[翻譯][php擴展開發和嵌入式]第3章-內存管理

全部翻譯內容pdf文檔下載地址: http://download.csdn.net/detail/lgg201/5107012

本書目前在github上由laruence(http://www.laruence.com)和walu(http://www.walu.cc)兩位大牛組織翻譯. 該翻譯項目地址爲: https://github.com/walu/phpbook

原書名: <Extending and Embedding PHP>

原作者: Sara Golemon

譯者: goosman.lei(雷果國)

譯者Email: [email protected]

譯者Blog: http://blog.csdn.net/lgg201

內存管理

php和c最重要的區別就是是否控制內存指針.

內存

在php中, 設置一個字符串變量很簡單: <?php $str = 'hello world'; ?>, 字符串可以自由的修改, 拷貝, 移動. 在C中, 則是另外一種方式, 雖然你可以簡單的用靜態字符串初始化: char *str = "hello world"; 但是這個字符串不能被修改, 因爲它存在於代碼段. 要創建一個可維護的字符串, 你需要分配一塊內存, 並使用一個strdup()這樣的函數將內容拷貝到其中.

{
   char *str;

   str = strdup("hello world");
   if (!str) {
       fprintf(stderr, "Unable to allocate memory!");
   }
}

傳統的內存管理函數(malloc(), free(), strdup(), realloc(), calloc()等)不會被php的源代碼直接使用, 本章將解釋這麼做的原因.

釋放分配的內存

內存管理在以前的所有平臺上都以請求/釋放的方式處理. 應用告訴它的上層(通常是操作系統)"我想要一些內存使用", 如果空間允許, 操作系統提供給程序, 並對提供出去的內存進行一個記錄.

應用使用完內存後, 應該將內存還給OS以使其可以被分配給其他地方. 如果程序沒有還回內存, OS就沒有辦法知道這段內存已經不再使用, 這樣就無法分配給其他進程. 如果一塊內存沒有被釋放, 並且擁有它的應用丟失了對它的句柄, 我們就稱爲"泄露", 因爲已經沒有人可以直接得到它了.

在典型的客戶端應用中, 小的不頻繁的泄露通常是可以容忍的, 因爲進程會在一段時間後終止, 這樣泄露的內存就會被OS回收. 並不是說OS很牛知道泄露的內存, 而是它知道爲已經終止的進程分配的內存都不會再使用.

對於長時間運行的服務端守護進程, 包括apache這樣的webserver, 進程被設計爲運行很長週期, 通常是無限期的. 因此OS就無法干涉內存使用, 任何程度的泄露無論多小都可能累加到足夠導致系統資源耗盡.

考慮用戶空間的stristr()函數; 爲了不區分大小寫查找字符串, 它實際上爲haystack和needle各創建了一份小寫的拷貝, 接着執行普通的區分大小寫的搜索去查找相關的偏移量. 在字符串的偏移量被定位後, haystack和needle字符串的小寫版本都不會再使用了. 如果沒有釋放這些拷貝, 那麼每個使用stristr()的腳本每次被調用的時候都會泄露一些內存. 最終, webserver進程會佔用整個系統的內存, 但是卻都沒有使用.

完美的解決方案是編寫良好的, 乾淨的, 一致的代碼, 保證它們絕對正確. 不過在php解釋器這樣的環境中, 這只是解決方案的一半.

錯誤處理

爲了提供從用戶腳本的激活請求和所在的擴展函數中跳出的能力, 需要存在一種方法跳出整個激活請求. Zend引擎中的處理方式是在請求開始的地方設置一個跳出地址, 在所有的die()/exit()調用後, 或者碰到一些關鍵性錯誤(E_ERROR)時, 執行longjmp()轉向到預先設置的跳出地址.

雖然這種跳出處理簡化了程序流程, 但它存在一個問題: 資源清理代碼(比如free()調用)會被跳過, 會因此帶來泄露. 考慮下面簡化的引擎處理函數調用的代碼:

void call_function(const char *fname, int fname_len TSRMLS_DC)
{
    zend_function *fe;
    char *lcase_fname;
    /* php函數是大小寫不敏感的, 爲了簡化在函數表中對它們的定位, 所有的函數名都隱式的翻譯爲小寫 */
    lcase_fname = estrndup(fname, fname_len);
    zend_str_tolower(lcase_fname, fname_len);

    if (zend_hash_find(EG(function_table),
            lcase_fname, fname_len + 1, (void **)&fe) == FAILURE) {
        zend_execute(fe->op_array TSRMLS_CC);
    } else {
        php_error_docref(NULL TSRMLS_CC, E_ERROR,
                         "Call to undefined function: %s()", fname);
    }
    efree(lcase_fname);
}

當php_error_docref()一行執行到時, 內部的處理器看到錯誤級別是關鍵性的, 就調用longjmp()中斷當前程序流, 離開call_function(), 這樣就不能到達efree(lcase_fname)一行. 那你就可能會想, 把efree()行移動到php_error_docref()上面, 但是如果這個call_function()調用進入第一個條件分支呢(查找到了函數名, 正常執行)? 還有一點, fname自己是一個分配的字符串, 並且它在錯誤消息中被使用, 在使用完之前你不能釋放它.

php_error_docref()函數是一個內部等價於trigger_error(). 第一個參數是一個可選的文檔引用, 如果在php.ini中啓用它將被追加到docref.root後面. 第三個參數可以是任意的E_*族常量標記錯誤的嚴重程度. 第四個和後面的參數是符合printf()樣式的格式串和可變參列表.

Zend內存管理

由於請求跳出(故障)產生的內存泄露的解決方案是Zend內存管理(ZendMM)層. 引擎的這一部分扮演了相當於操作系統通常扮演的角色, 分配內存給調用應用. 不同的是, 站在進程空間請求的認知角度, 它足夠底層, 當請求die的時候, 它可以執行和OS在進程die時所做的相同的事情. 也就是說它會隱式的釋放所有請求擁有的內存空間. 下圖展示了在php進程中ZendMM和OS的關係:


除了提供隱式的內存清理, ZendMM還通過php.ini的設置memory_limit控制了每個請求的內存使用. 如果腳本嘗試請求超過系統允許的, 或超過單進程內存限制剩餘量的內存, ZendMM會自動的引發一個E_ERROR消息, 並開始跳出進程. 一個額外的好處是多數時候內存分配的結果不需要檢查, 因爲如果失敗會立即longjmp()跳出到引擎的終止部分.

在php內部代碼和OS真實的內存管理層之間hook的完成, 最複雜的是要求所有內部的內存分配要從一組函數中選擇. 例如, 分配一個16字節的內存塊不是使用malloc(16), php代碼應該使用emalloc(16). 除了執行真正的內存分配任務, ZendMM還要標記內存塊所綁定請求的相關信息, 以便在請求被故障跳出時, ZendMM可以隱式的釋放它(分配的內存).

很多時候內存需要分配, 並使用超過單請求生命週期的時間. 這種分配我們稱爲持久化分配, 因爲它們在請求結束後持久的存在, 可以使用傳統的內存分配器執行分配, 因爲它們不可以被ZendMM打上每個請求的信息. 有時, 只有在運行時才能知道特定的分配需要持久化還是不需要, 因此ZendMM暴露了一些幫助宏, 由它們來替代其他的內存分配函數, 但是在末尾增加了附加的參數來標記是否持久化.

如果你真的想要持久化的分配, 這個參數應該被設置爲1, 這種情況下內存分配的請求將會傳遞給傳統的malloc()族分配器. 如果運行時邏輯確定這個塊不需要持久化 則這個參數被設置爲0, 調用將會被轉向到單請求內存分配器函數.

例如, pemalloc(buffer_len, 1)映射到malloc(buffer_len), 而pemalloc(buffer_len, 0)映射到emalloc(buffer_len), 如下:

#define in Zend/zend_alloc.h:

#define pemalloc(size, persistent) \
            ((persistent)?malloc(size): emalloc(size))

ZendMM提供的分配器函數列表如下, 並列出了它們對應的傳統分配器.


傳統分配器

php中的分配器

void *malloc(size_t count);

void *emalloc(size_t count);

void *pemalloc(size_t count, char persistent);

void *calloc(size_t count);

void *ecalloc(size_t count);

void *pecalloc(size_t count, char persistent);

void *realloc(void *ptr, size_t count);

void *erealloc(void *ptr, size_t count);

void *perealloc(void *ptr, size_t count, char persistent);

void *strdup(void *ptr);

void *estrdup(void *ptr);

void *pestrdup(void *ptr, char persistent);

void free(void *ptr);

void efree(void *ptr);

void pefree(void *ptr, char persistent);


你可能注意到了, pefree要求傳遞持久化標記. 這是因爲在pefree()調用時, 它並不知道ptr是否是持久分配的. 在廢持久分配的指針上調用free()可能導致雙重的free, 而在持久化的分配上調用efree()通常會導致段錯誤, 因爲內存管理器會嘗試查看管理信息, 而它不存在. 你的代碼需要記住它分配的數據結構是不是持久化的.

除了核心的分配器外, ZendMM還增加了特殊的函數:

void *estrndup(void *ptr, int len);

它分配len + 1字節的內存, 並從ptr拷貝len個字節到新分配的塊中. estrndup()的行爲大致如下:

void *estrndup(void *ptr, int len)
{
    char *dst = emalloc(len + 1);
    memcpy(dst, ptr, len);
    dst[len] = 0;
    return dst;
}

終止NULL字節被悄悄的放到了緩衝區末尾, 這樣做確保了所有使用estrndup()進行字符串賦值的函數不用擔心將結果緩衝區傳遞給期望NULL終止字符串的函數(比如printf())時產生錯誤. 在使用estrndup()拷貝非字符串數據時, 這個最後一個字節將被浪費, 但是相比帶來的方便, 這點小浪費就不算什麼了.

void *safe_emalloc(size_t size, size_t count, size_t addtl);
void *safe_pemalloc(size_t size, size_t count, size_t addtl, char persistent);

這兩個函數分配的內存大小是((size * count) + addtl)的結果. 你可能會問, "爲什麼要擴充這樣一個函數? 爲什麼不是使用emalloc/pemalloc, 然後自己計算呢?" 理由來源於它的名字"安全". 儘管這種情況很少有可能發生, 但仍然是有可能的, 當計算的結果溢出所在主機平臺的整型限制時, 結果會很糟糕. 可能導致分配負的字節數, 更糟的是分配一個正值的內存大小, 但卻小於所請求的大小. safe_emalloc()通過檢查整型溢出避免了這種類型的陷阱, 如果發生溢出, 它會顯式的報告失敗.

並不是所有的內存分配例程都有p*副本. 例如, pestrndup()和safe_pemalloc()在php 5.1之前就不存在. 有時你需要在ZendAPI的這些不足上工作.

引用計數

在php這樣長時間運行的多請求進程中謹慎的分配和釋放內存非常重要, 但這只是一半工作. 爲了讓高併發的服務器更加高效, 每個請求需要使用儘可能少的內存, 最小化不需要的數據拷貝. 考慮下面的php代碼片段:

<?php
    $a = 'Hello World';
    $b = $a;
    unset($a);
?>

在第一次調用後, 一個變量被創建, 它被賦予12字節的內存塊, 保存了字符串"Hello world"以及結尾的NULL. 現在來看第二句: $b被設置爲和$a相同的值, 接着$a被unset(釋放)

如果php認爲每個變量賦值都需要拷貝變量的內容, 那麼在數據拷貝期間就需要額外的12字節拷貝重複的字符串, 以及額外的處理器負載. 在第三行出現的時候, 這種行爲看起來就有些可笑了, 原來的變量被卸載使得數據的複製完全不需要. 現在我們更進一步想想當兩個變量中被裝載的是一個10MB文件的內容時, 會發生什麼? 它需要20MB的內存, 然而只要10MB就足夠了. 引擎真的會做這種無用功浪費這麼多的時間和內存嗎?

你知道php是很聰明的.

還記得嗎? 在引擎中變量名和它的值是兩個不同的概念. 它的值是自身是一個沒有名字的zval *. 使用zend_hash_add()將它賦值給變量$a. 那麼兩個變量名指向相同的值可以嗎?

{
    zval *helloval;
    MAKE_STD_ZVAL(helloval);
    ZVAL_STRING(helloval, "Hello World", 1);
    zend_hash_add(EG(active_symbol_table), "a", sizeof("a"),
                                           &helloval, sizeof(zval*), NULL);
    zend_hash_add(EG(active_symbol_table), "b", sizeof("b"),
                                           &helloval, sizeof(zval*), NULL);
}

此時, 在你檢查$a或$b的時候, 你可以看到, 它們實際都包含了字符串"Hello World". 不幸的是, 接着來了第三行: unset($a);. 這種情況下, unset()並不知道$a指向的數據還被另外一個名字引用, 它只是釋放掉內存. 任何後續對$b的訪問都將查看已經被釋放的內存空間, 這將導致引擎崩潰. 當然, 你並不希望引擎崩潰.

這通過zval的第三個成員: refcount解決. 當一個變量第一次被創建時, 它的refcount被初始化爲1, 因爲我們認爲只有創建時的那個變量指向它. 當你的代碼執行到將helloval賦值給$b時, 它需要將refcount增加到2, 因爲這個值現在被兩個變量"引用"

{
    zval *helloval;
    MAKE_STD_ZVAL(helloval);
    ZVAL_STRING(helloval, "Hello World", 1);
    zend_hash_add(EG(active_symbol_table), "a", sizeof("a"),
                                           &helloval, sizeof(zval*), NULL);
    ZVAL_ADDREF(helloval);
    zend_hash_add(EG(active_symbol_table), "b", sizeof("b"),
                                           &helloval, sizeof(zval*), NULL);
}

現在, 當unset()刪除變量的$a拷貝時, 它通過refcount看到還有別人對這個數據感興趣, 因此它只是將refcount減1, 其他什麼事情都不做.

寫時複製

通過引用計數節省內存是一個很好的主意, 但是當你只想修改其中一個變量時該怎麼辦呢? 考慮下面的代碼片段:

<?php
    $a = 1;
    $b = $a;
    $b += 5;
?>

看上面代碼的邏輯, 處理完後期望$a仍然等於1, 而$b等於6. 現在你知道, Zend爲了最大化節省內存, 在第二行代碼執行後$a和$b只想同一個zval, 那麼到達第三行代碼時會發生什麼呢? $b也會被修改嗎?

答案是Zend查看refcount, 看到它大於1, 就對它進行了隔離. Zend引擎中的隔離是破壞一個引用對, 它和你剛纔看到的處理是對立的:

zval *get_var_and_separate(char *varname, int varname_len TSRMLS_DC)
{
    zval **varval, *varcopy;
    if (zend_hash_find(EG(active_symbol_table),
                       varname, varname_len + 1, (void**)&varval) == FAILURE) {
       /* 變量不存在 */
       return NULL;
   }
   if ((*varval)->refcount < 2) {
       /* 變量名只有一個引用, 不需要隔離 */
       return *varval;
   }
   /* 其他情況, 對zval *做一次淺拷貝 */
   MAKE_STD_ZVAL(varcopy);
   varcopy = *varval;
   /* 對zval *進行一次深拷貝 */
   zval_copy_ctor(varcopy);

   /* 破壞varname和varval之間的關係, 這一步會將varval的引用計數減小1 */
   zend_hash_del(EG(active_symbol_table), varname, varname_len + 1);

   /* 初始化新創建的值的引用計數, 併爲新創建的值和varname建立關聯 */
   varcopy->refcount = 1;
   varcopy->is_ref = 0;
   zend_hash_add(EG(active_symbol_table), varname, varname_len + 1,
                                        &varcopy, sizeof(zval*), NULL);
   /* 返回新的zval * */
   return varcopy;
}

現在引擎就有了一個只被$b變量引用的zval *, 就可以將它轉換爲long, 並將它的值按照腳本請求增加5.

寫時修改

引用計數的概念還創建了一種新的數據維護方式, 用戶空間腳本將這種方式稱爲"引用". 考慮下面的用戶空間代碼片段:

<?php
    $a = 1;
    $b = &$a;
    $b += 5;
?>

憑藉你在php方面的經驗, 直覺上你可能認識到$a的值現在應該是6, 即便它被初始化爲1並沒有被(直接)修改過. 發生這種情況是因爲在引擎將$b的值增加5的時候, 它注意到$b是$a的一個引用, 它就說"對於我來說不隔離它的值就修改是沒有問題的, 因爲我原本就想要所有的引用變量都看到變更"

但是引擎怎麼知道呢? 很簡單, 它查看zval結構的最後一個元素: is_ref. 它只是一個簡單的開關, 定義了zval是值還是用戶空間中的引用. 在前面的代碼片段中, 第一行執行後, 爲$a創建的zval, refcount是1, is_ref是0, 因爲它僅僅屬於一個變量($a), 並沒有其他變量的引用指向它. 第二行執行時, 這個zval的refcount增加到2, 但是此時, 因爲腳本中增加了一個取地址符(&)標記它是引用傳值, 因此將is_ref設置爲1.

最後, 在第三行中, 引擎獲得$b關聯的zval, 檢查是否需要隔離. 此時這個zval不會被隔離, 因爲在前面我們沒有包含的一段代碼(如下). 在get_var_and_separate()中檢查refcount的地方, 還有另外一個條件:

if ((*varval)->is_ref || (*varval)->refcount < 2) {
    /* varname只有在真的是引用方式, 或者只被一個變量引用時纔會不發生隔離 */
    return *varval;
}

此時, 即便refcount爲2, 隔離處理也會被短路, 因爲這個值是引用傳值的. 引擎可以自由的修改它而不用擔心引用它的其他變量被意外修改.

隔離的問題

對於這些拷貝和引用, 有一些組合是is_ref和refcount無法很好的處理的. 考慮下面的代碼:

<?php
    $a = 1;
    $b = $a;
    $c = &$a;
?>

這裏你有一個值需要被3個不同的變量關聯, 兩個是寫時修改的引用方式, 另外一個是隔離的寫時複製上下文. 僅僅使用is_ref和refcount怎樣來描述這種關係呢?

答案是: 沒有. 這種情況下, 值必須被複制到兩個分離的zval *, 雖然兩者包含相同的數據. 如下圖:


類似的, 下面的代碼塊將導致相同的衝突, 並強制值隔離到一個拷貝中(如下圖)


<?php
    $a = 1;
    $b = &$a;
    $c = $a;
?>

注意, 這裏兩種情況下, $b都和原來的zval對象關聯, 因爲在隔離發生的時候, 引擎不知道操作中涉及的第三個變量的名字.

小結

php是一種託管語言. 從用戶空間一側考慮, 小心的控制資源和內存就意味着更容易的原型涉及和更少的崩潰. 在你深入研究揭開引擎的面紗後, 就不能再有博彩心裏, 而是對運行環境完整性的開發和維護負責.


目錄
上一章: 變量的裏裏外外

發佈了123 篇原創文章 · 獲贊 1149 · 訪問量 130萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章