引言
最近開發一個小功能,用到了佇列mcq,啟動一個程式消費佇列資料,後邊發現一個程式處理不過來了,又加了一個程式,過了段時間又處理不過來了…
這種方式每次都要修改crontab,如果程式掛掉了,不會及時的啟動,要等到下次crontab執行的時候才會啟動。關閉(重啟)程式的時候用的是kill,這可能會丟失正在處理的資料,比如下面這個例子,我們假設sleep過程就是處理邏輯,這裡為了明顯看出效果,將處理時間放大到10s:
<?php $i = 1; while (1) { echo "開始第[{$i}]次迴圈\n"; sleep(10); echo "結束第[{$i}]次迴圈\n"; $i++; }
當我們執行指令碼之後,等到迴圈開始之後,給程式傳送 kill {KaTeX parse error: Expected 'EOF', got '}' at position 4: pid}̲,預設傳送的是編號為15的SI…i是從佇列拿到的,拿到2的時候,正在處理,我們給程式傳送了kill訊號,和佇列資料丟失一樣,問題比較大,因此我要想辦法解決這些問題。
開始第[1]次迴圈 結束第[1]次迴圈 開始第[2]次迴圈 [1] 28372 terminated php t.php
nginx程式模型
這時候我想到了nginx,nginx作為高效能伺服器的中流砥柱,為成千上萬的企業和個人服務,他的程式模型比較經典,如下所示:
管理員通過master程式和nginx進行互動,從/path/to/nginx.pid讀取nginx master程式的pid,傳送訊號給master程式,master根據不同的訊號做出不同的處理,然後反饋資訊給管理員。worker是master程式fork出來的,master負責管理worker,不會去處理業務,worker才是具體業務的處理者,master可以控制worker的退出、啟動,當worker意外退出,master會收到子程式退出的訊息,也會重新啟動新的worker程式補充上來,不讓業務處理受影響。nginx還可以平滑退出,不丟失任何一個正在處理的資料,更新配置時nginx可以做到不影響線上服務來載入新的配置,這在請求量很大的時候特別有用。
程式設計
看了nginx的進模型,我們完全可以開發一個類似的類庫來滿足處理mcq資料的需求,做到單檔案控制所有程式、可以平滑退出、可以檢視子程式狀態。不需要太複雜,因為我們處理佇列資料接收一定的延遲,做到nginx那樣不間斷服務比較麻煩,費時費力,意義不是很大。設計的程式模型跟nginx類似,更像是nginx的簡化版本。
程式訊號量設計
訊號量是程式間通訊的一種方式,比較簡單,單功能也比較弱,只能傳送訊號給程式,程式根據訊號做出不同的處理。
master程式啟動的時候儲存pid到檔案/path/to/daeminze.pid,管理員通過訊號和master程式通訊,master程式安裝3種訊號,碰到不同的訊號,做出不同的處理,如下所示:
SIGINT => 平滑退出,處理完正在處理的資料再退出 SIGTERM => 暴力退出,無論程式是否正在處理資料直接退出 SIGUSR1 => 檢視程式狀態,檢視程式佔用記憶體,執行時間等資訊
master程式通過訊號和worker程式通訊,worker程式安裝了2個訊號,如下所示:
SIGINT => 平滑退出
SIGUSR1 => 檢視worker程式自身狀態
為什麼worker程式只安裝2個訊號呢,少了個SIGTERM,因為master程式收到訊號SIGTERM之後,向worker程式傳送SIGKILL訊號,預設強制關閉程式即可。
worker程式是通過master程式fork出來的,這樣master程式可以通過pcntl_wait來等待子程式退出事件,當有子程式退出的時候返回子程式pid,做處理並啟動新的程式補充上來。
master程式也通過pcntl_wait來等待接收訊號,當有訊號到達的時候,會返回-1,這個地方還有些坑,在下文中會詳細講。
PHP中有2種訊號觸發的方式,第一種方式是declare(ticks = 1);,這種效率不高,Zend每執行一次低階語句,都會去檢查程式中是否有未處理的訊號,現在已經很少使用了,PHP 5.3.0及之前的版本可能會用到這個。
第二種是通過pcntl_signal_dispatch來呼叫未處理的訊號,PHP 5.4.0及之後的版本適用,可以巧妙的將該函式放在迴圈中,效能上基本沒什麼損失,現在推薦適用。
PHP安裝修訊號量
PHP通過pcntl_signal安裝訊號,函式宣告如下所示:
bool pcntl_signal ( int $signo , [callback $handler [, bool $restart_syscalls = true ] )
第三個引數restart_syscalls不太好理解,找了很多資料,也沒太查明白,經過試驗發現,這個引數對pcntl_wait函式接收訊號有影響,當設定為預設值true的時候,傳送訊號,程式用pcntl_wait收不到,必須設定為false才可以,看看下面這個例子:
<?php $i = 0; while ($i<5) { $pid = pcntl_fork(); $random = rand(10, 50); if ($pid == 0) { sleep($random); exit(); } echo "child {$pid} sleep {$random}\n"; $i++; } pcntl_signal(SIGINT, function($signo) { echo "Ctrl + C\n"; }); while (1) { $pid = pcntl_wait($status); var_dump($pid); pcntl_signal_dispatch(); }
執行之後,我們對父程式傳送kill -SIGINT {$pid}訊號,發現pcntl_wait沒有反應,等到有子程式退出的時候,傳送過的SIGINT會一個個執行,比如下面結果:
child 29643 sleep 48 child 29644 sleep 24 child 29645 sleep 37 child 29646 sleep 20 child 29647 sleep 31 int(29643) Ctrl + C Ctrl + C Ctrl + C Ctrl + C int(29646)
這是執行指令碼之後馬上給父程式傳送了四次SIGINT訊號,等到一個子程式推出的時候,所有訊號都會觸發。
但當把安裝訊號的第三個引數設定為false:
pcntl_signal(SIGINT, function($signo) { echo "Ctrl + C\n"; }, false);
這時候給父程式傳送SIGINT訊號,pcntl_wait會馬上返回-1,訊號對應的事件也會觸發。
所以第三個引數大概意思就是,是否重新註冊此訊號,如果為false只註冊一次,觸發之後就返回,pcntl_wait就能收到訊息,如果為true,會重複註冊,不會返回,pcntl_wait收不到訊息。
訊號量和系統呼叫
訊號量會打斷系統呼叫,讓系統呼叫立刻返回,比如sleep,當程式正在sleep的時候,收到訊號,sleep會馬上返回剩餘sleep秒數,比如:
<?php pcntl_signal(SIGINT, function($signo) { echo "Ctrl + C\n"; }, false); while (true) { pcntl_signal_dispatch(); echo "123\n"; $limit = sleep(2); echo "limit sleep [{$limit}] s\n"; }
執行之後,按Ctrl + C,結果如下所示:
123 ^Climit sleep [1] s Ctrl + C 123 limit sleep [0] s 123 ^Climit sleep [1] s Ctrl + C 123 ^Climit sleep [2] s
daemon(守護)程式
這種程式一般設計為daemon程式,不受終端控制,不與終端互動,長時間執行在後臺,而對於一個程式,我們可以通過下面幾個步驟把他升級為一個標準的daemon程式:
protected function daemonize() { $pid = pcntl_fork(); if (-1 == $pid) { throw new Exception("fork程式失敗"); } elseif ($pid != 0) { exit(0); } if (-1 == posix_setsid()) { throw new Exception("新建立session會話失敗"); } $pid = pcntl_fork(); if (-1 == $pid) { throw new Exception("fork程式失敗"); } else if($pid != 0) { exit(0); } umask(0); chdir("/"); }
攏共分五步:
- fork子程式,父程式退出。
- 設定子程式為會話組長,程式組長。
- 再次fork,父程式退出,子程式繼續執行。
- 恢復檔案掩碼為0。
- 切換當前目錄到根目錄/。
第2步是為第1步做準備,設定程式為會話組長,必要條件是程式非程式組長,因此做第一次fork,程式組長(父程式)退出,子程式通過posix_setsid()設定為會話組長,同時也為程式組長。
第3步是為了不讓程式重新控制終端,因為一個程式控制一個終端的必要條件是會話組長(pid=sid)。
第4步是為了恢復預設的檔案掩碼,避免之前做的操作對檔案掩碼做了設定,帶來不必要的麻煩。關於檔案掩碼, linux中,檔案掩碼在建立檔案、資料夾的時候會用到,檔案的預設許可權為666,資料夾為777,建立檔案(夾)的時候會用預設值減去掩碼的值作為建立檔案(夾)的最終值,比如掩碼022下建立檔案666 - 222 = 644,建立資料夾777 - 022 = 755:
掩碼 | 新建檔案許可權 | 新建資料夾許可權 |
---|---|---|
umask(0) | 666 (-rw-rw-rw-) | 777 (drwxrwxrwx) |
umask(022) | 644 (-rw-r–r--) | 755 (drwxr-xr-x) |
第5步是切換了當前目錄到根目錄/,網上說避免起始執行他的目錄不能被正確解除安裝,這個不是太瞭解。
對應5步,每一步的各種id變化資訊:
操作後 | pid | ppid | pgid | sid |
---|---|---|---|---|
開始 | 17723 | 31381 | 17723 | 31381 |
第一次fork | 17723 | 1 | 17723 | 31381 |
posix_setsid() | 17740 | 1 | 17740 | 17740 |
第二次fork | 17840 | 1 | 17740 | 17740 |
另外,會話、程式組、程式的關係如下圖所示,這張圖有助於更好的理解。
至此,你也可以輕鬆地造出一個daemon程式了。
命令設計
我準備給這個類庫設計6個命令,如下所示:
- start 啟動命令
- restart 強制重啟
- stop 平滑停止
- reload 平滑重啟
- quit 強制停止
- status 檢視程式狀態
啟動命令
啟動命令就是預設的流程,按照預設流程走就是啟動命令,啟動命令會檢測pid檔案中是否已經有pid,pid對應的程式是否健康,是否需要重新啟動。
強制停止命令
管理員通過入口檔案結合pid給master程式傳送SIGTERM訊號,master程式給所有子程式傳送SIGKILL訊號,等待所有worker程式退出後,master程式也退出。
強制重啟命令
強制停止命令 + 啟動命令
平滑停止命令
平滑停止命令,管理員給master程式傳送SIGINT訊號,master程式給所有子程式傳送SIGINT,worker程式將自身狀態標記為stoping,當worker程式下次迴圈的時候會根據stoping決定停止,不在接收新的資料,等所有worker程式退出之後,master程式也退出。
平滑重啟命令
平滑停止命令 + 啟動命令
檢視程式狀態
檢視程式狀態這個借鑑了workerman的思路,管理員給master程式傳送SIGUSR1訊號,告訴主程式,我要看所有程式的資訊,master程式,master程式將自身的程式資訊寫入配置好的檔案路徑A中,然後傳送SIGUSR1,告訴worker程式把自己的資訊也寫入檔案A中,由於這個過程是非同步的,不知道worker程式啥時候寫完,所以master程式在此處等待,等所有worker程式都寫入檔案之後,格式化所有的資訊輸出,最後輸出的內容如下所示:
➜/dir /usr/local/bin/php DaemonMcn.php status Daemon [DaemonMcn] 資訊: -------------------------------- master程式狀態 -------------------------------- pid 佔用記憶體 處理次數 開始時間 執行時間 16343 0.75M -- 2018-05-15 09:42:45 0 天 0 時 3 分 12 slaver -------------------------------- slaver程式狀態 -------------------------------- 任務task-mcq: 16345 0.75M 236 2018-05-15 09:42:45 0 天 0 時 3 分 16346 0.75M 236 2018-05-15 09:42:45 0 天 0 時 3 分 -------------------------------------------------------------------------------- 任務test-mcq: 16348 0.75M 49 2018-05-15 09:42:45 0 天 0 時 3 分 16350 0.75M 49 2018-05-15 09:42:45 0 天 0 時 3 分 16358 0.75M 49 2018-05-15 09:42:45 0 天 0 時 3 分 16449 0.75M 1 2018-05-15 09:46:40 0 天 0 時 0 分 --------------------------------------------------------------------------------
等待worker程式將程式資訊寫入檔案的時候,這個地方用了個比較trick的方法,每個worker程式輸出一行資訊,統計檔案的行數,達到worker程式的行數之後表示所有worker程式都將資訊寫入完畢,否則,每個1s檢測一次。
其他設計
另外還加了兩個比較實用的功能,一個是worker程式執行時間限制,一個是worker程式迴圈處理次數限制,防止長時間迴圈程式出現記憶體溢位等意外情況。時間預設是1小時,執行次數預設是10w次。
除此之外,也可以支援多工,每個任務幾個程式獨立開,統一由master程式管理。
更多學習內容可以訪問從碼農成為架構師的修煉之路