首頁  >  文章  >  後端開發  >  [翻譯][php擴充開發與嵌入式]第3章-記憶體管理

[翻譯][php擴充開發與嵌入式]第3章-記憶體管理

黄舟
黄舟原創
2017-02-09 11:25:271173瀏覽

記憶體管理

php和c最重要的區別就是是否控制記憶體指標.

記憶體

在php中, 設定一個字串變數很簡單: , 字元字串可以自由的修改, 拷貝, 移動. 在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的關係:

[翻譯][php擴充開發與嵌入式]第3章-記憶體管理

除了提供隐式的内存清理, 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))
传统分配器 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 = &#39;Hello World&#39;;  
    $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擴充開發與嵌入式]第3章-記憶體管理

类似的, 下面的代码块将导致相同的冲突, 并强制值隔离到一个拷贝中(如下图)

[翻譯][php擴充開發與嵌入式]第3章-記憶體管理

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

注意, 这里两种情况下, $b都和原来的zval对象关联, 因为在隔离发生的时候, 引擎不知道操作中涉及的第三个变量的名字.

小结

php是一种托管语言. 从用户空间一侧考虑, 小心的控制资源和内存就意味着更容易的原型涉及和更少的崩溃. 在你深入研究揭开引擎的面纱后, 就不能再有博彩心里, 而是对运行环境完整性的开发和维护负责.

以上就是 [翻译][php扩展开发和嵌入式]第3章-内存管理的内容,更多相关内容请关注PHP中文网(www.php.cn)!


陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn