剖析php脚本的超时机制
在做php开发的时候,经常会设置max_input_time、max_execution_time,用来控制脚本的超时时间。但却从来没有思考过背后的原理。
趁着这两天有空,研究一下这个问题。文中源码取自php5.4.44版本。
超时配置
php的ini配置如何起作用,这是一个老生常谈的话题了。
首先,我们在php.ini里进行配置。当php启动的时候(php_module_startup阶段),会尝试读取ini文件并解析。解析过程简单来说,是分析ini文件,提取出其中合法的键值对,并保存到configuration_hash表。
OK,然后php会进一步调用zend_startup_extensions来启动各个模块(包含php Core模块,以及所有需要加载的扩展)。各个模块的启动函数中,会完成REGISTER_INI_ENTRIES动作。REGISTER_INI_ENTRIES负责将模块对应的一些配置从configuration_hash表取出,然后调用处理函数,最终将处理完的值存入模块的globals变量。
max_input_time、max_execution_time这两个配置属于php Core模块。对于php Core来说,REGISTER_INI_ENTRIES依然发生在php_module_startup中。同样属于php Core模块的配置还有expose_php、display_errors、memory_limit等等...
示意图如下:
---->php_module_startup----------->php_request_startup---->
|
|
|-->REGISTER_INI_ENTRIES
|
|
|-->zend_startup_extensions
| |
| |-->zm_startup_date
| | |-->REGISTER_INI_ENTRIES
| |
| |-->zm_startup_json
| | |-->REGISTER_INI_ENTRIES
|
|
|-->do otherthings
上面说到对于不同的配置,REGISTER_INI_ENTRIES会调用不同的函数来处理。我们直接来看max_execution_time对应的函数:
static PHP_INI_MH(OnUpdateTimeout)
{
// php启动阶段走这里
if (stage == PHP_INI_STAGE_STARTUP) {
// 将超时设置保存到EG(timeout_seconds)中
EG(timeout_seconds) = atoi(new_value);
return SUCCESS;
} // php执行过程中的ini set则走这里
zend_unset_timeout(TSRMLS_C);
EG(timeout_seconds) = atoi(new_value);
zend_set_timeout(EG(timeout_seconds), );
return SUCCESS;
}
暂时只看上半截,因为我们目前只需关注php的启动阶段,该函数行为很简单,将max_execution_time存入了EG(timeout_seconds)。
至于max_input_time,并没有特殊的处理函数,默认是会将max_input_time存入存入PG(max_input_time)。
因此,当REGISTER_INI_ENTRIES完成,发生的是:
max_execution_time ----> 存入EG(timeout_seconds)
max_input_time ----> 存入PG(max_input_time)
请求超时控制
现在我们搞清楚php的启动阶段发生了什么,继续来看php在实际处理请求的时候,如何管理超时。
在php_request_startup函数中有如下代码:
if (PG(max_input_time) == -) {
zend_set_timeout(EG(timeout_seconds), );
} else {
zend_set_timeout(PG(max_input_time), );
}
php_request_startup的时机很讲究。
以cgi为例,只有当php已经从CGI拿到了原始请求以及一些CGI的环境变量之后,php_request_startup才会被调用。上面这段代码实际执行的时候,由于请求已经拿到,所以SG(request_info)处于准备就绪状态,但是php中的$_GET
,$_POST
,$_FILE
等超全局变量尚未生成。
从代码上理解:
1、如果用户将max_input_time配做-1,或没有配置,那么脚本的生命周期就只受EG(timeout_seconds)约束。
2、否则,请求启动阶段的超时控制,受PG(max_input_time)约束。
3、zend_set_timeout函数负责设置定时器。一旦指定时间过去,定时器会通知php进程。zend_set_timeout下文会具体分析。
php_request_startup完成,则进入php的实际执行阶段,即php_execute_script。在php_execute_script中可以看到:
// 设定执行超时
if (PG(max_input_time) != -) {
#ifdef PHP_WIN32
zend_unset_timeout(TSRMLS_C); // 关闭之前的定时器
#endif
zend_set_timeout(INI_INT("max_execution_time"), );
} // 进入执行
retval = (zend_execute_scripts(ZEND_REQUIRE TSRMLS_CC, NULL, , prepend_file_p, primary_file, append_file_p) == SUCCESS);
OK,假如代码执行到这里,尚未发生max_input_time超时,则会重新指定max_execution_time的超时。
同样也是采取调用zend_set_timeout,并传入max_execution_time。特别注意一下,windows下面的需要显式调用zend_unset_timeout关闭原来的定时器,而linux下不需要。这是由于两个平台的定时器实现原理不同导致的,下文也会详细展开叙述。
最后用一张图表示超时控制的流程,左侧的case表明用户既配置了max_input_time,又配置了max_execution_time。而右侧的区别在于用户仅仅配置了max_execution_time:
zend_set_timeout
前文提到,zend_set_timeout函数用来设置定时器。具体来看下实现:
void zend_set_timeout(long seconds, int reset_signals) /* {{{ */
{
TSRMLS_FETCH(); // 赋值
EG(timeout_seconds) = seconds; #ifdef ZEND_WIN32
if(!seconds) {
return;
} // 启动定时器线程
if (timeout_thread_initialized == && InterlockedIncrement(&timeout_thread_initialized) == ) {
/* We start up this process-wide thread here and not in zend_startup(), because if Zend
* is initialized inside a DllMain(), you're not supposed to start threads from it.
*/
zend_init_timeout_thread();
} // 向线程发送WM_REGISTER_ZEND_TIMEOUT消息
PostThreadMessage(timeout_thread_id, WM_REGISTER_ZEND_TIMEOUT, (WPARAM) GetCurrentThreadId(),(LPARAM) seconds);
#else // linux平台下
struct itimerval t_r; /* timeout requested */
int signo; if (seconds) {
t_r.it_value.tv_sec = seconds;
t_r.it_value.tv_usec = t_r.it_interval.tv_sec = t_r.it_interval.tv_usec = ; // 设置定时器,seconds秒后会发送SIGPROF信号
setitimer(ITIMER_PROF, &t_r, NULL);
}
signo = SIGPROF; if (reset_signals) {
sigset_t sigset; // 设置SIGPROF信号对应的处理函数为zend_timeout
signal(signo, zend_timeout); // 防屏蔽
sigemptyset(&sigset);
sigaddset(&sigset, signo);
sigprocmask(SIG_UNBLOCK, &sigset, NULL);
}
#endif
}
上述实现基本上可以完全分成两种平台:
- 先看linux:
linux下的定时器要容易许多,调用setitimer函数就行,此外,zend_set_timeout还设定了SIGPROF信号的handler为zend_timeout。
注意,调用setitimer的时候,将it_interval设置成0,表明这个定时器只触发一次,而不会每隔一段时间触发一次。setitimer可以以三种方式计时,php中采用的是ITIMER_PROF,它同时计算了用户代码和内核代码的执行时间。一旦时间到了,会产生SIGPROF信号。
当php进程接收到SIGPROF信号,不管当前正在执行什么,都会跳转进入到zend_timeout。zend_timeout才是实际处理超时的函数。
- 再看windows:
首先会启动一个子线程,该线程主要用于设置定时器,同时维护EG(timed_out)变量。
子线程一旦生成,主线程便会向子线程发送一条消息:WM_REGISTER_ZEND_TIMEOUT。子线程接收到WM_REGISTER_ZEND_TIMEOUT之后,产生一个定时器并开始计时。同时,子线程会设置EG(timed_out) = 0。这很重要!windows平台下正是通过判断EG(timed_out)是否为1,来决定是否超时。
如果定时器到时间了,子线程收到WM_TIMER消息,则取消定时器,并且设置EG(timed_out) = 1。
如果需要关闭定时器,则子线程会收到WM_UNREGISTER_ZEND_TIMEOUT消息。关闭定时器,并不会改变EG(timed_out)。
相关代码还是很清晰的:
static LRESULT CALLBACK zend_timeout_WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message) {
case WM_DESTROY:
PostQuitMessage();
break; // 生成一个定时器,开始计时
case WM_REGISTER_ZEND_TIMEOUT:
/* wParam is the thread id pointer, lParam is the timeout amount in seconds */
if (lParam == ) {
KillTimer(timeout_window, wParam);
} else {
SetTimer(timeout_window, wParam, lParam*, NULL);
EG(timed_out) = ;
}
break; // 关闭定时器
case WM_UNREGISTER_ZEND_TIMEOUT:
/* wParam is the thread id pointer */
KillTimer(timeout_window, wParam);
break; // 超时了,也需关闭定时器
case WM_TIMER: {
KillTimer(timeout_window, wParam);
EG(timed_out) = ;
}
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
return ;
}
根据上文描述,最终都是需要跳转到zend_timeout来处理超时的。那windows下如何进入zend_timeout呢?
window下仅在execute函数中(zend_vm_execute.h刚开始的地方),可以看到调用zend_timeout:
while () {
int ret;
#ifdef ZEND_WIN32
if (EG(timed_out)) { // windows下的超时,执行每条opcode之前都判断是否需要调用zend_timeout
zend_timeout();
}
#endif if ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > ) {
...
}
}
上述代码可以看到:
在windows下,每执行完成一条opcode指令,就会进行一次超时判断。
因为主线程执行opcode的同时,子线程可能已经发生超时,而windows并没有什么机制可以让主线程停止手头的工作,直接跳入zend_timeout。所以只好利用子线程先将EG(timed_out)设置为1,然后主线程在等到当前opcode执行完成、进入下一条opcode之前,判断一下EG(timed_out)再调用zend_timeout。
因此准确的讲,windows的超时,其实是有一点点延时的。至少在某一个opcode执行的过程中,无法被打断。当然,正常情况下,单条opcode的执行时间会很短。但是可以很容易人为构造出一些很耗时的函数,使得function call需要等待较长时间。此时,如果子线程判断出超时了,则还需要经过漫长的等待,直到主线程完成该条opcode之后,才能调用zend_timeout。
zend_unset_timeout
void zend_unset_timeout(TSRMLS_D) /* {{{ */
{
#ifdef ZEND_WIN32 // 通过发送WM_UNREGISTER_ZEND_TIMEOUT消息来关闭定时器
if(timeout_thread_initialized) {
PostThreadMessage(timeout_thread_id, WM_UNREGISTER_ZEND_TIMEOUT, (WPARAM) GetCurrentThreadId(), (LPARAM) );
}
#else
if (EG(timeout_seconds)) {
struct itimerval no_timeout;
no_timeout.it_value.tv_sec = no_timeout.it_value.tv_usec = no_timeout.it_interval.tv_sec = no_timeout.it_interval.tv_usec = ; // 全置0,相当于关闭定时器
setitimer(ITIMER_PROF, &no_timeout, NULL);
}
#endif
}
zend_unset_timeout同样分成两种平台的实现。
- 先看linux:
linux下的关闭定时器也很简单。只要将struct itimerval中的4个值都设置为0,就行了。
- 再看windows:
由于windows是利用一个独立的线程来计时。因此,zend_unset_timeout会向该线程发送WM_UNREGISTER_ZEND_TIMEOUT消息。WM_UNREGISTER_ZEND_TIMEOUT对应的动作是去调用KillTimer来关闭定时器。注意,线程本身并不退出。
前文留下了一个问题,在php_execute_script中,windows下面要显示调用zend_unset_timeout来关闭定时器,而linux下不需要。因为对于一个linux进程来说,只能存在一个setitimer定时器。也就是说,重复调用setitimer,后面的定时器会直接覆盖前面的。
zend_timeout
ZEND_API void zend_timeout(int dummy) /* {{{ */
{
TSRMLS_FETCH(); if (zend_on_timeout) {
zend_on_timeout(EG(timeout_seconds) TSRMLS_CC);
} zend_error(E_ERROR, "Maximum execution time of %d second%s exceeded", EG(timeout_seconds), EG(timeout_seconds) == ? "" : "s");
}
如前文所述,zend_timeout是实际处理超时的函数。它的实现也很简单。
如果有配置exit_on_timeout,则zend_on_timeout会尝试调用sapi_terminate_process关闭sapi进程。如果无需exit_on_timeout,则直接进入zend_error进行出错处理。大部分情况下,我们并不会设置exit_on_timeout,毕竟我们期望的是虽然一个请求超时了,但是进程仍然保留下来,服务下一个请求。
zend_error除了会打印错误日志,还会利用longjump跳转到boilout指定的栈帧,一般是zend_end_try或者zend_catch宏所在的地方。关于longjump,可以另起一个话题,本文就不具体叙述了。在php_execute_script里面,zend_error会使得程序跳转到zend_end_try的位置然后继续执行。继续执行是指,会调用php_request_shutdown等函数来完成收尾工作。
直到这里,php脚本的超时机制算是讲清楚了。
windows下max_input_time的bug
最后来看一个疑似php内核的bug。回忆一下,之前有提到windows下只有一个地方调用了zend_timeout,就是execute函数里,准确讲是每条opcode执行之前。
那么,假如发生max_input_time类型的超时,即使子线程将EG(timed_out)被置为1,也得延迟到execute中才能进行超时处理。貌似一切正常。
而问题的关键之处便在于,我们并不能保证主线程执行到execute时,EG(timed_out)任然为1。一旦进入execute之前,EG(timed_out)被子线程修改成0,那么max_input_time类型的超时就永远不会被handle了。
为何EG(timed_out)会被子线程又修改为0呢?原因在于:php_execute_script中,调用了zend_set_timeout(INI_INT("max_execution_time"), 0)来设置定时器。
zend_set_timeout会向子线程发送WM_REGISTER_ZEND_TIMEOUT消息。子线程收到此消息,除了创建定时器之外,还会设置EG(timed_out) = 0(详见上文截取的zend_timeout_WndProc代码片段)。由于线程执行的不确定性,因此不能够判断主线程执行到execute的时候,子线程是否已接收到消息并设置EG(timed_out)为0。
如图所示,
如果execute中的判断发生在红线标注的时间点,则EG(timed_out)为1,execute会调用zend_timeout做超时处理。
如果execute中的判断发生在蓝线标注的时间点,则EG(timed_out)已被重置为0,max_input_time超时被彻底掩盖。
剖析php脚本的超时机制的更多相关文章
- v78.01 鸿蒙内核源码分析(消息映射篇) | 剖析LiteIpc(下)进程通讯机制 | 百篇博客分析OpenHarmony源码
百篇博客分析|本篇为:(消息映射篇) | 剖析LiteIpc(下)进程通讯机制 进程通讯相关篇为: v26.08 鸿蒙内核源码分析(自旋锁) | 当立贞节牌坊的好同志 v27.05 鸿蒙内核源码分析( ...
- 老李推荐: 第3章1节《MonkeyRunner源码剖析》脚本编写示例: MonkeyRunner API使用示例
老李推荐: 第3章1节<MonkeyRunner源码剖析>脚本编写示例: MonkeyRunner API使用示例 MonkeyRunner这个类可以说是编写monkeyrunner脚 ...
- 您还有心跳吗?超时机制分析(java)
注:本人是原作者,首发于并发编程网(您还有心跳吗?超时机制分析),此文结合那里的留言作了一些修改. 问题描述 在C/S模式中,有时我们会长时间保持一个连接,以避免频繁地建立连接,但同时,一般会有一个超 ...
- Chrome扩展开发之二——Chrome扩展中脚本的运行机制和通信方式
目录: 0.Chrome扩展开发(Gmail附件管理助手)系列之〇——概述 1.Chrome扩展开发之一——Chrome扩展的文件结构 2.Chrome扩展开发之二——Chrome扩展中脚本的运行机制 ...
- 从报错“无效操作,连接被关闭”探究Transaction的Timeout超时机制
1.报错如下:Invalid Operation the connection is closed,无效操作,连接被关闭.这个错误是并不是每次都报,只有在复杂操作.大事务的情况下才偶然报出来. sta ...
- C# Socket连接请求超时机制
作者:RazanPaul 译者:Todd Wei 原文:http://www.codeproject.com/KB/IP/TimeOutSocket.aspx 介绍 您可能注意到了,.Net的Syst ...
- TCP/IP的三次握手和四次分手以及超时机制
使用INADDR_ANY的时候,往往针对多网卡情况,采用tcp连接方式,需要选择使用哪一个网卡发送,自己猜想应该是使用三次握手机制,如何判断目标地址不可达,应该使用的是超时机制,即握手超时则不可到达. ...
- 老李推荐:第3章3节《MonkeyRunner源码剖析》脚本编写示例: MonkeyImage API使用示例 1
老李推荐:第3章3节<MonkeyRunner源码剖析>脚本编写示例: MonkeyImage API使用示例 在上一节的第一个“增加日记”的示例中,我们并没有看到日记是否真的增加成功 ...
- mysql超时机制
mysql每次建立一个socket连接(connect)时,这个socket都会占用一定内存.即使你关闭(close)连接时,并不是真正的关闭,而是处于睡眠(sleep)状态. 当你下次再进行连接时, ...
随机推荐
- 用canvas和原生js写的一个笨鸟先飞的小游戏(暂时只有一个关卡)
其中一个画布背景是一张图片,还有小鸟,两个管子的图片.暂时不知道怎么附上去就不添加了.这里只有源代码,css和js都是在html写着的,感觉比他们的容易吧,hah <!DOCTYPE html& ...
- [PY3]——Python的函数
Python函数总结图 1.调用 1.1 如何调用 1.1.1 函数名(传参) # 函数使用函数名来调用,函数名后紧跟一对小括号,小括号里传入函数定义时要求的参数 add(9,9) #9+9=18 ...
- 【angular5项目积累总结】列表多选样式框(1)
憋不住想说一下:angular坑太多,各种教程各种不完整,官网还只是简单的画饼充饥,没办法只有自己研究自己总结,便于以后提高工作效率. 第一种: view code list.css :ho ...
- Linux 服务器 MySql的安装和网站的发布
Linux安装MySql,并配置能通过自己的电脑连接服务器的数据库 昨天安装的MySql,今天上午配置MySql能使用本机连接服务器数据库,服务器时DigitalOcean的,提供了很全面很专业的文档 ...
- django中的template部分
模版部分 取值 {{key}} 语句块 {%for item in data%} {%extends 'base.html'%} 深度变量的查找(万能的句点号) 在到目前为止的例子中,我们通过 con ...
- [javaSE] 进制转换(二进制十进制十六进制八进制)
十进制转二进制,除2运算 十进制6转二进制是 110 (注意从右往左写,使用算式从下往上写) 二进制转十进制,乘2过程 二进制110转十进制 0*2的0次方+1*2的1次方+1*2的2次方=6 对 ...
- Java学习网址
JAVA学习记录均参考自爱慕课网址: 爱慕课:http://www.imooc.com/course/list?c=java
- java.sql.SQLException: Io 异常: Invalid number format for port number
java.sql.SQLException: Io 异常: Invalid number format for port number jdbc数据库链接配置没有写端口号 要检查jdbc的配置 ...
- Miller-Rabbin随机性素数测试算法
//**************************************************************** // Miller_Rabin 算法进行素数测试 //速度快,而且 ...
- jdk锁相关
锁类型 可重入锁:在执行对象中所有同步方法不用再次获得锁 可中断锁:在等待获取锁过程中可中断 公平锁: 按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利 读写锁:对资源读取和写入的 ...