《PHP實戰:php腳本運行時的超時機制詳解》要點:
本文介紹了PHP實戰:php腳本運行時的超時機制詳解,希望對您有用。如果有疑問,可以聯系我們。
在做php開發的時候,經常會設置max_input_time、max_execution_time,用來控制腳本的超時時間.但卻從來沒有思考過背后的原理.PHP實戰
趁著這兩天有空,研究一下這個問題.PHP實戰
超時配置
PHP實戰
php的ini配置如何起作用,這是一個老生常談的話題了.PHP實戰
首先,我們在php.ini里進行配置.當php啟動的時候(php_module_startup階段),會嘗試讀取ini文件并解析.解析過程簡單來說,是分析ini文件,提取出其中合法的鍵值對,并保留到configuration_hash表.PHP實戰
OK,然后php會進一步調用zend_startup_extensions來啟動各個模塊(包括php Core模塊,以及所有需要加載的擴展).各個模塊的啟動函數中,會完成REGISTER_INI_ENTRIES動作.REGISTER_INI_ENTRIES負責將模塊對應的一些配置從configuration_hash表取出,然后調用處理函數,最終將處理完的值存入模塊的globals變量.PHP實戰
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實戰
示意圖如下:PHP實戰
---->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對應的函數:PHP實戰
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), 0); return SUCCESS; }
暫時只看上半截,因為我們目前只需關注php的啟動階段,該函數行為很簡單,將max_execution_time存入了EG(timeout_seconds).PHP實戰
至于max_input_time,并沒有特殊的處理函數,默認是會將max_input_time存入存入PG(max_input_time).PHP實戰
因此,當REGISTER_INI_ENTRIES完成,產生的是:PHP實戰
max_execution_time ----> 存入EG(timeout_seconds)PHP實戰
max_input_time?????? ----> 存入PG(max_input_time)PHP實戰
哀求超時控制
PHP實戰
現在我們搞清楚php的啟動階段發生了什么,繼續來看php在實際處理哀求的時候,如何管理超時.PHP實戰
在php_request_startup函數中有如下代碼:PHP實戰
if (PG(max_input_time) == -1) { zend_set_timeout(EG(timeout_seconds), 1); } else { zend_set_timeout(PG(max_input_time), 1); }
php_request_startup的時機很講究.PHP實戰
以cgi為例,只有當php已經從CGI拿到了原始哀求以及一些CGI的環境變量之后,php_request_startup才會被調用.上面這段代碼實際執行的時候,由于哀求已經拿到,所以SG(request_info)處于準備就緒狀態,但是php中的$_GET,$_POST,$_FILE等超全局變量尚未生成.PHP實戰
從代碼上理解:PHP實戰
1、如果用戶將max_input_time配做-1,或沒有配置,那么腳本的生命周期就只受EG(timeout_seconds)約束.PHP實戰
2、否則,哀求啟動階段的超時控制,受PG(max_input_time)約束.PHP實戰
3、zend_set_timeout函數負責設置定時器.一旦指定時間過去,定時器會通知php進程.zend_set_timeout下文會具體分析.PHP實戰
php_request_startup完成,則進入php的實際執行階段,即php_execute_script.在php_execute_script中可以看到:PHP實戰
// 設定執行超時 if (PG(max_input_time) != -1) { #ifdef PHP_WIN32 zend_unset_timeout(TSRMLS_C); // 關閉之前的定時器 #endif zend_set_timeout(INI_INT("max_execution_time"), 0); } // 進入執行 retval = (zend_execute_scripts(ZEND_REQUIRE TSRMLS_CC, NULL, 3, prepend_file_p, primary_file, append_file_p) == SUCCESS);
OK,假如代碼執行到這里,尚未產生max_input_time超時,則會重新指定max_execution_time的超時.PHP實戰
同樣也是采取調用zend_set_timeout,并傳入max_execution_time.特別注意一下,windows下面的需要顯式調用zend_unset_timeout關閉本來的定時器,而linux下不需要.這是由于兩個平臺的定時器實現原理不同導致的,下文也會詳細展開敘述.PHP實戰
最后用一張圖表示超時控制的流程,左側的case注解用戶既配置了max_input_time,又配置了max_execution_time.而右側的區別在于用戶僅僅配置了max_execution_time:PHP實戰
PHP實戰
zend_set_timeout
PHP實戰
前文提到,zend_set_timeout函數用來設置定時器.具體來看下實現:PHP實戰
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 == 0 && InterlockedIncrement(&timeout_thread_initialized) == 1) { /* 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 = 0; // 設置定時器,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 }
上述實現基本上可以完全分成兩種平臺:PHP實戰
先看linux:
PHP實戰
linux下的定時器要容易許多,調用setitimer函數就行,此外,zend_set_timeout還設定了SIGPROF信號的handler為zend_timeout.PHP實戰
注意,調用setitimer的時候,將it_interval設置成0,注解這個定時器只觸發一次,而不會每隔一段時間觸發一次.setitimer可以以三種方式計時,php中采用的是ITIMER_PROF,它同時計算了用戶代碼和內核代碼的執行時間.一旦時間到了,會產生SIGPROF信號.PHP實戰
當php進程接收到SIGPROF信號,不管當前正在執行什么,都會跳轉進入到zend_timeout.zend_timeout才是實際處理超時的函數.PHP實戰
再看windows:
PHP實戰
首先會啟動一個子線程,該線程主要用于設置定時器,同時維護EG(timed_out)變量.PHP實戰
子線程一旦生成,主線程便會向子線程發送一條消息:WM_REGISTER_ZEND_TIMEOUT.子線程接收到WM_REGISTER_ZEND_TIMEOUT之后,發生一個定時器并開始計時.同時,子線程會設置EG(timed_out) = 0.這很重要!windows平臺下正是通過判斷EG(timed_out)是否為1,來決定是否超時.PHP實戰
如果定時器到時間了,子線程收到WM_TIMER消息,則取消定時器,而且設置EG(timed_out) = 1.PHP實戰
如果必要關閉定時器,則子線程會收到WM_UNREGISTER_ZEND_TIMEOUT消息.關閉定時器,并不會改變EG(timed_out).PHP實戰
相關代碼還是很清晰的:PHP實戰
static LRESULT CALLBACK zend_timeout_WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_DESTROY: PostQuitMessage(0); break; // 生成一個定時器,開始計時 case WM_REGISTER_ZEND_TIMEOUT: /* wParam is the thread id pointer, lParam is the timeout amount in seconds */ if (lParam == 0) { KillTimer(timeout_window, wParam); } else { SetTimer(timeout_window, wParam, lParam*1000, NULL); EG(timed_out) = 0; } 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) = 1; } break; default: return DefWindowProc(hWnd, message, wParam, lParam); } return 0; }
根據上文描述,最終都是必要跳轉到zend_timeout來處理超時的.那windows下如何進入zend_timeout呢?PHP實戰
window下僅在execute函數中(zend_vm_execute.h剛開始的地方),可以看到調用zend_timeout:PHP實戰
while (1) { int ret; #ifdef ZEND_WIN32 if (EG(timed_out)) { // windows下的超時,執行每條opcode之前都判斷是否必要調用zend_timeout zend_timeout(0); } #endif if ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > 0) { ... } }
上述代碼可以看到:PHP實戰
在windows下,每執行完成一條opcode指令,就會進行一次超時判斷.PHP實戰
因為主線程執行opcode的同時,子線程可能已經發生超時,而windows并沒有什么機制可以讓主線程停止手頭的工作,直接跳入zend_timeout.所以只好利用子線程先將EG(timed_out)設置為1,然后主線程在比及當前opcode執行完成、進入下一條opcode之前,判斷一下EG(timed_out)再調用zend_timeout.PHP實戰
因此準確的講,windows的超時,其實是有一點點延時的.至少在某一個opcode執行的過程中,無法被打斷.當然,正常情況下,單條opcode的執行時間會很短.但是可以很容易人為構造出一些很耗時的函數,使得function call必要等待較長時間.此時,如果子線程判斷出超時了,則還必要經過漫長的等待,直到主線程完成該條opcode之后,才能調用zend_timeout.PHP實戰
zend_unset_timeoutPHP實戰
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) 0); } #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; // 全置0,相當于關閉定時器 setitimer(ITIMER_PROF, &no_timeout, NULL); } #endif }
zend_unset_timeout同樣分成兩種平臺的實現.PHP實戰
先看linux:
PHP實戰
linux下的關閉定時器也很簡單.只要將struct itimerval中的4個值都設置為0,就行了.PHP實戰
再看windows:
PHP實戰
由于windows是利用一個獨立的線程來計時.因此,zend_unset_timeout會向該線程發送WM_UNREGISTER_ZEND_TIMEOUT消息.WM_UNREGISTER_ZEND_TIMEOUT對應的動作是去調用KillTimer來關閉定時器.注意,線程自己并不退出.PHP實戰
前文留下了一個問題,在php_execute_script中,windows下面要顯示調用zend_unset_timeout來關閉定時器,而linux下不必要.因為對于一個linux進程來說,只能存在一個setitimer定時器.也就是說,重復調用setitimer,后面的定時器會直接覆蓋前面的.PHP實戰
zend_timeoutPHP實戰
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) == 1 ? "" : "s"); }
如前文所述,zend_timeout是實際處理超時的函數.它的實現也很簡單.PHP實戰
如果有配置exit_on_timeout,則zend_on_timeout會嘗試調用sapi_terminate_process關閉sapi進程.如果無需exit_on_timeout,則直接進入zend_error進行出錯處理.大部分情況下,我們并不會設置exit_on_timeout,畢竟我們期望的是雖然一個哀求超時了,但是進程仍然保留下來,服務下一個哀求.PHP實戰
zend_error除了會打印差錯日志,還會利用longjump跳轉到boilout指定的棧幀,一般是zend_end_try或者zend_catch宏所在的地方.關于longjump,可以另起一個話題,本文就不具體敘述了.在php_execute_script里面,zend_error會使得程序跳轉到zend_end_try的位置然后繼續執行.繼續執行是指,會調用php_request_shutdown等函數來完成收尾工作.PHP實戰
直到這里,php腳本的超時機制算是講清楚了.PHP實戰
最后來看一個疑似php內核的bug.PHP實戰
windows下max_input_time的bug
PHP實戰
回憶一下,之前有提到windows下只有一個地方調用了zend_timeout,便是execute函數里,準確講是每條opcode執行之前.PHP實戰
那么,假如發生max_input_time類型的超時,即使子線程將EG(timed_out)被置為1,也得延遲到execute中能力進行超時處理.貌似一切正常.PHP實戰
而問題的關鍵之處便在于,我們并不克不及保證主線程執行到execute時,EG(timed_out)任然為1.一旦進入execute之前,EG(timed_out)被子線程修改成0,那么max_input_time類型的超時就永遠不會被handle了.PHP實戰
為何EG(timed_out)會被子線程又修改為0呢?原因在于:php_execute_script中,調用了zend_set_timeout(INI_INT("max_execution_time"), 0)來設置定時器.PHP實戰
zend_set_timeout會向子線程發送WM_REGISTER_ZEND_TIMEOUT消息.子線程收到此消息,除了創建定時器之外,還會設置EG(timed_out) = 0(詳見上文截取的zend_timeout_WndProc代碼片段).由于線程執行的不確定性,因此不克不及夠判斷主線程執行到execute的時候,子線程是否已接收到消息并設置EG(timed_out)為0.PHP實戰
PHP實戰
如圖所示,PHP實戰
如果execute中的判斷產生在紅線標注的時間點,則EG(timed_out)為1,execute會調用zend_timeout做超時處理.PHP實戰
如果execute中的判斷產生在藍線標注的時間點,則EG(timed_out)已被重置為0,max_input_time超時被徹底掩蓋.PHP實戰
《PHP實戰:php腳本運行時的超時機制詳解》是否對您有啟發,歡迎查看更多與《PHP實戰:php腳本運行時的超時機制詳解》相關教程,學精學透。維易PHP學院為您提供精彩教程。
轉載請注明本頁網址:
http://www.snjht.com/jiaocheng/7535.html