PHP 實現守護程式

JaguarJack發表於2019-08-08

寫 PHP CLI 程式的老司機們可能經常會寫一些常駐程式,比如訊息佇列消費者程式,這些程式會一直執行,除非要發版,不然一般不會重啟的,所以程式程式是不可能由我們通過 ssh 登入到伺服器上通過終端來直接啟動的(因為一旦斷開 ssh 程式就退出了),常見的做法就是用 systemd 或者 supervisor 來使其成為 守護程式,這樣程式就可以一直執行,遇到錯誤意外退出也能被自動重啟。

好學的你可能會思考守護程式到底是怎麼實現的?為什麼有的程式既可以自己就成為守護程式,又可以通過 systemd 來後臺執行?如果不依賴外部,我們的 PHP 程式該怎樣變成守護程式呢?

成為守護程式的步驟

其實只需要建立子程式並退出父程式,將要處理的工作在子程式中進行就可以實現一個守護程式了。但是僅僅是這麼做的話,如果後續任務很複雜,或者引入了一些第三方包,那麼可能就會出現奇奇怪怪的問題了。

而在《UNIX環境高階程式設計》(英語:Advanced Programming in the UNIX Environment,簡稱APUE)一書中有介紹關於守護程式的編碼規範,我們按照規範來實現我們的守護程式就可以避免出現那些奇怪的問題了。而且規範也不復雜,只需要幾步就可以了:

  • 建立子程式,退出父程式
  • 子程式建立一個新的會話併成為 session leader
  • 重設檔案掩碼
  • 改變工作目錄
  • 關閉標準輸入輸出

實現

<?php

function daemon()
{
    // [1] 建立子程式
    $pid = pcntl_fork();
    if ($pid == -1) {
        die('fork failed');
    }

    // [2] 如果是父程式,則退出
    if ($pid > 0) {
        exit(0);
    }

    ///////////////// 以下是子程式 /////////////////

    // [3] 建立一個新的會話併成為 session leader
    if ( ($sid = posix_setsid()) <= 0 ) {
        die("Set sid failed.\n");
    }

    // [4] 重設檔案掩碼
    umask(0);

    // [5] 改變工作目錄
    if (chdir('/') === false) {
        die("chdir failed.\n");
    }

    // [6] 關閉標準輸入輸出
    fclose(STDIN);
    fclose(STDOUT);
    fclose(STDERR);
}

daemon();

// ... 真正的處理邏輯

說明

上面短短的十幾二十行程式碼就實現了一個守護程式,接下來解釋一下有些步驟為什麼要這麼做。

建立子程式並退出父程式

pcntl_fork() 的返回值有三種情況,上面的程式碼([1][2])已經處理了對應的情況。

建立新的會話

呼叫 posix_setsid() 建立新會話會使得當前程式成為新會話中的“會話首程式”,同時也會使當前程式成為“程式組組長”,並且使得當前程式脫離控制終端。

重設檔案掩碼

呼叫 umask() 重設檔案掩碼,這裡通常是 0。為什麼是 0 而不是其他呢,因為子程式從父程式繼承來的檔案掩碼可能會遮蔽某些特定的檔案操作許可權。比如說引入的第三方庫可能需要用特定的許可權來建立檔案,並且它沒有將檔案許可權作為一個選項引數由你指定,那麼就可能會出現失敗的情況;而我們傳入 0,會使得從呼叫了 umask() 之後,守護程式建立的檔案許可權為 0666,目錄許可權為 0777,均為最高許可權。

關於 umask() 後面會展開新的篇幅來說明,感興趣的可以先自行搜尋資料學習。

改變工作目錄

通過 chdir() 我們將工作目錄設定為根目錄 /,主要是因為守護程式是長時間執行的,通常只有系統關閉/重啟才會退出。假如從父程式繼承來的工作目錄是個掛載的檔案系統,如果不改變工作目錄,那麼將會導致這個掛載的檔案系統一直沒法解除安裝。

當然也不一定要將工作目錄切換到根目錄,你也可以根據實際情況切換到特定的目錄。

關閉標準輸入輸出

因為守護程式是脫離終端控制的,所以是沒有標準輸入輸出互動的,我們將其關閉即可。

其他

二次 fork

你可能在一些資料中看到有人推薦你在 [3] 建立一個新的會話併成為 session leader 之後再次進行 fork。這一步驟是在基於 System V 的系統中,可以保證你的守護程式不是“會話首程式”,可以阻止其重新申請獲取一個控制終端。

關閉不必要的檔案描述符

按照編碼規範,實際還有一步是關閉不必要的檔案描述符。但我們為了簡單起見,上面的程式碼在程式啟動之後先建立守護程式再執行其他操作,因此這裡只開啟了三個檔案描述符: 012(即標準輸入標準輸出標準錯誤)。

注意事項

因為上面的程式碼將標準輸入輸出關閉了,也就是說如果你在 daemon() 之後有諸如 echo "Hello world"; 之類的輸出,那麼你的程式將會出錯然後退出,並且你將看不到任何錯誤資訊(因為標準錯誤也被關閉了)。

解決方案有兩種,一種是用 file_put_contents 代替 echo,但是這樣並不優雅,而且萬一引入的第三方包中寫了 echo 或者是 file_put_contents(STDOUT, ...),那你的程式也會“莫名其妙”就掛了,會讓你排查半天到底是哪裡出了問題。

因此我們還可以在第 [6] 之後加入:

    // [7] 重定向輸入輸出
    global $stdin, $stdout, $stderr;
    $stdin = fopen('/dev/null', 'r');
    $stdout = fopen('/dev/null', 'wb'); // 你也可以將標準輸出重定向到指定的檔案,相當於是日誌
    $stderr = fopen('/dev/null', 'wb'); // 同上

參考資料


本文首發於本人部落格:https://yian.me/blog/what-is/php-daemon.html

相關文章