记一次zend引擎的采坑事件

时间:2022-05-27 18:11:14

开放平台有一个通知的需求,需要一个消费进程不断的去读取buffer中的数据,然后消费并删除。于是,我用cron每分钟起一个php进程去读取数据库,并做通知的工作。同时,增加了一个文件锁,保证同一时间只能有一个进程在干活。

某一天,突然发现buffer中的数据有增无减,进程的工作日志也已经没有输出了。ps看了一下进程,还是处于运行态,ltracestrace都是空,看上去像是进入了某个while(true)的逻辑,看了一下CPU,果真,已经飙到98%了。gdb到这个进程,发现进程纠结在php_shutdown_handler这个函数,php的错误日志显示out of memory的错误。至此,事故的原因已经有所眉目了。

1. 事故原因

我们在php进程的退出事件中注册了一个回调函数php_shutdown_handler,在函数中做了上报monitor和报警等操作。我们可以想象一下,如果进程退出的原因是内存不足时,会发生什么情况?

记一次zend引擎的采坑事件

正如想象,zend引擎进入了上图所示的死循环状态,因内存不足导致进程需要退出,在退出前进入了我们注册的php_shutdown_handler函数,在此函数中需要进程留下遗言,然而遗言讲到一半,再一次out of memory,又一次触发了退出事件,并进入php_shutdown_handler函数,从而进入了无限的递归循环当中,再也无法走到正确退出的逻辑。从而导致了我们之前观察到的那些现象。

2. 解决方法

2.1 利用ini_set()增大进程的内存限制

这是我第一个想到的解决办法。由于在cgi模式下,同一台机器上运行的进程有成百上千,zend为了保护系统资源,默认为每一个php进程做了内存限制,每次zend需要申请内存时会检查一下配置项中的memory_limit, 如果超出限制,会直接退出进程。这个配置项可以通过修改php.ini中的参数来改变,也可以通过调用ini_set('memory_limit')函数来在进程执行的过程中动态的修改。所以,这个想法也就自然而然的产生了,即在php_shutdown_handler中调用ini_set将原有的内存限制(memory_limit)扩展为2倍。

$mem_limit = ini_get('memory_limit'); //64M
$num = intval(substr($mem_limit, 0, strlen($mem_limit)-1)); //64
$last = $mem_limit[strlen($mem_limit)]-1; //M
$mem_limit = $num * 2 . $last;  //128M
ini_set('memory_limit', $mem_limit);

 比较不方便的是,通过ini_get()调用得到的memory_limit是"64M"格式的字符串,想要double的话,需要做一些字符串变换,而这些字符串变换操作依然需要申请内存。所以上述方法是行不通的。

那我们用一个暴力点的办法吧,直接将内存限制改为1G。记一次zend引擎的采坑事件

ini_set('memory_limit', '1024M');

测试了一下,依然没有解决问题记一次zend引擎的采坑事件。我们知道php的配置项在zend当中存放在一个配置哈希表当中,虽然我们仅仅修改了其中的一个值,也占用了一些内存(实际测下来大约为500B),在极限情况下还是无法解决问题。

2.2 先占个坑,再释放

既然加内存的方法已经失败了,我们再换个方法试试看吧。一个比较直观的方法就是先占个坑,需要内存的时候,释放这个坑,再用这部分内存就行了。

step1: 

在注册函数之前,在全局变量中预分配一个1M的内存。

//预先非配内存
$_SERVER['php_shutdown_mem_pre_alloc'] = str_repeat('*', 1024 * 1024 );
//注册回调函数
register_shutdown_function('php_shutdown_handler');

step2:

在回调函数中回收内存,将预分配的全局变量回收。

// php异常出错跳出的处理函数
function php_shutdown_handler() {
    unset($_SERVER['php_shutdown_mem_pre_alloc']);

    $error = error_get_last(); // 如果没错返回null
    PHPShutdownHandler::report($error);
}

好,再去测试一下。结果也在意料之中,又失败了记一次zend引擎的采坑事件

回想一下,当时确实有些操之过急,你以为php的unset就是delete操作么?那就错了,一般来说php这种神奇的脚本语言都有自己的垃圾回收机制的。如果想立即启用gc,还需要一点点操作。

// php异常出错跳出的处理函数
function php_shutdown_handler() {
    unset($_SERVER['php_shutdown_mem_pre_alloc']);
    gc_collect_cycles();

    $error = error_get_last(); // 如果没错返回null
    PHPShutdownHandler::report($error);
}

 主动调用gc_collect_cycles()立即启用垃圾回收机制,问题就迎刃而解了。

3. 一些优化

当然,这样的代码还不能立即发布,原因是生产环境的代码是在php auto prepend当中注册退出回调函数的,也就意味着所有的php脚本都会执行这段代码。如果预分配和进程结束都加逻辑的话,那么造成的时间成本可能让所有服务器性能优化的努力付诸东流。另外,由于每个进程预分配了1M的内存,假如一台机器上配置了500个php-fpm进程,那么当他们同时执行的时候,预分配的无用内存就占到了500M。这在高峰期是不能容忍的内存消耗。

3.1 预分配内存时间消耗测试

我们预分配函数str_repeat()等消耗的时间做了测试,平均大概在0.7ms左右,这个时间成本相对于一个生产环境的CGI来说是可以忽略不计的。

3.2 上报方法内存消耗测试

内存消耗的测试测试的结果显示,一次上报大概消耗260KB左右的内存。如果一台机器启用300个php-fpm, 每个预分配400KB的内存,则预分配的内存峰值大约是120M。经过讨论,这样的开销在合理范围内,至此问题便解决了。

总结来说,在PHP 5.3的版本当中,zend没有保证_onExit注册的函数最多只能执行一次(后续版本是否修复此bug还没有验证),而PHP作为一种脚本语言又不太容易直接操作内存,使得问题的解决方法不是特别直观。