PHP開發者該知道的多程式消費佇列

it阿布發表於2020-07-31

引言

最近開發一個小功能,用到了佇列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("/");
}

攏共分五步:

  1. fork子程式,父程式退出。
  2. 設定子程式為會話組長,程式組長。
  3. 再次fork,父程式退出,子程式繼續執行。
  4. 恢復檔案掩碼為0。
  5. 切換當前目錄到根目錄/。

第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變化資訊:

操作後pidppidpgidsid
開始 17723 31381 17723 31381
第一次fork 17723 1 17723 31381
posix_setsid() 17740 1 17740 17740
第二次fork 17840 1 17740 17740

另外,會話、程式組、程式的關係如下圖所示,這張圖有助於更好的理解。

至此,你也可以輕鬆地造出一個daemon程式了。

命令設計

我準備給這個類庫設計6個命令,如下所示:

  1. start 啟動命令
  2. restart 強制重啟
  3. stop 平滑停止
  4. reload 平滑重啟
  5. quit 強制停止
  6. 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 時 312 slaver
-------------------------------- slaver程式狀態 --------------------------------
任務task-mcq:
16345     0.75M          236            2018-05-15 09:42:45      0 天 0 時 316346     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 時 316350     0.75M          49             2018-05-15 09:42:45      0 天 0 時 316358     0.75M          49             2018-05-15 09:42:45      0 天 0 時 316449     0.75M          1              2018-05-15 09:46:40      0 天 0 時 0--------------------------------------------------------------------------------

等待worker程式將程式資訊寫入檔案的時候,這個地方用了個比較trick的方法,每個worker程式輸出一行資訊,統計檔案的行數,達到worker程式的行數之後表示所有worker程式都將資訊寫入完畢,否則,每個1s檢測一次。

其他設計

另外還加了兩個比較實用的功能,一個是worker程式執行時間限制,一個是worker程式迴圈處理次數限制,防止長時間迴圈程式出現記憶體溢位等意外情況。時間預設是1小時,執行次數預設是10w次。

除此之外,也可以支援多工,每個任務幾個程式獨立開,統一由master程式管理。

更多學習內容可以訪問從碼農成為架構師的修煉之路

相關文章