剖析php腳本的超時機制

在作php開發的時候,常常會設置max_input_time、max_execution_time,用來控制腳本的超時時間。但卻歷來沒有思考過背後的原理。php

趁着這兩天有空,研究一下這個問題。文中源碼取自php5.4.44版本。linux

超時配置

php的ini配置如何起做用,這是一個老生常談的話題了。json

首先,咱們在php.ini裏進行配置。當php啓動的時候(php_module_startup階段),會嘗試讀取ini文件並解析。解析過程簡單來講,是分析ini文件,提取出其中合法的鍵值對,並保存到configuration_hash表。windows

OK,而後php會進一步調用zend_startup_extensions來啓動各個模塊(包含php Core模塊,以及全部須要加載的擴展)。各個模塊的啓動函數中,會完成REGISTER_INI_ENTRIES動做。REGISTER_INI_ENTRIES負責將模塊對應的一些配置從configuration_hash表取出,而後調用處理函數,最終將處理完的值存入模塊的globals變量。api

max_input_time、max_execution_time這兩個配置屬於php Core模塊。對於php Core來講,REGISTER_INI_ENTRIES依然發生在php_module_startup中。一樣屬於php Core模塊的配置還有expose_php、display_errors、memory_limit等等...app

示意圖以下:ide

---->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對應的函數:ui

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)。this

至於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) == -1) {
    zend_set_timeout(EG(timeout_seconds), 1);
} else {
    zend_set_timeout(PG(max_input_time), 1);
}

php_request_startup的時機很講究。

以cgi爲例,只有當php已經從CGI拿到了原始請求以及一些CGI的環境變量以後,php_request_startup纔會被調用。上面這段代碼實際執行的時候,因爲請求已經拿到,因此SG(request_info)處於準備就緒狀態,可是php中的$_GET$_POST$_FILE等超全局變量還沒有生成。

從代碼上理解:

一、若是用戶將max_input_time配作-1,或沒有配置,那麼腳本的生命週期就只受EG(timeout_seconds)約束。

二、不然,請求啓動階段的超時控制,受PG(max_input_time)約束。

三、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_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的超時。

一樣也是採起調用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 == 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
}

上述實現基本上能夠徹底分紅兩種平臺:

  • 先看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(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呢?

window下僅在execute函數中(zend_vm_execute.h剛開始的地方),能夠看到調用zend_timeout:

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) {
    ...
    }
}

上述代碼能夠看到:

在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) 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一樣分紅兩種平臺的實現。

  • 先看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) == 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宏所在的地方。關於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超時被完全掩蓋。

相關文章
相關標籤/搜索