PHP開発を行う際、スクリプトのタイムアウトを制御するためにmax_input_timeとmax_execution_timeを設定することが多いです。しかし、その背後にある原理については考えたこともありませんでした。
この 2 日間の自由時間を利用して、この問題を学習してください。
タイムアウト設定
PHP の ini 設定がどのように機能するかはよくあるトピックです。
まず、php.iniで設定します。 php が起動すると (php_module_startup 段階)、ini ファイルを読み取って解析しようとします。簡単に言うと、解析プロセスは、ini ファイルを分析し、正当なキーと値のペアを抽出して、configuration_hash テーブルに保存することです。
OK、その後、php はさらに zend_startup_extensions を呼び出して各モジュール (php コア モジュールとロードする必要があるすべての拡張機能を含む) を開始します。各モジュールのスタートアップ関数では、REGISTER_INI_ENTRIES アクションが完了します。 REGISTER_INI_ENTRIES は、configuration_hash テーブルからモジュールに対応するいくつかの構成を取り出し、処理関数を呼び出し、最後に処理された値をモジュールのグローバル変数に格納する役割を果たします。
max_input_time、max_execution_time これら 2 つの設定は php Core モジュールに属します。 php Core の場合、REGISTER_INI_ENTRIES は引き続き php_module_startup で発生します。 php Core モジュールにも属する設定には、expose_php、display_errors、memory_limit などが含まれます...
概略図は次のとおりです。
リーリー上で述べたように、REGISTER_INI_ENTRIES は構成ごとに異なる関数を呼び出します。 max_execution_time に対応する関数を直接見てみましょう:
リーリーPHP の起動フェーズに注目するだけなので、今のところ前半だけを見てください。この関数の動作は非常に単純で、max_execution_time は EG (timeout_秒) に格納されます。
max_input_time に関しては、特別な処理関数はありません。デフォルトでは、max_input_time は PG (max_input_time) に格納されます。
REGISTER_INI_ENTRIES が完了すると、次のことが起こります:
max_execution_time ----> EG(timeout_秒)に保存します
max_input_time ----> PG(max_input_time)に保存します
リクエストタイムアウト制御
php_request_startup 関数には次のコードがあります:
リーリー
php_request_startupのタイミングは非常に特殊です。CGI を例にとると、php_request_startup は、php が元のリクエストと CGI からいくつかの CGI 環境変数を取得した後にのみ呼び出されます。実際に上記のコードを実行すると、リクエストは取得できているのでSG(request_info)は準備完了状態ですが、PHPの$_GET、$_POST、$_FILEなどのスーパーグローバル変数はまだ生成されていません。
コードから理解する:
1. ユーザーが max_input_time を -1 に設定するか、設定しない場合、スクリプトのライフサイクルは EG (timeout_秒) によってのみ制限されます。
2. それ以外の場合、リクエスト起動フェーズのタイムアウト制御はPG(max_input_time)の対象となります。
3. zend_set_timeout 関数はタイマーを設定します。指定した時間が経過すると、タイマーが PHP プロセスに通知します。 zend_set_timeout については、以下で詳しく分析します。
php_request_startupが完了すると、phpの実際の実行フェーズ、つまりphp_execute_scriptに入ります。 php_execute_script で確認できます:
リーリー
OK、ここでコードが実行され、max_input_time タイムアウトが発生していない場合、max_execution_time のタイムアウトが再指定されます。zend_set_timeout を呼び出して max_execution_time を渡すことによっても同じことが行われます。 Windows では元のタイマーをオフにするには、zend_unset_timeout を明示的に呼び出す必要があるが、Linux ではそうではないことに特に注意してください。これは、2 つのプラットフォームでのタイマーの実装原理が異なるためです。これについては、以下で詳しく説明します。
最後に、図はタイムアウト制御プロセスを表すために使用されます。左側のケースは、ユーザーが max_input_time と max_execution_time の両方を設定したことを示しています。右側の違いは、ユーザーが max_execution_time のみを設定したことです:
zend_set_timeout
リーリー
上記の実装は基本的に 2 つのプラットフォームに完全に分割できます:
まず Linux について見てみましょう:
setitimer を呼び出すときは、it_interval を 0 に設定して、このタイマーが 2 回おきではなく 1 回だけトリガーされることを示すことに注意してください。 setitimer は 3 つの方法で時間を測定できます。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中,调用了zend_set_timeout(INI_INT("max_execution_time"), 0)来设置定时器。
zend_set_timeout は、WM_REGISTER_ZEND_TIMEOUT メッセージを子スレッドに送信します。子スレッドがこのメッセージを受信すると、タイマーの作成に加えて、EG(timed_out) = 0 も設定されます (詳細については、上でインターセプトした zend_timeout_WndProc コード スニペットを参照してください)。スレッドの実行は不確実であるため、メインスレッドの実行時に子スレッドがメッセージを受信したかどうかを判断して EG (timed_out) を 0 に設定することはできません。
写真に示すように、
赤線の時点でexecuteの判定が発生した場合、EG(timed_out)が1となり、executeはzend_timeoutを呼び出してタイムアウト処理を行います。
青線の時点で実行判定が発生した場合、EG(timed_out)は0にリセットされており、max_input_timeタイムアウトは完全にカバーされています。