When doing PHP development, max_input_time and max_execution_time are often set to control the timeout of the script. But I never thought about the principle behind it.
Take advantage of the free time in these two days to study this issue.
How PHP’s ini configuration works is a commonplace topic.
First, we configure it in php.ini. When php starts (php_module_startup stage), it will try to read the ini file and parse it. To put it simply, the parsing process is to analyze the ini file, extract the legal key-value pairs, and save them to the configuration_hash table.
OK, then php will further call zend_startup_extensions to start each module (including the php Core module and all extensions that need to be loaded). In the startup function of each module, the REGISTER_INI_ENTRIES action will be completed. REGISTER_INI_ENTRIES is responsible for taking out some configurations corresponding to the module from the configuration_hash table, then calling the processing function, and finally storing the processed values into the globals variable of the module.
The two configurations max_input_time and max_execution_time belong to the php Core module. For php Core, REGISTER_INI_ENTRIES still occurs in php_module_startup. Configurations that also belong to the php Core module include expose_php, display_errors, memory_limit, etc...
The schematic diagram is as follows:
---->php_module_startup----------->php_request_startup---->
|
|
|-->REGISTER_INI_ENTRIES
|
|
|-->zend_startup_extensions
| |
|-->zm_startup_date
| |-->REGISTER_INI_ENTRIES
|
|-->REGISTER_INI_ENTRIES
| -->zm_startup_json
|-->REGISTER_INI_ENTRIES
|
|
As mentioned above, REGISTER_INI_ENTRIES will call different functions for different configurations. Let’s look directly at the function corresponding to max_execution_time:
<span>static</span><span> PHP_INI_MH(OnUpdateTimeout) { </span><span>//</span><span> php启动阶段走这里</span> <span>if</span> (stage ==<span> PHP_INI_STAGE_STARTUP) { </span><span>//</span><span> 将超时设置保存到EG(timeout_seconds)中</span> EG(timeout_seconds) =<span> atoi(new_value); </span><span>return</span><span> SUCCESS; } </span><span>//</span><span> php执行过程中的ini set则走这里</span> <span> zend_unset_timeout(TSRMLS_C); EG(timeout_seconds) </span>=<span> atoi(new_value); zend_set_timeout(EG(timeout_seconds), </span><span>0</span><span>); </span><span>return</span><span> SUCCESS; }</span>
We will only look at the first half for now, because we only need to pay attention to the startup phase of php. The behavior of this function is very simple, and max_execution_time is stored in EG (timeout_seconds).
As for max_input_time, there is no special processing function. By default, max_input_time will be stored in PG (max_input_time).
So when REGISTER_INI_ENTRIES completes, what happens is:
max_execution_time ----> Store in EG(timeout_seconds)
max_input_time ----> Store in PG(max_input_time)
Now that we understand what happens in the startup phase of PHP, let’s continue to look at how PHP manages timeouts when it actually processes requests.
There is the following code in the php_request_startup function:
<span>if</span> (PG(max_input_time) == -<span>1</span><span>) { zend_set_timeout(EG(timeout_seconds), </span><span>1</span><span>); } </span><span>else</span><span> { zend_set_timeout(PG(max_input_time), </span><span>1</span><span>); }</span>
The timing of php_request_startup is very particular.
$_GET
Taking cgi as an example, php_request_startup will be called only after php has obtained the original request and some CGI environment variables from CGI. When the above code is actually executed, since the request has been obtained, SG (request_info) is in a ready state, but super global variables such as $_POST
, $_FILE
, and
Understanding from the code:
1. If the user sets max_input_time to -1, or does not configure it, then the life cycle of the script is only limited by EG (timeout_seconds).
2. Otherwise, the timeout control in the request startup phase is subject to PG (max_input_time).
3. The zend_set_timeout function is responsible for setting the timer. Once the specified time has passed, the timer will notify the php process. zend_set_timeout will be analyzed in detail below.
After php_request_startup is completed, it enters the actual execution phase of php, that is, php_execute_script. You can see it in php_execute_script:
<span>//</span><span> 设定执行超时</span> <span>if</span> (PG(max_input_time) != -<span>1</span><span>) { #ifdef PHP_WIN32 zend_unset_timeout(TSRMLS_C); </span><span>//</span><span> 关闭之前的定时器</span> <span>#endif</span><span> zend_set_timeout(INI_INT(</span><span>"</span><span>max_execution_time</span><span>"</span>), <span>0</span><span>); } </span><span>//</span><span> 进入执行</span> retval = (zend_execute_scripts(ZEND_REQUIRE TSRMLS_CC, NULL, <span>3</span>, prepend_file_p, primary_file, append_file_p) == SUCCESS);
OK, if the code is executed here and the max_input_time timeout has not occurred, the timeout of max_execution_time will be respecified. <🎜>
同样也是采取调用zend_set_timeout,并传入max_execution_time。特别注意一下,windows下面的需要显式调用zend_unset_timeout关闭原来的定时器,而linux下不需要。这是由于两个平台的定时器实现原理不同导致的,下文也会详细展开叙述。
最后用一张图表示超时控制的流程,左侧的case表明用户既配置了max_input_time,又配置了max_execution_time。而右侧的区别在于用户仅仅配置了max_execution_time:
前文提到,zend_set_timeout函数用来设置定时器。具体来看下实现:
<span>void</span> zend_set_timeout(<span>long</span> seconds, <span>int</span> reset_signals) <span>/*</span><span> {{{ </span><span>*/</span><span> { TSRMLS_FETCH(); </span><span>//</span><span> 赋值</span> EG(timeout_seconds) =<span> seconds; #ifdef ZEND_WIN32 </span><span>if</span>(!<span>seconds) { </span><span>return</span><span>; } </span><span>//</span><span> 启动定时器线程</span> <span>if</span> (timeout_thread_initialized == <span>0</span> && InterlockedIncrement(&timeout_thread_initialized) == <span>1</span><span>) { </span><span>/*</span><span> 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. </span><span>*/</span><span> zend_init_timeout_thread(); } </span><span>//</span><span> 向线程发送WM_REGISTER_ZEND_TIMEOUT消息</span> <span> PostThreadMessage(timeout_thread_id, WM_REGISTER_ZEND_TIMEOUT, (WPARAM) GetCurrentThreadId(),(LPARAM) seconds); </span><span>#else</span> <span>//</span><span> linux平台下</span> <span>struct</span> itimerval t_r; <span>/*</span><span> timeout requested </span><span>*/</span> <span>int</span><span> signo; </span><span>if</span><span> (seconds) { t_r.it_value.tv_sec </span>=<span> seconds; t_r.it_value.tv_usec </span>= t_r.it_interval.tv_sec = t_r.it_interval.tv_usec = <span>0</span><span>; </span><span>//</span><span> 设置定时器,seconds秒后会发送SIGPROF信号</span> setitimer(ITIMER_PROF, &<span>t_r, NULL); } signo </span>=<span> SIGPROF; </span><span>if</span><span> (reset_signals) { sigset_t sigset; </span><span>//</span><span> 设置SIGPROF信号对应的处理函数为zend_timeout</span> <span> signal(signo, zend_timeout); </span><span>//</span><span> 防屏蔽</span> sigemptyset(&<span>sigset); sigaddset(</span>&<span>sigset, signo); sigprocmask(SIG_UNBLOCK, </span>&<span>sigset, NULL); } </span><span>#endif</span><span> }</span>
上述实现基本上可以完全分成两种平台:
linux下的定时器要容易许多,调用setitimer函数就行,此外,zend_set_timeout还设定了SIGPROF信号的handler为zend_timeout。
注意,调用setitimer的时候,将it_interval设置成0,表明这个定时器只触发一次,而不会每隔一段时间触发一次。setitimer可以以三种方式计时,php中采用的是ITIMER_PROF,它同时计算了用户代码和内核代码的执行时间。一旦时间到了,会产生SIGPROF信号。
当php进程接收到SIGPROF信号,不管当前正在执行什么,都会跳转进入到zend_timeout。zend_timeout才是实际处理超时的函数。
首先会启动一个子线程,该线程主要用于设置定时器,同时维护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)。
相关代码还是很清晰的:
<span>static</span><span> LRESULT CALLBACK zend_timeout_WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { </span><span>switch</span><span> (message) { </span><span>case</span><span> WM_DESTROY: PostQuitMessage(</span><span>0</span><span>); </span><span>break</span><span>; </span><span>//</span><span> 生成一个定时器,开始计时</span> <span>case</span><span> WM_REGISTER_ZEND_TIMEOUT: </span><span>/*</span><span> wParam is the thread id pointer, lParam is the timeout amount in seconds </span><span>*/</span> <span>if</span> (lParam == <span>0</span><span>) { KillTimer(timeout_window, wParam); } </span><span>else</span><span> { SetTimer(timeout_window, wParam, lParam</span>*<span>1000</span><span>, NULL); EG(timed_out) </span>= <span>0</span><span>; } </span><span>break</span><span>; </span><span>//</span><span> 关闭定时器</span> <span>case</span><span> WM_UNREGISTER_ZEND_TIMEOUT: </span><span>/*</span><span> wParam is the thread id pointer </span><span>*/</span><span> KillTimer(timeout_window, wParam); </span><span>break</span><span>; </span><span>//</span><span> 超时了,也需关闭定时器</span> <span>case</span><span> WM_TIMER: { KillTimer(timeout_window, wParam); EG(timed_out) </span>= <span>1</span><span>; } </span><span>break</span><span>; </span><span>default</span><span>: </span><span>return</span><span> DefWindowProc(hWnd, message, wParam, lParam); } </span><span>return</span> <span>0</span><span>; }</span>
根据上文描述,最终都是需要跳转到zend_timeout来处理超时的。那windows下如何进入zend_timeout呢?
window下仅在execute函数中(zend_vm_execute.h刚开始的地方),可以看到调用zend_timeout:
<span>while</span> (<span>1</span><span>) { </span><span>int</span><span> ret; #ifdef ZEND_WIN32 </span><span>if</span> (EG(timed_out)) { <span>//</span><span> windows下的超时,执行每条opcode之前都判断是否需要调用zend_timeout</span> zend_timeout(<span>0</span><span>); } </span><span>#endif</span> <span>if</span> ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > <span>0</span><span>) { ... } }</span>
上述代码可以看到:
在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。
<span>void</span> zend_unset_timeout(TSRMLS_D) <span>/*</span><span> {{{ </span><span>*/</span><span> { #ifdef ZEND_WIN32 </span><span>//</span><span> 通过发送WM_UNREGISTER_ZEND_TIMEOUT消息来关闭定时器</span> <span>if</span><span>(timeout_thread_initialized) { PostThreadMessage(timeout_thread_id, WM_UNREGISTER_ZEND_TIMEOUT, (WPARAM) GetCurrentThreadId(), (LPARAM) </span><span>0</span><span>); } </span><span>#else</span> <span>if</span><span> (EG(timeout_seconds)) { </span><span>struct</span><span> itimerval no_timeout; no_timeout.it_value.tv_sec </span>= no_timeout.it_value.tv_usec = no_timeout.it_interval.tv_sec = no_timeout.it_interval.tv_usec = <span>0</span><span>; </span><span>//</span><span> 全置0,相当于关闭定时器</span> setitimer(ITIMER_PROF, &<span>no_timeout, NULL); } </span><span>#endif</span><span> }</span>
zend_unset_timeout同样分成两种平台的实现。
linux下的关闭定时器也很简单。只要将struct itimerval中的4个值都设置为0,就行了。
由于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_API <span>void</span> zend_timeout(<span>int</span> dummy) <span>/*</span><span> {{{ </span><span>*/</span><span> { TSRMLS_FETCH(); </span><span>if</span><span> (zend_on_timeout) { zend_on_timeout(EG(timeout_seconds) TSRMLS_CC); } zend_error(E_ERROR, </span><span>"</span><span>Maximum execution time of %d second%s exceeded</span><span>"</span>, EG(timeout_seconds), EG(timeout_seconds) == <span>1</span> ? <span>""</span> : <span>"</span><span>s</span><span>"</span><span>); }</span>
如前文所述,zend_timeout是实际处理超时的函数。它的实现也很简单。
如果有配置exit_on_timeout,则zend_on_timeout会尝试调用sapi_terminate_process关闭sapi进程。如果无需exit_on_timeout,则直接进入zend_error进行出错处理。大部分情况下,我们并不会设置exit_on_timeout,毕竟我们期望的是虽然一个请求超时了,但是进程仍然保留下来,服务下一个请求。
In addition to printing the error log, zend_error will also use longjump to jump to the stack frame specified by boilout, which is usually where the zend_end_try or zend_catch macro is located. Regarding longjump, you can start another topic, which will not be described in detail in this article. In php_execute_script, zend_error will cause the program to jump to the zend_end_try location and continue execution. Continued execution means that functions such as php_request_shutdown will be called to complete the finishing work.
Up to this point, the timeout mechanism of the php script has been explained clearly.
Finally, let’s look at a suspected bug in the PHP kernel.
Recall that it was mentioned before that there is only one place where zend_timeout is called under Windows, which is in the execute function. To be precise, it is before each opcode is executed.
Then, if a timeout of the max_input_time type occurs, even if the sub-thread sets EG (timed_out) to 1, the timeout processing must be delayed until execute. Everything seems to be fine.
The key to the problem is that we cannot guarantee that EG (timed_out) will still be 1 when the main thread executes execute. Once EG(timed_out) is modified to 0 by the child thread before entering execute, the timeout of the max_input_time type will never be handled.
Why is EG(timed_out) modified to 0 by the child thread? The reason is: in php_execute_script, zend_set_timeout(INI_INT("max_execution_time"), 0) is called to set the timer.
zend_set_timeout will send the WM_REGISTER_ZEND_TIMEOUT message to the child thread. When the child thread receives this message, in addition to creating a timer, it will also set EG(timed_out) = 0 (see the zend_timeout_WndProc code snippet intercepted above for details). Due to the uncertainty of thread execution, it is impossible to determine whether the child thread has received the message and set EG (timed_out) to 0 when the main thread executes execute.
As shown in the picture,
If the judgment in execute occurs at the time point marked by the red line, EG (timed_out) is 1, and execute will call zend_timeout for timeout processing.
If the judgment in execute occurs at the time point marked by the blue line, EG (timed_out) has been reset to 0, and the max_input_time timeout is completely covered.