php脚本运行时超时机制的详细说明
在进行php开发时,经常设置max_input_time和max_execution_time来控制脚本的超时。但是我从来没有想过背后的原理。
趁着这两天好好研究一下这个问题。
超时配置
php的ini配置如何工作是一个常见的话题。
首先,我们用php.ini配置它当php启动时(php_module_startup阶段),它会尝试读取ini文件并解析它。简单来说,解析过程就是分析ini文件,提取其中的合法键值对,并保存在configuration_hash表中。
好的,那么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 | | |-做其他事情如上所述,REGISTER _ INI _ ENTRIES会针对不同的配置调用不同的函数。让我们直接看看max_execution_time对应的函数:
静态PHP _ ini _ MH(更新超时时){//如果(stage==PHP _ ini _ stage _ startup){//将超时设置保存在EG中(time out _ seconds)EG(time out _ seconds)=atoi(new _ value);返回SUCCESS}//在PHP执行中设置的ini在这里是Zend _ unset _ time out(TSRMLS _ C);EG(超时_秒)=atoi(新_值);Zend _ set _ time out(EG(time out _ seconds),0);返回SUCCESS}暂时只需要关注php的启动阶段。函数非常简单,max_execution_time以EG(timeout_seconds)为单位保存。
至于max_input_time,没有专门的处理功能。默认情况下,最大输入时间将存储在PG中(最大输入时间)。
因此,当REGISTER_INI_ENTRIES完成时,会发生以下情况:
max _ execution _ time-以EG为单位存放(超时_秒)。
PG中的最大输入定期存款(最大输入时间)。
请求超时控制。
现在让我们了解一下在php的启动阶段发生了什么,并继续了解php在实际处理请求时是如何管理超时的。
php_request_startup函数中有以下代码:
if(PG(max _ input _ time)==-1){ Zend _ set _ time out(EG(time out _ seconds),1);} else { Zend _ set _ time out(PG(max _ input _ time),1);} PHP _ request _ startup的时机非常特别。
以cgi为例,php _ request _ startup只有在php从CGI获得原始请求和一些CGI环境变量后才会被调用。当上面的代码实际执行时,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)!=-1){ # ifdef PHP _ WIN32 Zend _ unset _ time out(TSRMLS _ C);//关闭前一个定时器# endifzend _ set _ time out(ini _ int(' max _ execution _ time '),0);}//输入执行retval=(Zend _ execute _ scripts(Zend _ require tsrmls _ cc,null,3,suspend _ file _ p,primary _ file,append _ file _ p)=success);好的,如果在这里执行代码之前没有发生max_input_time的超时,max_execution_time的超时将被重新指定。
它还调用zend_set_timeout并传递max_execution_time。尤其是在windows下,需要显式调用zend_unset_timeout来关闭原来的定时器,但是在linux下,就不需要了。这是由于两个平台的定时器实现原理不同,下面将详细描述。
最后,一个图表显示了超时控制的过程,左边的案例显示用户同时配置了max_input_time和max_execution_time。在右侧,区别在于用户只配置了max_execution_time:
zend_set_timeout
如前所述,zend_set_timeout函数用于设置定时器。具体看一下实现:
void zend_set_timeout(长秒,int reset _ signals)/* { { { */{ TSRMLS _ FETCH();//分配EG(timeout_seconds)=秒;#ifdef ZEND_WIN32 if(!秒){ return}//启动定时器线程if(time out _ thread _ initialized==0互锁增量(time out _ thread _ initialized)=1){/*我们在这里启动这个进程范围的线程,而不是在zend_startup()中启动,因为如果Zend *是在DllMain()中初始化的,就不应该从它启动线程。*/Zend _ init _ time out _ thread();}//向线程发送WM_REGISTER_ZEND_TIMEOUT消息post thread message(time out _ thread _ id,WM _ register _ Zend _ timeout,(wparam) getcurrentthreadid(),(lparam)seconds);# else//Linux平台下的struct itimerval t _ r;/*请求超时*/int signo;if(秒){ t_r.it_value.tv_sec=秒;t _ r . it _ value . TV _ usec=t _ r . it _ interval . TV _ sec=t _ r . it _ interval . TV _ usec=0;//设置定时器,秒后发送SIGPROF信号setitimer(ITIMER_PROF,t_r,NULL);} signo=SIGPROFif(reset _ sigset信号){ sigset _ t sigset//将SIGPROF信号对应的处理函数设置为Zend _ timeout信号(signo,Zend _ time out);//抗屏蔽sigemptyset(sigset);sigaddset(sigset,signo);sigprocmask(SIG_UNBLOCK,sigset,NULL);}#endif}基本上,上面的实现可以完全分为两个平台:
先看看linux:
linux下的计时器要简单得多,只需调用setitimer函数。此外,zend_set_timeout还将SIGPROF信号的处理程序设置为zend_timeout。
请注意,在调用setitimer时,it_interval被设置为0,这意味着该计时器将只触发一次,而不是每隔一次。Setitimer有三种计时方式,php中使用ITIMER_PROF,同时计算用户代码和内核代码的执行时间。一旦时间到了,信号就会产生。
当php进程接收到SIGPROF信号时,无论当前正在执行什么,都会跳转到zend_timeout。Zend_timeout是实际处理超时的函数。
再看看窗户:
首先会启动一个子线程,主要用于设置定时器和维护EG(time out _ out)变量。
一旦生成了子线程,主线程就会向子线程发送一条消息:WM _ REGISTER _ ZEND _ TIMEOUT。在接收到WM _ REGISTER _ ZEND _超时后,子线程生成一个定时器并开始计时。同时,子线程将设置EG(time _ out)=0。这很重要!在windows平台下,是通过判断EG(time out _ out)是否为1来决定是否超时。
如果定时器启动,子线程收到WM_TIMER消息,取消定时器并设置EG(time _ out)=1。
如果定时器需要关闭,子线程将收到WM _ UNREGISTER _ ZEND _超时消息。关闭定时器不会改变EG(超时)。
相关代码还是很清晰的:
静态LRESULT CALLBACK Zend _ time out _ WndProc(HWND HWND,UINT消息,WPARAM wParam,LPARAM LPARAM){ switch(message){ case WM _ destroy : post quit message(0);打破;//生成定时器开始计数case WM _ register _ Zend _ time out 3360/* wparam为线程id指针,lparam为超时量(以秒为单位*/if(lparam==0){ kill timer(time out _ window,wparam);} else { SetTimer(timeout_window,wParam,lParam*1000,NULL);EG(time _ out)=0;} break//关闭timer case WM _ unregister _ Zend _ timeout 3360/* wparam是线程id指针*/kill timer (timeout _ window,wparam);打破;//定时器case WM _ timer : { kill timer(time out _ window,wparam)需要关闭;EG(time _ out)=1;} breakdefault:返回DefWindowProc(hWnd,message,wParam,lParam);}返回0;}根据上面的描述,最终需要跳转到zend_timeout来处理超时。windows下如何进入zend_timeout?
在window下,只有在execute函数中(zend_vm_execute.h刚刚启动的地方),可以看到zend_timeout被调用:
while(1){ int ret;# ifdefzend _ win32 if(eg(time out _ out)){//time out在windows下,执行每个操作码之前,判断是否需要调用Zend _ time out Zend _ time out(0);} # endif((ret=opline-handler(execute _ data TSR mls _ cc))0){ 0.}}以上代码可以看出:
在windows下,每次执行操作码指令,都会做出超时判断。
因为当主线程执行opcode时,子线程可能已经超时,windows没有任何机制让主线程停止手头的工作,直接跳转到zend_timeout。因此,我们必须先使用子线程将EG(time _ out)设置为1,然后主线程判断EG(time _ out),然后调用zend_timeout,然后等待当前操作码完成执行并输入下一个操作码。
因此,准确地说,窗口的超时实际上有点延迟。至少有一个操作码在执行过程中不能被中断。当然,在正常情况下,单个操作码的执行时间会非常短。但是容易人为构造一些耗时的函数,使得函数调用需要等待很长时间。此时,如果子线程判断它已经超时,那么在调用zend_timeout之前,将需要等待很长时间,直到主线程完成操作码。
zend_unset_timeout
void Zend _ unset _ time out(ts rmls _ d)/* { { */{ # ifdefzend _ win32//通过发送WM_UNREGISTER_ZEND_TIMEOUT消息关闭计时器if(timeout_thread_initialized)。{ post thread message(time out _ thread _ id,WM_UNREGISTER_ZEND_TIMEOUT),(WPARAM) GetCurrentThreadId(),(LPARAM)0);} # else if(EG(time out _ seconds)){ struct itimerval no _ time out;no _ time out . it _ value . TV _ sec=no _ time out . it _ value . TV _ usec=no _ time out . it _ interval . TV _ sec=no _ time out . it _ interval . TV _ usec=0;//将all设置为0,相当于关闭timer settimer (itimer _ prof,no _ timeout,null);}#endif}zend_unset_timeout也分为两个平台。
先看看linux:
linux下的关闭定时器也很简单。只需将struct itimerval中的所有四个值都设置为0。
再看看窗户:
Windows使用独立的线程来计时。因此,zend_unset_timeout向线程发送WM_UNREGISTER_ZEND_TIMEOUT消息。对应于WM _ UNREGISTER _ ZEND _超时的动作是调用KillTimer关闭定时器。请注意,线程本身不会退出。
之前留下了一个问题。在php_execute_script中,需要在windows下调用zend_unset_timeout来关闭定时器,但在linux下不需要。因为对于一个linux进程,只能有一个setitimer。也就是说,如果重复调用setitimer,后面的定时器会直接覆盖前面的定时器。
zend_timeout
ZEND _ API void ZEND _ time out(int dummy)/* { { { */{ TSRMLS _ FETCH();if(Zend _ on _ time out){ Zend _ on _ time out(EG(time out _ seconds)TSRMLS _ CC);} zend_error(E_ERROR,'超过%d秒%s的最大执行时间',EG(timeout_seconds),EG(time out _ seconds)=1?' :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宏所在的位置。关于龙跳,我们可以从另一个话题开始,所以本文就不具体描述了。在php_execute_script中,zend_error将导致程序跳转到zend_end_try,然后继续执行。继续执行意味着将调用php_request_shutdown等函数来完成收尾工作。
到目前为止,php脚本的超时机制已经明确。
最后,看一个疑似php内核的bug。
Windows max_input_time错误。
回想一下,之前提到过zend_timeout只在windows下的一个地方被调用,也就是说,在execute函数中,正好在每个操作码被执行之前。
然后,如果发生max_input_time类型的超时,即使子线程将EG(time out)设置为1,在处理超时之前,它也必须延迟执行。一切似乎正常。
问题的关键在于,我们不能保证EG(time out _ out)在主线程执行时仍然为1。一旦EG(time out)在执行前被子线程修改为0,max_input_time类型的超时将永远不会被处理。
为什么EG(time _ out)被子线程修改为0?原因是:在php_execute_script中,调用Zend _ set _ time out(ini _ int(' max _ execution _ time '),0)来设置定时器。
Zend_set_timeout向子线程发送WM_REGISTER_ZEND_TIMEOUT消息。收到此消息后,子线程除了创建一个计时器之外,还将设置EG(time out _ out)=0(有关详细信息,请参见上面截取的zend_timeout_WndProc代码片段)。由于线程执行的不确定性,当主线程执行执行时,无法判断子线程是否收到消息并将EG(time _ out)设置为0。
如图所示,
如果执行中的判断发生在红线标记的时间,EG(time _ out)为1,执行将调用zend_timeout进行超时处理。
如果执行中的判断发生在蓝线标记的时间,则EG(time out _ out)已复位为0,max_input_time超时被完全隐藏。
版权声明:php脚本运行时超时机制的详细说明是由宝哥软件园云端程序自动收集整理而来。如果本文侵犯了你的权益,请联系本站底部QQ或者邮箱删除。