剖析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超时被完全掩盖。

相关文章
相关标签/搜索