>  기사  >  백엔드 개발  >  PHP 스크립트 시간 초과 메커니즘에 대한 자세한 설명

PHP 스크립트 시간 초과 메커니즘에 대한 자세한 설명

*文
*文원래의
2017-12-29 18:04:101852검색

일상적인 개발 중에 PHP 스크립트 실행 시간이 초과되는 상황이 발생할 수 있습니다. 이러한 상황이 발생하면 set_time_limit(비안전 모드)를 사용하거나 구성 파일을 수정하고 서버를 다시 시작하거나 프로그램을 수정하는 경우가 많습니다. 이 문제를 해결하려면 프로그램의 실행 시간을 허용 범위 내로 조정하십시오. 그것이 모두에게 도움이 되기를 바랍니다.

PHP 개발을 할 때 max_input_time 및 max_execution_time을 설정하여 스크립트의 시간 초과를 제어하는 ​​경우가 많습니다. 하지만 그 뒤에 숨어 있는 원리에 대해서는 생각해 본 적이 없습니다.

이틀 동안의 자유 시간을 활용하여 이 문제를 연구하세요.

Timeout 구성

PHP의 ini 구성 작동 방식은 일반적인 주제입니다.

먼저 php.ini에서 구성합니다. PHP가 시작되면(php_module_startup 단계) ini 파일을 읽고 구문 분석하려고 시도합니다. 간단히 말하면, 파싱 프로세스는 ini 파일을 분석하고, 유효한 키-값 쌍을 추출하고, 이를 Configuration_hash 테이블에 저장하는 것입니다.

좋아요, 그러면 PHP는 zend_startup_extensions를 추가로 호출하여 각 모듈(php Core 모듈 및 로드해야 하는 모든 확장 포함)을 시작합니다. 각 모듈의 시작 기능에서 REGISTER_INI_ENTRIES 작업이 완료됩니다. REGISTER_INI_ENTRIES는 Configuration_hash 테이블에서 모듈에 해당하는 일부 구성을 꺼낸 후 처리 함수를 호출하고 마지막으로 처리된 값을 모듈의 전역 변수에 저장하는 역할을 담당합니다.

max_input_time, max_execution_time 이 두 구성은 PHP Core 모듈에 속합니다. php Core의 경우 REGISTER_INI_ENTRIES는 여전히 php_module_startup에서 발생합니다. php Core 모듈에도 속하는 구성에는 visible_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_request_startup은 PHP가 CGI에서 원본 요청과 일부 CGI 환경 변수를 얻은 후에만 호출됩니다. 위의 코드가 실제로 실행되면 요청을 받았기 때문에 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에서는 그렇지 않다는 사실에 특히 주의하세요. 이는 두 플랫폼의 타이머 구현 원리가 다르기 때문입니다. 이에 대해서는 아래에서 자세히 설명하겠습니다.

마지막으로 타임아웃 제어 과정을 그림으로 보여드리겠습니다. 왼쪽의 경우는 사용자가 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 신호 핸들러를 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中才能进行超时处理。貌似一切正常。

문제의 핵심은 메인 스레드가 실행을 실행할 때 EG(timed_out)가 여전히 1이라고 보장할 수 없다는 것입니다. 실행에 들어가기 전에 하위 스레드에 의해 EG(timed_out)가 0으로 수정되면 max_input_time 유형의 시간 초과는 처리되지 않습니다.

하위 스레드에서 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으로 설정하는 것은 불가능합니다.

그림과 같이

빨간 선으로 표시된 시점에 Execution 판단이 발생하면 EG(timed_out)는 1이 되고, Execute는 timeout 처리를 위해 zend_timeout을 호출하게 됩니다.

파란색 선으로 표시된 시점에 실행 판정이 발생하면 EG(timed_out)가 0으로 재설정되어 max_input_time 타임아웃이 완전히 커버된 것입니다.

관련 권장 사항:

PHP의 기본 작동 메커니즘 및 작동 원리에 대한 간략한 분석

PHP 공유 메모리 사용에 대한 자세한 설명

PHP는 텍스트 기반 모스 부호를 생성합니다.

위 내용은 PHP 스크립트 시간 초과 메커니즘에 대한 자세한 설명의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.