03月20, 2013

警惕由eAccelerator导致的Apache进程崩溃

一、现象描述

某业务在访问量增大后,其服务器出现了Apache的worker进程频繁发生段错误退出的现象,查看error_log发现有如下记录:

[notice] child pid 17109 exit signal Segmentation fault (11)

二、示例

经过排查,发现在高并发时,eAccelerator可能导致以上的问题。

经过试验,发现下面简单的PHP脚本就可能会造成Segmentation Fault (测试环境是Apache + PHP (5.2.5) + EA (0.9.5.3):

function test($option = array(‘ab3kfksdfksdf233r3’ => ‘abckd’))

{

return ‘asdfsdfasdfiiis’;

}

echo test();

三、问题的根源

导致这个问题的根本原因是,在多核机器上,PHP内核中对变量引用计数的加减不是原子操作。PHP中的每个值都是用一个zval_struct结构来表示,其中对变量的引用计数refcount的增减操作是非原子性的。

struct _zval_struct {

zvalue_value value;

zend_uint refcount;   /zend_uint即 unsigned int/

zend_uchar type;

zend_uchar is_ref;

};

那到底是什么原因导致进程崩溃呢?我们来一步步揭开其根源。

3.1 eAccelerator工作原理

当apache处理php脚本请求时,会调用php模块在apache中的注册的钩子函数php_handler,在这个函数中会进行一些检查、初始化等操作,然后调用zend_execute_scripts来执行具体的脚本。zend_execute_scripts执行时会先调用zend_compile_file对脚本进行编译,生成zend_op_array,然后再调用zend_execute来执行这个字节码。

如果php脚本没有被修改过,那么每次执行前都对脚本进行编译是没有必要的,而且编译是一个比较耗资源的过程。php的eAccelerator(以下简称EA)模块的作用就是减少这个编译过程,加速脚本执行。具体做法是挂入钩子函数:

zend_compile_file = eaccelerator_compile_file;

这样zend引擎每次编译脚本时,实际是调用的eaccelerator_compile_file,EA在这个函数中对zend_op_array做了一层cache,如果发现文件最近没有修改过,并且启用了caching机制,就会直接从cache中读取并恢复zend_op_array,然后返回这些字节码以便后续执行。如果文件有修改过,则会重新编译一份,存在EA的cache中然后并返回编译的脚本字节码,如下图:

了解了这个原理后,我们回过头看下为什么文章开始的那个php脚本,在某种条件下会使得进程崩溃呢?

3.2 浅拷贝

当第一次收到处理请求时,由于没有命中EA的cache,则会对php脚本进行编译。在php代码编译阶段,函数的默认参数$option = array(‘ab3kfksdfksdf233r3’ => ‘abckd’),会被编译成一个hash表结构,存在EA的共享内存中。

当执行test函数时,由于没有传递参数,则会使用函数的默认参数$option。因为EA在编译时将$option数组的哈希表存在了共享内存中,这时会调用zend_hash_copy函数将变量从共享内存中拷贝一份到当前函数的执行环境中,用gdb可看到拷贝时的内存结构(点击查看大图):
12
其中ht=0x1f5eaee8是执行时的hash表,source=0x2afe8c33c3d8是变量在EA共享内存中的位置。可以看到’ab3kfksdfksdf233r3’存在起始位置在0x2afe8c33c4a0的内存中。zend_hash_copy会调用zend_hash_quick_add_or_update函数将source哈表中每一个key-value插入到新的哈希表ht。进一步可以看到共享内存中hash表的结构(点击查看大图):
13
共享内存中的hash表的arKey,pDataPtr分别存了hash表$option中第一个记录的key与val的指针。通过pmap查看进程的内存map,可以看到这些地址是位于EA的共享内存中的,起始位置00002afe8c33a000,共128M。

00002afe8c336000 16K rw— [ anon ]

00002afe8c33a000 131072K rw-s- /dev/zero (deleted)

00007fffc80c0000 84K rw— [ stack ]

查看EA创建共享内存的代码,恰好是使用文件内存映射 /dev/zero。

int fd = open(“/dev/zero”, O_RDWR, S_IRUSR | S_IWUSR);

p = (MM*)mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

在php内核Hash表的Bucket中,实际的数据是保存在pData指针指向的内存块中,通常这个内存块是系统另外分配的。但有一种情况例外,就是当Bucket保存的数据是一个指针时,HashTable将不会另外请求系统分配空间来保存这个指针,而是直接将该指针保存到pDataPtr中,然后再将pData指向该结构成员的地址,这样可以提高效率。

在执行zend_hash_copy函数拷贝hash表时,对于hash表中指针类型的value是进行的浅拷贝,实际上只是拷贝了数据元素的value(即’abckd’变量)的指针,并没有拷贝实际的值,在拷贝完后将指向变量的引用计数加1。在执行完该函数后,用gdb可见:新的啥希表PDataPtr还是指向共享内存的变量的(点击查看大图):
14
如果不是使用默认参数数组的话,是不会出现浅拷贝现象的。例如下面的代码

function test()

{

$option = array(‘ab3kfksdfksdf233r3’ => ‘abckd’);

return ‘asdfsdfasdfiiis’;

}

echo test();

因为 $option = array(‘ab3kfksdfksdf233r3’ => ‘abckd’);是函数执行的代码,array(‘ab3kfksdfksdf233r3’ => ‘abckd’)会执行过程中动态创建的,并不是事先编译好存放在共享内存中,所以不会出现这个问题。

3.3 非原子操作

当test函数执行完成后,会去释放函数中的符号表,进而释放$option参数,这时该哈希表会被释放,释放哈希表中的value代码如下:

ZEND_API void _zval_ptr_dtor(zval **zval_ptr ZEND_FILE_LINE_DC)

{

(*zval_ptr)->refcount–;

if ((*zval_ptr)->refcount==0) {

zval_dtor(*zval_ptr);

safe_free_zval_ptr_rel(*zval_ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_CC);

} else if ((*zval_ptr)->refcount == 1) {

if ((*zval_ptr)->type == IS_OBJECT) {

TSRMLS_FETCH();

if (EG(ze1_compatibility_mode)) {

return;

}

}

(*zval_ptr)->is_ref = 0;

}

函数首先会将引用计数减1,当引用计数为0时才会去释放真正的value,释放函数safe_free_zval_ptr_rel()最终会调用zend_mm_free_int()(下文会涉及到这个函数)。

在正常情况下编译后value的初始引用计数初始是1,然后每次执行php 代码在默认变量赋值时zend_hash_copy中会将引用加1,执行完函数时释放会进行减1。这样来看,在函数执行完后进行释放时引用计数肯定是>=1的,所以从来不会真正释放这个变量。但问题就出现在这里,由于变量是存在共享内存中的,变量的refcount也是存在共享内存中。如下(点击查看大图):
15
但在多核的系统中,这个操作(*zval_ptr)->refcount–)不是原子的。所以在并发比较高的时候,多个进程可能同时执行这条语句,使得加的次数小于减的次数,从而引用计数出现等于0的情况,这时就会去释放这个位于共享内存中的变量了。

那为什么只有在多核环境下才会出现呢?

因为在CISC处理器(如x86)上,gcc会将i++编译成addl指令。在单处理器上,该操作是原子的。但在多核机器上就不是原子操作了。因为处理器执行该指令时,首先会从内存中将i取出放在寄存器中,然后对寄存器中的值++,最后再写回内存中,这个过程涉及到两次内存访问,所以在多CPU机器上,这个操作就不是原子的了。如果要让自增操作为原子的,必须对总线加锁才行。

3.4 无效地址访问

来看下php的释放过程,在释放变量时最终会调用zend_mm_free_int函数来释放该value块的内存(_zend_mm_free_int其实不是释放int类型变量,而是释放一小块内存)。

static void _zend_mm_free_int(zend_mm_heap heap, void p ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)

{

zend_mm_block *mm_block;

zend_mm_block *next_block;

size_t size;

if (!ZEND_MM_VALID_PTR(p)) {

return;

}

mm_block = ZEND_MM_HEADER_OF(p);

size = ZEND_MM_BLOCK_SIZE(mm_block); 由于共享内存中是没有这个管理块的,所以这个mm_block的内容是不确定的,有时size会非常大。

ZEND_MM_CHECK_PROTECTION(mm_block);

… …

heap->size -= size;

next_block = ZEND_MM_BLOCK_AT(mm_block, size);

if (ZEND_MM_IS_FREE_BLOCK(next_block)) { //此处发生Segmentation fault

… …

}

PHP的内存管理与glibc中的有一点类似,每一个申请的内存块之前都会记录该块内存的大小。php内核在释放一块内存时,首先会找到该块内存的信息管理块mm_block,位于变量指针p的前面,这个结构体主要包含zend_mm_block_info这个结构体在64位的机器上是16字节)。

typedef struct _zend_mm_block_info {

size_t _size;    当前block的内存大小

size_t _prev    前面block的内存大小

} zend_mm_block_info;

释放时会根据mm_block中记录的大小来回收这块内存,交还给mm_heap。此时没有真正的释放内存,只是将这块内存做了free的标记,加入free list中,所以此时也不会崩溃。之后会根据mm_block信息来确定前面或者后面的mm_block是否也为free状态,如果也是空闲状态,就会将两小块free block合并成一大块,这样优化内存的使用,减少碎片。

回到zend_mm_free_int函数,指针p为value所在地址,由于该string是EA在编译期间创建,而不是在php动态运行期间申请的内存,所以不属于php内存管理中的范畴,也就没有相应的mm_block结构,于是当php内存管理模块释放该块内存时,解析出的mm_block内容是不确定的。对于例子中的$option存储结构,当释放此hash表中的元素时,由于struct _zval_struct(就是存的” abckd”字符串)位于0x2afe8c33c4b8,释放时会在该地址的前16字节处找到mm_block(位于0x2afe8c33c4a8)。看下如果将该地址处解析成mm_block会是什么样子:

可以看到_size = 3689347935099906918,将这个size加上基址0x2afe8c33c4a8后,那next block的起始位置就位于0x33335d64f0a7300e,显然这是一个无效地址,并不在当前进程的有效地址空间中,访问该地址时就发生Segmentation Fault了。

在测试过程中,如果示例中$option = array(‘abckd’)时,几乎不会发生崩溃的情况,那是为什么呢?用gdb查看那块内存内容:

可见,这块内存刚好存的就是哈希表的key——’ab3kfksdfksdf233r3’,与mm_bock有一段地址相交。如果这个key只有几个字符或是int型的,可能将不会覆盖到mm_block的size位置,此时mm_block的size很可能就是0,也就是为什么这种情况下几乎不会出现Segmenation Fault了。

四、结论

由此可知,发生Segmenation Fault需要同时满足如下几个条件:

1 使用eAccelerator并且启用caching机制,这样编译php脚本后的opcode会保存在共享内存中。

2 php函数中使用默认数组参数这种特殊结构,并且数组中元素的value为字符串类型。这样在编译时会将这个数组保存到一个哈希表中。使用默认参数调用函数时,由于在赋值实参时采用浅拷贝,所以执行时的变量实际是位于共享内存中。

3 在多核机器上且并发度较高,使得非原子的操作refcount,导致其值变为0,进而在释放资源时会去释放共享内存中的变量。

4 最后释放共享内存中的变量时,解析出的mm_block中size为一个很大的值,这样下一个mm_block的位置就是一个无效的地址,访问该地址就会出现段错误了。例如此例中的key为’ab3kfksdfksdf233r3’,刚好覆盖mm_block变量,如果key为数字或者几个字符,那么mm_block中的size有可能还是0,也不会发生段错误了 。

五、解决办法

知道了原因之后,很容易得出如下几个解决方案:

1 在php的代码编写中,避免类似于使用数组默认参数的写法。

2 使用其它opcode缓存扩展如apc。apc在opcode恢复时采用的是深度复制策略,会遍历每条opcode指令,将opcode的操作数完全复制一份到执行进程的内存中。

3 修改php源码,将对变量refcount的操作改成原子的。

4 修改EA扩展,将变量的refcount初始值设置为一个很大的值,这样refcount为0的概率就很小了,但这种发法又可能引起其它副作用,而且具体实现时需要在编译完成后去遍历一遍编译好的optcode,修改符合条件的refcount(因为ea编译php代码是调用php内核中的编译接口,所以在编译阶段没办法将refcount修改,只能等编译好后再遍历opcode进行修改)。看来这样方案不是太妥。

本文链接:http://blogs.360.cn/post/警惕由eaccelerator导致的apache进程崩溃.html

-- EOF --

Comments