【PHP-FPM】重啟過程原始碼詳解

Uchiha_Ponny發表於2019-07-17

一、概要

在工作中,我們經常需要重啟PHP-FPM,那麼這個重啟過程都發生了那些事情呢?讓我們從PHP原始碼中一探究竟吧。

執行環境: Mac 10.14.2 + PHP 7.3.7

二、原始碼解析

訊號在fpm的重啟中扮演著重要的角色。那什麼是訊號呢?

訊號是由使用者、系統或者程式傳送給目標程式的資訊,以通知目標程式某個狀態的改變或系統異常。Linux訊號可由如下條件產生:

  • 對於前臺程式,使用者可以通過輸入特殊的終端字元來給它傳送訊號。
  • 系統異常。比如浮點異常和非法記憶體段訪問。
  • 系統狀態變化。比如 alarm 定時器到期將引起 SIGALARM 訊號。
  • 執行 kill 命令或呼叫 kill 函式

在PHP-FPM中,使用者通過kill命令來重啟fpm,master程式也是通過kill()函式向worker程式傳送訊號來結束程式。fpm的重啟分為優雅重啟(kill -SIGUSR2)和強制重啟(kill -SIGTERM)兩種,下面是以優雅重啟為例,master程式將收到SIGUSR2訊號。

master程式訊號初始化

master程式訊號初始化函式fpm_signals_init_main() 主要做了兩件事情:

  1. 建立unix_socket對sp
  2. 註冊訊號處理函式

建立unix_socket對sp

通過socketpair()來建立這一對雙全工的unix_socket,其中sp[0]的可讀事件在fpm_event_loop()中被註冊到事件佇列中,其回撥函式為fpm_got_signal(),這樣往sp[1]寫入資料時將觸發sp[0]的可讀事件回撥。對這倆unix_socket還有兩個操作:

  1. 設定fd狀態標誌為非阻塞: 呼叫fcntl(fd, F_SETFL, old_flags|O_NONBLOCK),這樣當fd不可讀或不可寫的時候,read()write()不會阻塞,而是直接返回-1,errno設為EAGAIN。
  2. 設定fd標誌為FD_CLOEXEC: 呼叫fcntl(fd, F_SETFD, FD_CLOEXEC),這樣當程式呼叫exec()族函式前會關閉該fd。這麼做是為了防止檔案描述符的洩露,因為呼叫exec()族函式會用新程式替換掉當前程式執行的程式,程式的正文、資料、堆和棧段都會被替換,這就導致原先儲存檔案描述符的變數不存在了,也就無法關閉“老程式“的fd,導致檔案描述符洩露。

註冊訊號處理函式

註冊的訊號有SIGTERMSIGINTSIGUSR1SIGUSR2SIGCHLDSIGQUIT六種。

int fpm_signals_init_main() /* {{{ */
{
	struct sigaction act;

	if (0 > socketpair(AF_UNIX, SOCK_STREAM, 0, sp)) {
		zlog(ZLOG_SYSERROR, "failed to init signals: socketpair()");
		return -1;
	}

	if (0 > fd_set_blocked(sp[0], 0) || 0 > fd_set_blocked(sp[1], 0)) {
		zlog(ZLOG_SYSERROR, "failed to init signals: fd_set_blocked()");
		return -1;
	}

	if (0 > fcntl(sp[0], F_SETFD, FD_CLOEXEC) || 0 > fcntl(sp[1], F_SETFD, FD_CLOEXEC)) {
		zlog(ZLOG_SYSERROR, "falied to init signals: fcntl(F_SETFD, FD_CLOEXEC)");
		return -1;
	}

	memset(&act, 0, sizeof(act));
	act.sa_handler = sig_handler;
	sigfillset(&act.sa_mask);

	if (0 > sigaction(SIGTERM,  &act, 0) ||
	    0 > sigaction(SIGINT,   &act, 0) ||
	    0 > sigaction(SIGUSR1,  &act, 0) ||
	    0 > sigaction(SIGUSR2,  &act, 0) ||
	    0 > sigaction(SIGCHLD,  &act, 0) ||
	    0 > sigaction(SIGQUIT,  &act, 0)) {

		zlog(ZLOG_SYSERROR, "failed to init signals: sigaction()");
		return -1;
	}
	return 0;
}
複製程式碼

worker程式訊號初始化

worker程式訊號初始化函式fpm_signals_init_child() 主要做了三件事情:

  1. 關閉unix_socket對sp
  2. 註冊訊號處理函式
  3. ZendVM初始化訊號

關閉unix_socket對sp

這對unix_socket繼承自master程式,worker程式用不到它們。

註冊訊號處理函式

  • SIGQUIT:處理函式為sig_soft_quit()sa_flags變數設為SA_RESTART表示訊號處理函式返回後重新呼叫被中斷的系統呼叫,這樣worker程式正在處理中的事情不會受到影響。
  • 其他訊號:處理函式為SIG_DFL,即採用預設行為。

ZendVM初始化訊號

呼叫zend_signal_init(),這個不展開講了。

int fpm_signals_init_child() /* {{{ */
{
	struct sigaction act, act_dfl;

	memset(&act, 0, sizeof(act));
	memset(&act_dfl, 0, sizeof(act_dfl));

	act.sa_handler = &sig_soft_quit;
	act.sa_flags |= SA_RESTART;

	act_dfl.sa_handler = SIG_DFL;

	close(sp[0]);
	close(sp[1]);

	if (0 > sigaction(SIGTERM,  &act_dfl,  0) ||
	    0 > sigaction(SIGINT,   &act_dfl,  0) ||
	    0 > sigaction(SIGUSR1,  &act_dfl,  0) ||
	    0 > sigaction(SIGUSR2,  &act_dfl,  0) ||
	    0 > sigaction(SIGCHLD,  &act_dfl,  0) ||
	    0 > sigaction(SIGQUIT,  &act,      0)) {

		zlog(ZLOG_SYSERROR, "failed to init child signals: sigaction()");
		return -1;
	}

	zend_signal_init();
	return 0;
}
複製程式碼

master程式訊號處理

呼叫訊號處理函式

master程式收到SIGUSR2訊號後將回撥sig_handler()進行訊號處理。我們可以看到SIGUSR2被對映為2,並寫入到 sp[1]

static void sig_handler(int signo) /* {{{ */
{
	static const char sig_chars[NSIG + 1] = {
		[SIGTERM] = 'T',
		[SIGINT]  = 'I',
		[SIGUSR1] = '1',
		[SIGUSR2] = '2',
		[SIGQUIT] = 'Q',
		[SIGCHLD] = 'C'
	};
	char s;
	int saved_errno;

	if (fpm_globals.parent_pid != getpid()) {
		/* prevent a signal race condition when child process
			have not set up it's own signal handler yet */
		return;
	}

	saved_errno = errno;
	s = sig_chars[signo];
	zend_quiet_write(sp[1], &s, sizeof(s));		//實際呼叫write()
	errno = saved_errno;
}
複製程式碼

當往sp[1]寫入資料後,sp[0]變為可讀,觸發事件回撥fpm_got_signal()。從sp[0]讀取到寫入的資料 2,之後呼叫fpm_pctl() 來進行重啟操作。

static void fpm_got_signal(struct fpm_event_s *ev, short which, void *arg) /* {{{ */
{
	char c;
	int res, ret;
	int fd = ev->fd;

	do {
		do {
			res = read(fd, &c, 1);
		} while (res == -1 && errno == EINTR);

		if (res <= 0) {
			if (res < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
				zlog(ZLOG_SYSERROR, "unable to read from the signal pipe");
			}
			return;
		}

		switch (c) {
      case 'C' :                  /* SIGCHLD */
				zlog(ZLOG_DEBUG, "received SIGCHLD");
				fpm_children_bury();
				break;  
			......
			case '2' :                  /* SIGUSR2 */
				zlog(ZLOG_DEBUG, "received SIGUSR2");
				zlog(ZLOG_NOTICE, "Reloading in progress ...");
				fpm_pctl(FPM_PCTL_STATE_RELOADING, FPM_PCTL_ACTION_SET);
				break;
		}

		if (fpm_globals.is_child) {
			break;
		}
	} while (1);
	return;
}
複製程式碼

切換fpm狀態為reloading

由下面的fpm_pctl()程式碼可知,對於FPM_PCTL_ACTION_SET操作只有當fpm狀態fpm_state為正常時(FPM_PCTL_STATE_NORMAL),重啟操作才能進行下去。

之後將重置已傳送訊號(fpm_signal_sent=0),並設定fpm當前狀態為FPM_PCTL_STATE_RELOADING,然後呼叫fpm_pctl_action_next()進行下一步操作。

void fpm_pctl(int new_state, int action) /* {{{ */
{
	switch (action) {
		case FPM_PCTL_ACTION_SET :
			if (fpm_state == new_state) { /* already in progress - just ignore duplicate signal */
				return;
			}

			switch (fpm_state) { /* check which states can be overridden */
				case FPM_PCTL_STATE_NORMAL :
					/* 'normal' can be overridden by any other state */
					break;
				case FPM_PCTL_STATE_RELOADING :
					/* 'reloading' can be overridden by 'finishing' */
					if (new_state == FPM_PCTL_STATE_FINISHING) break;
				case FPM_PCTL_STATE_FINISHING :
					/* 'reloading' and 'finishing' can be overridden by 'terminating' */
					if (new_state == FPM_PCTL_STATE_TERMINATING) break;
				case FPM_PCTL_STATE_TERMINATING :
					/* nothing can override 'terminating' state */
					zlog(ZLOG_DEBUG, "not switching to '%s' state, because already in '%s' state",
						fpm_state_names[new_state], fpm_state_names[fpm_state]);
					return;
			}

			fpm_signal_sent = 0;
			fpm_state = new_state;

			zlog(ZLOG_DEBUG, "switching to '%s' state", fpm_state_names[fpm_state]);
			/* fall down */

		case FPM_PCTL_ACTION_TIMEOUT :
			fpm_pctl_action_next();
			break;
		case FPM_PCTL_ACTION_LAST_CHILD_EXITED :
			fpm_pctl_action_last();
			break;

	}
}
複製程式碼

向worker程式傳送訊號

此階段可以看成是三個升級訊號的傳送過程:

  1. SIGQUIT: 首先傳送SIGQUIT訊號,worker程式收到後會進行優雅關閉,並設定一個超時時為process_control_timeout的定時器事件,關於process_control_timeout 可以看我另外一篇文章【PHP】配置檔案中的超時時間解析,定時器超時後最終將呼叫fpm_pctl(FPM_PCTL_STATE_UNSPECIFIED, FPM_PCTL_ACTION_TIMEOUT);,從action名稱可以看出是要進行超時的操作。
  2. SIGTERM:fpm_pctl()原始碼可知,action FPM_PCTL_ACTION_TIMEOUT 仍然呼叫fpm_pctl_action_next(),只不過這次SIGQUIT訊號會升級為SIGTERM傳送給worker程式,定時器超時時間變為1s。
  3. SIGKILL: 定時器又超時後,SIGTERM會升級為終極訊號SIGKILLSIGKILL訊號相比SIGTERM是不可被捕獲或者忽略的,它將強行終止worker程式。
static void fpm_pctl_action_next() /* {{{ */
{
	int sig, timeout;

	if (!fpm_globals.running_children) {
		fpm_pctl_action_last();
	}

	if (fpm_signal_sent == 0) {
		if (fpm_state == FPM_PCTL_STATE_TERMINATING) {
			sig = SIGTERM;
		} else {
			sig = SIGQUIT;
		}
		timeout = fpm_global_config.process_control_timeout;
	} else {
		if (fpm_signal_sent == SIGQUIT) {
			sig = SIGTERM;
		} else {
			sig = SIGKILL;
		}
		timeout = 1;
	}

  // 實際呼叫kill()
	fpm_pctl_kill_all(sig);
	fpm_signal_sent = sig;
	fpm_pctl_timeout_set(timeout);
}
複製程式碼

worker程式訊號處理

worker程式主要處理master傳送過來的三個訊號,即SIGQUITSIGTERMSIGKILL

  • SIGQUIT:worker程式訊號初始化階段我們知道,SIGQUIT訊號的回撥事件是sig_soft_quit()。它首先會關閉listening_socket,並且將in_shutdown置為1,這樣accept()系統呼叫將立即返回-1,worker程式不再接收請求,開始結束程式的操作。
static void sig_soft_quit(int signo) /* {{{ */
{
	int saved_errno = errno;

	/* closing fastcgi listening socket will force fcgi_accept() exit immediately */
	close(fpm_globals.listening_socket);
	if (0 > socket(AF_UNIX, SOCK_STREAM, 0)) {
		zlog(ZLOG_WARNING, "failed to create a new socket");
	}
  // 設定in_shutdown=1
	fpm_php_soft_quit();
	errno = saved_errno;
}

int fcgi_accept_request(fcgi_request *req)
{
	while (1) {
		if (req->fd < 0) {
			while (1) {
				if (in_shutdown) {
					return -1;
				}
        ......
        req->fd = accept(listen_socket, (struct sockaddr *)&sa, &len);
        ......
      }
    } else {
			fcgi_close(req, 1, 1);
		}  
  }
}
複製程式碼
  • SIGTERM: SIGTERM訊號採用SIG_DFL預設處理方式,即終止程式,可以被阻塞、捕獲、忽略。
  • SIGKILL: SIGKILL訊號不能被捕獲或者忽略,將強行終止worker程式。

master程式對worker的善後處理

worker程式的狀態發生變化時,被終止或者暫停,核心會向master程式傳送一個非同步通知,即SIGCHLD訊號,由訊號處理函式fpm_got_signal()可知將執行fpm_children_bury()

下面將fpm_children_bury()的程式碼拆解到對應部分下。

waitpid()介紹

在這裡先介紹下waitpid()是幹嘛的:

當子程式結束的時候,核心會為終止子程式儲存一定量的資訊,這些資訊至少包括程式ID、該程式的的終止狀態、以及該程式使用的CPU時間總量。

一個已經終止、但是其父程式尚未對其進行善後處理(獲取終止子程式的有關資訊,釋放它仍佔用的資源)的程式會成為殭屍程式殭屍程式的程式號會被一直佔用著,但是系統所能使用的程式號是有限的,所以如果有大量的殭屍程式產生,將因為沒有可用的程式號而導致系統不能產生新的程式。

wait()waitpid()就可以讓父程式獲取到這些資訊,並被核心釋放掉。

// 最外層迴圈
while ( (pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
	......
}
複製程式碼

終止狀態判斷

master程式通過waitpid()獲取到終止的worker程式的pid 和終止狀態status後,將對status進行一些判斷

  1. WIFEXITED(status): 這是正常終止的子程式返回的狀態。
  2. WIFSIGNALED(status): 這是異常終止子程式返回的狀態,比如直接向子程式傳送終止訊號。通過WTERMSIG(status)來獲取時子程式終止的訊號編號。
  3. WIFSTOPPED(status): 這是暫停子程式返回的狀態。如果fpm開啟了slowlog,那麼當請求時間超過request_slowlog_timeout後,master程式的心跳檢測模組會給worker程式傳送SIGSTOP訊號,worker程式被暫停,狀態發生變化,核心向master程式傳送SIGCHLD訊號,之後就會執行到這裡。最後將呼叫fpm_php_trace()函式來列印導致請求slow的堆疊資訊。
if (WIFEXITED(status)) {

	snprintf(buf, sizeof(buf), "with code %d", WEXITSTATUS(status));

	/* if it's been killed because of dynamic process management
		* don't restart it automaticaly
		*/
	if (child && child->idle_kill) {
		restart_child = 0;
	}

  // 呼叫fpm_php_trace()
	if (WEXITSTATUS(status) != FPM_EXIT_OK) {
		severity = ZLOG_WARNING;
	}

} else if (WIFSIGNALED(status)) {
	const char *signame = fpm_signal_names[WTERMSIG(status)];
	const char *have_core = WCOREDUMP(status) ? " - core dumped" : "";

	if (signame == NULL) {
		signame = "";
	}

	snprintf(buf, sizeof(buf), "on signal %d (%s%s)", WTERMSIG(status), signame, have_core);

	/* if it's been killed because of dynamic process management
		* don't restart it automaticaly
		*/
	if (child && child->idle_kill && WTERMSIG(status) == SIGQUIT) {
		restart_child = 0;
	}

	if (WTERMSIG(status) != SIGQUIT) { /* possible request loss */
		severity = ZLOG_WARNING;
	}
} else if (WIFSTOPPED(status)) {

	zlog(ZLOG_NOTICE, "child %d stopped for tracing", (int) pid);

	if (child && child->tracer) {
		child->tracer(child);
	}

	continue;
}
複製程式碼

善後worker程式

child = fpm_child_find(pid);

if (child) {
	struct fpm_worker_pool_s *wp = child->wp;
	struct timeval tv1, tv2;

  // 資源釋放  
	fpm_child_unlink(child);
	fpm_scoreboard_proc_free(wp->scoreboard, child->scoreboard_i);
	fpm_clock_get(&tv1);
	timersub(&tv1, &child->started, &tv2);

	......

  // 關閉標準輸出、標準錯誤
	fpm_child_close(child, 1 /* in event_loop */);
  
  // 在後文中詳解
	fpm_pctl_child_exited();

	......
    
} else {
	zlog(ZLOG_ALERT, "oops, unknown child (%d) exited %s. Please open a bug report (https://bugs.php.net).", pid, buf);
}
複製程式碼

fpm_pctl_child_exited()原始碼可知,如果這是最後一個worker程式的終止,將呼叫fpm_pctl(FPM_PCTL_STATE_UNSPECIFIED, FPM_PCTL_ACTION_LAST_CHILD_EXITED);

int fpm_pctl_child_exited() /* {{{ */
{
	if (fpm_state == FPM_PCTL_STATE_NORMAL) {
		return 0;
	}

	if (!fpm_globals.running_children) {
		fpm_pctl(FPM_PCTL_STATE_UNSPECIFIED, FPM_PCTL_ACTION_LAST_CHILD_EXITED);
	}
	return 0;
}
複製程式碼

繼續追蹤原始碼會發現,在重啟操作中最後會呼叫fpm_pctl_exec()

execvp()函式將重新執行php-fpm程式,當前程式的正文、資料、堆和棧段都將被替換掉。

static void fpm_pctl_exec() /* {{{ */
{
	fpm_cleanups_run(FPM_CLEANUP_PARENT_EXEC);
	execvp(saved_argv[0], saved_argv);
  // 正常情況不會走到這裡
	zlog(ZLOG_SYSERROR, "failed to reload: execvp() failed");
	exit(FPM_EXIT_SOFTWARE);
}
複製程式碼

至此,PHP-FPM就完成了重啟。

三、重啟日誌

PHP列印了很多Debug日誌,大家可以在php-fpm.conf中將log_level選項設定為debug來開啟。下面是debug日誌的例子,可以對照著理解下上文內容。

[16-Jul-2019 16:51:40.248439] DEBUG: pid 36507, fpm_got_signal(), line 110: received SIGUSR2
[16-Jul-2019 16:51:40.248711] NOTICE: pid 36507, fpm_got_signal(), line 111: Reloading in progress ...
[16-Jul-2019 16:51:40.248909] DEBUG: pid 36507, fpm_pctl(), line 229: switching to 'reloading' state
[16-Jul-2019 16:51:40.249112] DEBUG: pid 36507, fpm_pctl_kill_all(), line 157: [pool www] sending signal 3 SIGQUIT to child 36508
[16-Jul-2019 16:51:40.249360] DEBUG: pid 36507, fpm_pctl_kill_all(), line 166: 1 child(ren) still alive
[16-Jul-2019 16:51:40.249624] DEBUG: pid 36507, fpm_event_loop(), line 417: event module triggered 1 events
[16-Jul-2019 16:51:40.256626] DEBUG: pid 36507, fpm_got_signal(), line 74: received SIGCHLD
[16-Jul-2019 16:51:40.256968] DEBUG: pid 36507, fpm_children_bury(), line 259: [pool www] child 36508 exited with code 0 after 16.412179 seconds from start
[16-Jul-2019 16:51:40.257411] NOTICE: pid 36507, fpm_pctl_exec(), line 96: reloading: execvp("/usr/local/Cellar/php/7.3.7/sbin/php-fpm", {"/usr/local/Cellar/php/7.3.7/sbin/php-fpm", "--fpm-config=/usr/local/etc/php/7.3.7/php-fpm.conf", "--pid=/usr/local/Cellar/php/7.3.7/var/run/php-fpm.pid"})
[16-Jul-2019 16:51:40.319184] DEBUG: pid 36507, fpm_unix_init_main(), line 518: The calling process is waiting for the master process to ping via fd=4
[16-Jul-2019 16:51:40.321064] DEBUG: pid 36699, fpm_scoreboard_init_main(), line 38: got clock tick '100'
[16-Jul-2019 16:51:40.321588] NOTICE: pid 36699, fpm_sockets_init_main(), line 417: using inherited socket fd=7, "127.0.0.1:9001"
[16-Jul-2019 16:51:40.321588] NOTICE: pid 36699, fpm_sockets_init_main(), line 417: using inherited socket fd=7, "127.0.0.1:9001"
[16-Jul-2019 16:51:40.321782] DEBUG: pid 36699, fpm_socket_af_inet_socket_by_addr(), line 290: Found address for 127.0.0.1, socket opened on 127.0.0.1
[16-Jul-2019 16:51:40.321969] DEBUG: pid 36699, fpm_event_init_main(), line 335: event module is kqueue and 1 fds have been reserved
[16-Jul-2019 16:51:40.322374] NOTICE: pid 36699, fpm_init(), line 83: fpm is running, pid 36699
[16-Jul-2019 16:51:40.322505] DEBUG: pid 36699, main(), line 1858: Sending "1" (OK) to parent via fd=5
[16-Jul-2019 16:51:40.322648] DEBUG: pid 36507, fpm_unix_init_main(), line 537: I received a valid acknowledge from the master process, I can exit without error
[16-Jul-2019 16:51:40.322977] DEBUG: pid 36699, fpm_children_make(), line 428: [pool www] child 36702 started
[16-Jul-2019 16:51:40.323302] DEBUG: pid 36699, fpm_event_loop(), line 364: 1296 bytes have been reserved in SHM
[16-Jul-2019 16:51:40.323498] NOTICE: pid 36699, fpm_event_loop(), line 365: ready to handle connections
複製程式碼

相關文章