首頁  >  文章  >  後端開發  >  php腳本超時機制詳解

php腳本超時機制詳解

*文
*文原創
2017-12-29 18:04:101851瀏覽

在我們平常的開發中,也許曾經都遇到過PHP腳本運行逾時的情況,當遇到這種情況我們經常會透過使用set_time_limit(非安全模式),或修改設定檔並重啟伺服器,或者修改程序減少程序的執行時間,使其在允許的範圍之內,以解決此問題。希望對大家有幫助。

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

趁這兩天有空,研究一下這個問題。

逾時配置

php的ini配置如何運作,這是一個老生常談的話題了。

首先,我們在php.ini裡進行設定。當php啟動的時候(php_module_startup階段),會嘗試讀取ini檔案並解析。解析過程簡單來說,是分析ini文件,提取出其中合法的鍵值對,並儲存到configuration_hash表。

OK,然後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
    |
    |
    |-->do otherthings

上面說到對於不同的配置,REGISTER_INI_ENTRIES會呼叫不同的函式來處理。我們直接來看max_execution_time對應的函數:


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

至於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等超全域變數尚未產生。

從程式碼上理解:

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_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脚本的超时机制算是讲清楚了。

最后来看一个疑似php内核的bug。

windows下max_input_time的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中,呼叫了z​​end_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逾時被徹底掩蓋。

相關推薦:

淺析PHP底層的運作機制與工作原理

PHP共享記憶體使用詳解

PHP產生基於文字的摩斯電碼

#

以上是php腳本超時機制詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn