[翻译][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万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章