寫過 CLI 常駐程式的老司機肯定遇到過這麼一個問題:在需要更新程式的時候,我要怎樣才能安全關閉老程式?你可能會想到 NGINX
、php-fpm
之類的平滑重啟是給程式傳送 USR2
訊號,然後它就會將當前請求處理完再退出。
但程式是怎樣接收訊號、處理訊號,估計就不是很多人能說清楚了。
原理
要實現平滑關閉/重啟不難,這裡先講解兩個知識點:
阻塞訊號
當我們的程式正在處理一個任務的時候,你肯定不希望它中途被終止,比如說你在執行一個資料庫事務,肯定不希望事務還沒被提交程式就被終止了。
<?php
echo "開始執行事務" . PHP_EOL;
// 模擬一些耗時的操作
$finish_time = time() + 5;
while (time() < $finish_time) {
}
echo "事務執行完畢" . PHP_EOL;
上面這段程式碼,如果你在第二個 echo
之前用 kill
命令去殺死這個程式,那麼第二個 echo
就不會被執行了。那能不能做到在事務過程中暫時先忽略 kill
訊號呢?
能。我們可以使用 pcntl_sigprocmask()
來阻塞訊號,讓事務完成之後再響應 kill
訊號。
<?php
// 阻塞訊號
$sig_set = array(SIGINT, SIGTERM); // 要阻塞的訊號集合
pcntl_sigprocmask(SIG_BLOCK, $sig_set); // SIG_BLOCK: 把訊號加入到當前阻塞訊號中
echo date("[Y-m-d H:i:s]") . " 開始執行事務" . PHP_EOL;
$finish_time = time() + 5;
while (time() < $finish_time) {
}
echo date("[Y-m-d H:i:s]") . "事務執行完畢" . PHP_EOL;
pcntl_sigprocmask(SIG_UNBLOCK, $sig_set); // SIG_UNBLOCK: 從當前阻塞訊號中移出訊號
同樣的,在第二個 echo
之前按下 Ctrl + C
或者用 kill
命令去殺這個程式,你會發現第二個 echo
正常執行了,並且兩條輸出的時間間隔是 5 秒。
我們的常駐程式通常是在一個 while(true)
迴圈中去執行重複的任務,如果這麼寫的話:
<?php
while (true) {
pcntl_sigprocmask(SIG_BLOCK, $sig_set);
// ...
pcntl_sigprocmask(SIG_UNBLOCK, $sig_set);
}
我們是可以保證一個事務不會被打斷,但是我們的程式還不知道是不是已經接收到訊號了,並且把阻塞訊號移除之後程式立刻就退出了,沒辦法去做一些收尾工作(比如關閉檔案)。
處理訊號
為了解決上面提到的問題,我們需要在訊號發生的時候去做收尾工作,然後再退出程式。
pcntl
擴充套件提供了一些訊號相關的函式,我們可以使用 pcntl_signal()
和 pcntl_signal_dispatch()
來註冊訊號處理器和分發訊號。
<?php
$sig_handler = function ($signo) {
echo "收到訊號 {$signo}" . PHP_EOL;
};
pcntl_signal(SIGINT, $sig_handler); // 給 SIGINT 訊號註冊一個處理器
// 模擬耗時操作
echo "開始執行事務" . PHP_EOL;
$finish_time = time() + 5;
while(true) {
if (time() > $finish_time) {
echo "事務執行完畢" . PHP_EOL;
break;
}
}
pcntl_signal_dispatch(); // 分發訊號
執行上面這段程式碼並在 5 秒內按下 Ctrl + C
,你會看到 sig_handler
被執行了;而如果不按下 Ctrl + C
,那麼 sig_handler
就不會被執行。
到這裡你應該已經理解了 pcntl_signal()
和 pcntl_signal_dispatch()
的用法了,把它放到到剛剛的程式碼試試
<?php
$sig_handler = function ($signo) {
echo "收到訊號 {$signo}" . PHP_EOL;
};
$sig_set = array(SIGINT, SIGTERM);
foreach ($sig_set as $sig) {
pcntl_signal($sig, $sig_handler); // 註冊多個訊號
}
// [1]
while (true) {
// [2-1]
pcntl_sigprocmask(SIG_BLOCK, $sig_set);
// [2-2]
// ...
// [2-3]
pcntl_sigprocmask(SIG_UNBLOCK, $sig_set);
// [2-4]
}
// [3]
pcntl_signal_dispatch()
該放哪裡呢?是 [1]
[2]
還是 [3]
?先動手試一下
然後你會發現,只有放在 [2]
才能讓訊號處理器執行。同時這個實驗也告訴我們 pcntl_signal_dispatch()
要在訊號發生後才會使處理器執行:放在 [1]
時,除非你手速足夠快,不然在你按下 Ctrl + C
或者是 kill
之前就已經執行過了;而放在 [3]
它就永遠沒機會執行。
至於放在 [2]
的哪個位置,我建議是放在 [2-4]
,因為這個時候已經處理完任務了。
拼起來
到這裡你已經瞭解平滑關閉/重啟的原理了,我們把上面的半成品程式碼(因為在收到訊號後可能還會進入下一層迴圈)整理一下:
<?php
$running = true;
$sig_handler = function ($signo) use (&$running) {
echo "收到訊號 {$signo}" . PHP_EOL;
// 做收尾工作
$running = false;
};
$sig_set = array(SIGINT, SIGTERM, SIGUSR2 /* 熟悉的 USR2 訊號不能漏 */);
foreach ($sig_set as $sig) {
pcntl_signal($sig, $sig_handler); // 註冊多個訊號
}
while ($running) {
pcntl_sigprocmask(SIG_BLOCK, $sig_set);
// ... 業務邏輯
pcntl_sigprocmask(SIG_UNBLOCK, $sig_set);
pcntl_signal_dispatch();
}
我們就得到了一個可以平滑程式的常駐程式框架,你也可以把它封裝成一個類。
思考
細心的你可能會發現,上面這段程式碼如果業務邏輯出現了死迴圈,還是沒辦法退出,那麼我們能不能設定個超時強制開始處理收尾工作然後退出程式呢?
先思考一下,以後我再寫一篇文章說明 :-)
本文首發於本人部落格:https://yian.me/blog/what-is/php-graceful-shutdown.html