php腳本運(yùn)行時(shí)的超時(shí)機(jī)制詳解
在做php開發(fā)的時(shí)候,經(jīng)常會(huì)設(shè)置max_input_time、max_execution_time,用來(lái)控制腳本的超時(shí)時(shí)間。但卻從來(lái)沒(méi)有思考過(guò)背后的原理。
趁著這兩天有空,研究一下這個(gè)問(wèn)題。
超時(shí)配置
php的ini配置如何起作用,這是一個(gè)老生常談的話題了。
首先,我們?cè)趐hp.ini里進(jìn)行配置。當(dāng)php啟動(dòng)的時(shí)候(php_module_startup階段),會(huì)嘗試讀取ini文件并解析。解析過(guò)程簡(jiǎn)單來(lái)說(shuō),是分析ini文件,提取出其中合法的鍵值對(duì),并保存到configuration_hash表。
OK,然后php會(huì)進(jìn)一步調(diào)用zend_startup_extensions來(lái)啟動(dòng)各個(gè)模塊(包含php Core模塊,以及所有需要加載的擴(kuò)展)。各個(gè)模塊的啟動(dòng)函數(shù)中,會(huì)完成REGISTER_INI_ENTRIES動(dòng)作。REGISTER_INI_ENTRIES負(fù)責(zé)將模塊對(duì)應(yīng)的一些配置從configuration_hash表取出,然后調(diào)用處理函數(shù),最終將處理完的值存入模塊的globals變量。
max_input_time、max_execution_time這兩個(gè)配置屬于php Core模塊。對(duì)于php Core來(lái)說(shuō),REGISTER_INI_ENTRIES依然發(fā)生在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
上面說(shuō)到對(duì)于不同的配置,REGISTER_INI_ENTRIES會(huì)調(diào)用不同的函數(shù)來(lái)處理。我們直接來(lái)看max_execution_time對(duì)應(yīng)的函數(shù):
static PHP_INI_MH(OnUpdateTimeout) { // php啟動(dòng)階段走這里 if (stage == PHP_INI_STAGE_STARTUP) { // 將超時(shí)設(shè)置保存到EG(timeout_seconds)中 EG(timeout_seconds) = atoi(new_value); return SUCCESS; } // php執(zhí)行過(guò)程中的ini set則走這里 zend_unset_timeout(TSRMLS_C); EG(timeout_seconds) = atoi(new_value); zend_set_timeout(EG(timeout_seconds), 0); return SUCCESS; }
暫時(shí)只看上半截,因?yàn)槲覀兡壳爸恍桕P(guān)注php的啟動(dòng)階段,該函數(shù)行為很簡(jiǎn)單,將max_execution_time存入了EG(timeout_seconds)。
至于max_input_time,并沒(méi)有特殊的處理函數(shù),默認(rèn)是會(huì)將max_input_time存入存入PG(max_input_time)。
因此,當(dāng)REGISTER_INI_ENTRIES完成,發(fā)生的是:
max_execution_time ----> 存入EG(timeout_seconds)
max_input_time ----> 存入PG(max_input_time)
請(qǐng)求超時(shí)控制
現(xiàn)在我們搞清楚php的啟動(dòng)階段發(fā)生了什么,繼續(xù)來(lái)看php在實(shí)際處理請(qǐng)求的時(shí)候,如何管理超時(shí)。
在php_request_startup函數(shù)中有如下代碼:
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的時(shí)機(jī)很講究。
以cgi為例,只有當(dāng)php已經(jīng)從CGI拿到了原始請(qǐng)求以及一些CGI的環(huán)境變量之后,php_request_startup才會(huì)被調(diào)用。上面這段代碼實(shí)際執(zhí)行的時(shí)候,由于請(qǐng)求已經(jīng)拿到,所以SG(request_info)處于準(zhǔn)備就緒狀態(tài),但是php中的$_GET,$_POST,$_FILE等超全局變量尚未生成。
從代碼上理解:
1、如果用戶將max_input_time配做-1,或沒(méi)有配置,那么腳本的生命周期就只受EG(timeout_seconds)約束。
2、否則,請(qǐng)求啟動(dòng)階段的超時(shí)控制,受PG(max_input_time)約束。
3、zend_set_timeout函數(shù)負(fù)責(zé)設(shè)置定時(shí)器。一旦指定時(shí)間過(guò)去,定時(shí)器會(huì)通知php進(jìn)程。zend_set_timeout下文會(huì)具體分析。
php_request_startup完成,則進(jìn)入php的實(shí)際執(zhí)行階段,即php_execute_script。在php_execute_script中可以看到:
// 設(shè)定執(zhí)行超時(shí) if (PG(max_input_time) != -1) { #ifdef PHP_WIN32 zend_unset_timeout(TSRMLS_C); // 關(guān)閉之前的定時(shí)器 #endif zend_set_timeout(INI_INT("max_execution_time"), 0); } // 進(jìn)入執(zhí)行 retval = (zend_execute_scripts(ZEND_REQUIRE TSRMLS_CC, NULL, 3, prepend_file_p, primary_file, append_file_p) == SUCCESS);
OK,假如代碼執(zhí)行到這里,尚未發(fā)生max_input_time超時(shí),則會(huì)重新指定max_execution_time的超時(shí)。
同樣也是采取調(diào)用zend_set_timeout,并傳入max_execution_time。特別注意一下,windows下面的需要顯式調(diào)用zend_unset_timeout關(guān)閉原來(lái)的定時(shí)器,而linux下不需要。這是由于兩個(gè)平臺(tái)的定時(shí)器實(shí)現(xiàn)原理不同導(dǎo)致的,下文也會(huì)詳細(xì)展開敘述。
最后用一張圖表示超時(shí)控制的流程,左側(cè)的case表明用戶既配置了max_input_time,又配置了max_execution_time。而右側(cè)的區(qū)別在于用戶僅僅配置了max_execution_time:
zend_set_timeout
前文提到,zend_set_timeout函數(shù)用來(lái)設(shè)置定時(shí)器。具體來(lái)看下實(shí)現(xiàn):
void zend_set_timeout(long seconds, int reset_signals) /* {{{ */ { TSRMLS_FETCH(); // 賦值 EG(timeout_seconds) = seconds; #ifdef ZEND_WIN32 if(!seconds) { return; } // 啟動(dòng)定時(shí)器線程 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(); } // 向線程發(fā)送WM_REGISTER_ZEND_TIMEOUT消息 PostThreadMessage(timeout_thread_id, WM_REGISTER_ZEND_TIMEOUT, (WPARAM) GetCurrentThreadId(), (LPARAM) seconds); #else // linux平臺(tái)下 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; // 設(shè)置定時(shí)器,seconds秒后會(huì)發(fā)送SIGPROF信號(hào) setitimer(ITIMER_PROF, &t_r, NULL); } signo = SIGPROF; if (reset_signals) { sigset_t sigset; // 設(shè)置SIGPROF信號(hào)對(duì)應(yīng)的處理函數(shù)為zend_timeout signal(signo, zend_timeout); // 防屏蔽 sigemptyset(&sigset); sigaddset(&sigset, signo); sigprocmask(SIG_UNBLOCK, &sigset, NULL); } #endif }
上述實(shí)現(xiàn)基本上可以完全分成兩種平臺(tái):
先看linux:
linux下的定時(shí)器要容易許多,調(diào)用setitimer函數(shù)就行,此外,zend_set_timeout還設(shè)定了SIGPROF信號(hào)的handler為zend_timeout。
注意,調(diào)用setitimer的時(shí)候,將it_interval設(shè)置成0,表明這個(gè)定時(shí)器只觸發(fā)一次,而不會(huì)每隔一段時(shí)間觸發(fā)一次。setitimer可以以三種方式計(jì)時(shí),php中采用的是ITIMER_PROF,它同時(shí)計(jì)算了用戶代碼和內(nèi)核代碼的執(zhí)行時(shí)間。一旦時(shí)間到了,會(huì)產(chǎn)生SIGPROF信號(hào)。
當(dāng)php進(jìn)程接收到SIGPROF信號(hào),不管當(dāng)前正在執(zhí)行什么,都會(huì)跳轉(zhuǎn)進(jìn)入到zend_timeout。zend_timeout才是實(shí)際處理超時(shí)的函數(shù)。
再看windows:
首先會(huì)啟動(dòng)一個(gè)子線程,該線程主要用于設(shè)置定時(shí)器,同時(shí)維護(hù)EG(timed_out)變量。
子線程一旦生成,主線程便會(huì)向子線程發(fā)送一條消息:WM_REGISTER_ZEND_TIMEOUT。子線程接收到WM_REGISTER_ZEND_TIMEOUT之后,產(chǎn)生一個(gè)定時(shí)器并開始計(jì)時(shí)。同時(shí),子線程會(huì)設(shè)置EG(timed_out) = 0。這很重要!windows平臺(tái)下正是通過(guò)判斷EG(timed_out)是否為1,來(lái)決定是否超時(shí)。
如果定時(shí)器到時(shí)間了,子線程收到WM_TIMER消息,則取消定時(shí)器,并且設(shè)置EG(timed_out) = 1。
如果需要關(guān)閉定時(shí)器,則子線程會(huì)收到WM_UNREGISTER_ZEND_TIMEOUT消息。關(guān)閉定時(shí)器,并不會(huì)改變EG(timed_out)。
相關(guān)代碼還是很清晰的:
static LRESULT CALLBACK zend_timeout_WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_DESTROY: PostQuitMessage(0); break; // 生成一個(gè)定時(shí)器,開始計(jì)時(shí) 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; // 關(guān)閉定時(shí)器 case WM_UNREGISTER_ZEND_TIMEOUT: /* wParam is the thread id pointer */ KillTimer(timeout_window, wParam); break; // 超時(shí)了,也需關(guān)閉定時(shí)器 case WM_TIMER: { KillTimer(timeout_window, wParam); EG(timed_out) = 1; } break; default: return DefWindowProc(hWnd, message, wParam, lParam); } return 0; }
根據(jù)上文描述,最終都是需要跳轉(zhuǎn)到zend_timeout來(lái)處理超時(shí)的。那windows下如何進(jìn)入zend_timeout呢?
window下僅在execute函數(shù)中(zend_vm_execute.h剛開始的地方),可以看到調(diào)用zend_timeout:
while (1) { int ret; #ifdef ZEND_WIN32 if (EG(timed_out)) { // windows下的超時(shí),執(zhí)行每條opcode之前都判斷是否需要調(diào)用zend_timeout zend_timeout(0); } #endif if ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > 0) { ... } }
上述代碼可以看到:
在windows下,每執(zhí)行完成一條opcode指令,就會(huì)進(jìn)行一次超時(shí)判斷。
因?yàn)橹骶€程執(zhí)行opcode的同時(shí),子線程可能已經(jīng)發(fā)生超時(shí),而windows并沒(méi)有什么機(jī)制可以讓主線程停止手頭的工作,直接跳入zend_timeout。所以只好利用子線程先將EG(timed_out)設(shè)置為1,然后主線程在等到當(dāng)前opcode執(zhí)行完成、進(jìn)入下一條opcode之前,判斷一下EG(timed_out)再調(diào)用zend_timeout。
因此準(zhǔn)確的講,windows的超時(shí),其實(shí)是有一點(diǎn)點(diǎn)延時(shí)的。至少在某一個(gè)opcode執(zhí)行的過(guò)程中,無(wú)法被打斷。當(dāng)然,正常情況下,單條opcode的執(zhí)行時(shí)間會(huì)很短。但是可以很容易人為構(gòu)造出一些很耗時(shí)的函數(shù),使得function call需要等待較長(zhǎng)時(shí)間。此時(shí),如果子線程判斷出超時(shí)了,則還需要經(jīng)過(guò)漫長(zhǎng)的等待,直到主線程完成該條opcode之后,才能調(diào)用zend_timeout。
zend_unset_timeout
void zend_unset_timeout(TSRMLS_D) /* {{{ */ { #ifdef ZEND_WIN32 // 通過(guò)發(fā)送WM_UNREGISTER_ZEND_TIMEOUT消息來(lái)關(guān)閉定時(shí)器 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,相當(dāng)于關(guān)閉定時(shí)器 setitimer(ITIMER_PROF, &no_timeout, NULL); } #endif }
zend_unset_timeout同樣分成兩種平臺(tái)的實(shí)現(xiàn)。
先看linux:
linux下的關(guān)閉定時(shí)器也很簡(jiǎn)單。只要將struct itimerval中的4個(gè)值都設(shè)置為0,就行了。
再看windows:
由于windows是利用一個(gè)獨(dú)立的線程來(lái)計(jì)時(shí)。因此,zend_unset_timeout會(huì)向該線程發(fā)送WM_UNREGISTER_ZEND_TIMEOUT消息。WM_UNREGISTER_ZEND_TIMEOUT對(duì)應(yīng)的動(dòng)作是去調(diào)用KillTimer來(lái)關(guān)閉定時(shí)器。注意,線程本身并不退出。
前文留下了一個(gè)問(wèn)題,在php_execute_script中,windows下面要顯示調(diào)用zend_unset_timeout來(lái)關(guān)閉定時(shí)器,而linux下不需要。因?yàn)閷?duì)于一個(gè)linux進(jìn)程來(lái)說(shuō),只能存在一個(gè)setitimer定時(shí)器。也就是說(shuō),重復(fù)調(diào)用setitimer,后面的定時(shí)器會(huì)直接覆蓋前面的。
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) == 1 ? "" : "s"); }
如前文所述,zend_timeout是實(shí)際處理超時(shí)的函數(shù)。它的實(shí)現(xiàn)也很簡(jiǎn)單。
如果有配置exit_on_timeout,則zend_on_timeout會(huì)嘗試調(diào)用sapi_terminate_process關(guān)閉sapi進(jìn)程。如果無(wú)需exit_on_timeout,則直接進(jìn)入zend_error進(jìn)行出錯(cuò)處理。大部分情況下,我們并不會(huì)設(shè)置exit_on_timeout,畢竟我們期望的是雖然一個(gè)請(qǐng)求超時(shí)了,但是進(jìn)程仍然保留下來(lái),服務(wù)下一個(gè)請(qǐng)求。
zend_error除了會(huì)打印錯(cuò)誤日志,還會(huì)利用longjump跳轉(zhuǎn)到boilout指定的棧幀,一般是zend_end_try或者zend_catch宏所在的地方。關(guān)于longjump,可以另起一個(gè)話題,本文就不具體敘述了。在php_execute_script里面,zend_error會(huì)使得程序跳轉(zhuǎn)到zend_end_try的位置然后繼續(xù)執(zhí)行。繼續(xù)執(zhí)行是指,會(huì)調(diào)用php_request_shutdown等函數(shù)來(lái)完成收尾工作。
直到這里,php腳本的超時(shí)機(jī)制算是講清楚了。
最后來(lái)看一個(gè)疑似php內(nèi)核的bug。
windows下max_input_time的bug
回憶一下,之前有提到windows下只有一個(gè)地方調(diào)用了zend_timeout,就是execute函數(shù)里,準(zhǔn)確講是每條opcode執(zhí)行之前。
那么,假如發(fā)生max_input_time類型的超時(shí),即使子線程將EG(timed_out)被置為1,也得延遲到execute中才能進(jìn)行超時(shí)處理。貌似一切正常。
而問(wèn)題的關(guān)鍵之處便在于,我們并不能保證主線程執(zhí)行到execute時(shí),EG(timed_out)任然為1。一旦進(jìn)入execute之前,EG(timed_out)被子線程修改成0,那么max_input_time類型的超時(shí)就永遠(yuǎn)不會(huì)被handle了。
為何EG(timed_out)會(huì)被子線程又修改為0呢?原因在于:php_execute_script中,調(diào)用了zend_set_timeout(INI_INT("max_execution_time"), 0)來(lái)設(shè)置定時(shí)器。
zend_set_timeout會(huì)向子線程發(fā)送WM_REGISTER_ZEND_TIMEOUT消息。子線程收到此消息,除了創(chuàng)建定時(shí)器之外,還會(huì)設(shè)置EG(timed_out) = 0(詳見上文截取的zend_timeout_WndProc代碼片段)。由于線程執(zhí)行的不確定性,因此不能夠判斷主線程執(zhí)行到execute的時(shí)候,子線程是否已接收到消息并設(shè)置EG(timed_out)為0。
如圖所示,
如果execute中的判斷發(fā)生在紅線標(biāo)注的時(shí)間點(diǎn),則EG(timed_out)為1,execute會(huì)調(diào)用zend_timeout做超時(shí)處理。
如果execute中的判斷發(fā)生在藍(lán)線標(biāo)注的時(shí)間點(diǎn),則EG(timed_out)已被重置為0,max_input_time超時(shí)被徹底掩蓋。
相關(guān)文章
thinkphp5.1 文件引入路徑問(wèn)題及注意事項(xiàng)
這篇文章主要介紹了thinkphp5.1 文件引入路徑問(wèn)題,本文給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2018-06-06PHP服務(wù)器端API原理及示例講解(接口開發(fā))
下面小編就為大家分享一篇PHP服務(wù)器端API原理及示例講解(接口開發(fā)),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-03-03ThinkPHP打開驗(yàn)證碼頁(yè)面顯示亂碼的解決方法
這篇文章主要介紹了ThinkPHP打開驗(yàn)證碼頁(yè)面顯示亂碼的解決方法,通過(guò)頭部添加自定義文件驗(yàn)證目錄并過(guò)濾BOM頭來(lái)實(shí)現(xiàn)該功能,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2014-12-12destoon二次開發(fā)常用數(shù)據(jù)庫(kù)操作
這篇文章主要介紹了destoon二次開發(fā)常用數(shù)據(jù)庫(kù)操作,需要的朋友可以參考下2014-06-06Yii2實(shí)現(xiàn)中國(guó)省市區(qū)三級(jí)聯(lián)動(dòng)實(shí)例
本篇文章主要介紹了Yii2實(shí)現(xiàn)中國(guó)省市區(qū)三級(jí)聯(lián)動(dòng)實(shí)例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-02-02ThinkPHP自定義函數(shù)解決模板標(biāo)簽加減運(yùn)算的方法
這篇文章主要介紹了ThinkPHP自定義函數(shù)解決模板標(biāo)簽加減運(yùn)算的方法,實(shí)例分析了ThinkPHP中自定義函數(shù)在模板標(biāo)簽中的使用技巧,需要的朋友可以參考下2015-07-07CentOS 6.3下安裝PHP xcache擴(kuò)展模塊筆記
這篇文章主要介紹了CentOS 6.3下安裝PHP xcache擴(kuò)展模塊筆記,本文包含xchache的編譯安裝、配置、測(cè)試等內(nèi)容,需要的朋友可以參考下2014-09-09