實作 PHP Daemon 接收 Unix Signal 訊號

Written by Simon Asika on

Signal.png

一個 Daemon 守護進程除了存在記憶體中默默執行工作之外,也需要能夠接收訊號來處理事情,並且能夠寫入一些 Log 訊息讓使用者查詢。

前幾天寫了 用 PHP Daemon 創建 HTTP Server,但範例中的 Daemon 其實不夠完整,我們這次額外來實作讓 Daemon 可以接收 Unix 的訊號(Signal),來處理事情吧。

什麼是訊號(Signal)

我們取「老陳獨白」網誌中的「Linux 信號signal處理機制」來解釋:

軟中斷信號(signal,又簡稱為信號)用來通知進程發生了非同步事件。進程之間可以互相通過系統調用kill發送軟中斷信號。內核也可以因為內部事件而給進程發送信號,通知進程發生了某個事件。注意,信號只是用來通知某進程發生了什麼事件,並不給該進程傳遞任何資料。

因此我們可以知道,訊號的功能是為了傳遞發生的事件給背景執行中的工作。它與事件(Event)驅動的程式不太一樣,我們並不傳遞任何參數或值給背景程序,單純的只是通知並觸發一個事件,而且通常是系統及別的事件,例如程序終止、重開或錯誤發生等等,而程序可以選擇回應處理或忽略。

訊號可以從 Linux 系統的核心送往某一個程序,也可以從一個程序送往某一個程序。例如要終止某一個程式,可以從一個命令 kill 送出 SIGTERM 訊號給該程序,來終止該程序的執行。

為什麼要接收訊號

我們寫的 Daemon 通常是掛著來自動處理事情,有時候事情處理到一半尚未完成,系統卻發出了終止程序的指令(例如SIGTERM),或許我們會想要把當下的任務處理完再結束,或是執行其他工作如回收記憶體、備份當下執行狀態或紀錄日誌等等。此時就可以註冊自己的訊號處理機制,先執行完之後,再由程序自己主動終止。

最常遇到的狀況例如 Queue 佇列排程系統,當下在處理的事務應該要先完成或是重新改為未執行的狀態,否則重開 Daemon 後,說不定就少一條紀錄了。

如何接收訊號

Linux 系統中所有的背景程序,都有一些預設的訊號處理機制,例如按下 Ctrl + C 會發出 SIGINT 訊號,而輸入 kill {pid} 會發出 SIGTERM 訊號,並自動結束程序。

詳細訊號說明可以看這幾篇文章,對訊號的介紹非常明瞭,也提供了對照表:

而在 PHP 中,我們是採用 pcntl_signal() 函式來註冊自己的訊號處理機制,使用方式很簡單,就像平常常用的 callback 方法:

// 直接指派一個函式處理 SIGTERM 訊號
pcntl_signal(SIGTERM, 'signal');

// 指派物件中的方法
pcntl_signal(SIGTERM, array($this, 'signal'));

// 指派一個靜態類別的方法
pcntl_signal(SIGTERM, array('SomeClass', 'signal'));

接著我們當我們執行 kill {pid} 指令時,Linux 就會發出 SIGTERM 訊號給我們的程式。

要注意的是,當我們註冊自己的函式以後,就是蓋掉預設行為了,如果我們沒有自行在程式碼中 exit 程序的話,這個 Daemon 會刪也刪不掉,一直存在背景中,這時就要靠 kill -sigkill {pid}kill -9 {pid} 才能強制刪除。

如何讓程序刪除自己

我們都知道刪除程序要靠 ps 指令查出 pid 以後,再用 kill {pid} 來刪除程序,就算程式要刪除自己也是要知道 pid。但在我們寫的程式裏面,如何得知自己的 pid 呢?

此時我們可以額外寫一個 .pid 檔案在 /tmp 中,例如 /tmp/daemon/daemon.pid ,等到要刪除時,再從暫存檔中提取 pid 來用。

範例程式

我現在寫一個範例用的 Daemon 程式叫做 helloworld.php ,原始碼如下:

<?php
/**
 * A PHP daemon demo file.
 *
 * @author     Asika
 * @email      asika@asikart.com
 * @date       2013-10-12
 * 
 * @copyright  Copyright (C) 2013 - Asika.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

/**
 * Daemon Application.
 */
class DaemonSignalListener
{
    /**
     * Store process id for close self.
     *
     * @var int 
     */
    protected $processId;

    /**
     * File path to save pid.
     *
     * @var string 
     */
    protected $pidFile = '/tmp/daemon/daemon.pid';

    /**
     * Daemon log file.
     *
     * @var string   
     */
    protected $logFile = '/var/www/daemon.log';

    /**
     * Create a daemon process.
     *
     * @return  DaemonHttpApplication  Return self to support chaining.
     */
    public function execute()
    {
        // Create first child.
        if(pcntl_fork())
        {
            // I'm the parent
            // Protect against Zombie children
            pcntl_wait($status);
            exit;
        }

        // Make first child as session leader.
        posix_setsid();

        // Create second child.
        $pid = pcntl_fork();
        if($pid)
        {
            // If pid not 0, means this process is parent, close it.
            $this->processId = $pid;
            $this->storeProcessId();

            exit;
        }

        $this->addLog('Daemonized');

        fwrite(STDOUT, "Daemon Start\n-----------------------------------------\n");

        $this->registerSignalHandler();

        // Declare ticks to start signal monitoring. When you declare ticks, PCNTL will monitor
        // incoming signals after each tick and call the relevant signal handler automatically.
        declare (ticks = 1);

        while(true)
        {
            $this->doExecute();
        }

        return $this;
    }

    /**
     * Method to run the application routines.
     * Most likely you will want to fetch a queue to do something.
     *
     * @return  void
     */
    protected function doExecute()
    {
        // Do some stuff you want.
    }

    /**
     * Method to attach signal handler to the known signals.
     *
     * @return  void
     */
    protected function registerSignalHandler()
    {
        $this->addLog('registerHendler');

        pcntl_signal(SIGINT, array($this, 'shutdown'));

        pcntl_signal(SIGTERM, array($this, 'shutdown'));

        pcntl_signal(SIGUSR1, array($this, 'customSignal'));
    }

    /**
     * Store the pid to file.
     *
     * @return  DaemonSignalListener  Return self to support chaining.
     */
    protected function storeProcessId()
    {
        $file = $this->pidFile;

        // Make sure that the folder where we are writing the process id file exists.
        $folder = dirname($file);

        if(!is_dir($folder))
        {
            mkdir($folder);
        }

        file_put_contents($file, $this->processId);

        return $this;
    }

    /**
     * Shut down our daemon.
     * 
     * @param   integer  $signal  The received POSIX signal.
     */
    public function shutdown($signal)
    {
        $this->addLog('Shutdown by signal: ' . $signal);

        $pid = file_get_contents($this->pidFile);

        // Remove the process id file.
        @ unlink($this->pidFile);

        passthru('kill -9 ' . $pid);
        exit;
    }

    /**
     * Hendle the SIGUSR1 signal.
     * 
     * @param   integer  $signal  The received POSIX signal.
     */
    public function customSignal($signal)
    {
        $this->addLog('Execute custom signal: ' . $signal);
    }

    /**
     * Add a log to log file.
     * 
     * @param   string  $text  Log string.
     */
    protected function addLog($text)
    {
        $file = $this->logFile;

        $time = new Datetime();

        $text = sprintf("%s - %s\n", $text, $time->format('Y-m-d H:i:s'));

        $fp = fopen($file, 'a+');
        fwrite($fp,$text);
        fclose($fp);
    }
}



// Start Daemon
// --------------------------------------------------
$daemon = new DaemonSignalListener();

$daemon->execute();

說明

Daemon 啟用

這是一個 Daemon 物件,執行 execute() 後就會把自己常駐在背景中執行。Daemon 本身的創建方法不再贅述,參考上一篇: 用 PHP Daemon 創建 HTTP Server,execute 方法內大多數一模一樣,但是額外加了一些功能來讓 Daemon 有能力註冊訊號。

下面這一段程式碼利用 $this->storeProcessId() 來把 pid 儲存在檔案內。要在父程序內執行,因為父程序才會取得子程序的 pid。子程序回傳的會是 0。

// Create second child.
$pid = pcntl_fork();
if($pid)
{
    // If pid not 0, means this process is parent, close it.
    $this->processId = $pid;
    $this->storeProcessId();

    exit;
}

下面這一段程式碼用日誌紀錄了 Daemon 的啟用,並呼叫 $this->registerSignalHandler() 註冊訊號,然後非常重要的一點是一定要有 declare (ticks = 1) 這一行,才能真正監聽訊號,否則你怎麼樣都無法把訊號送進來的。

最後我們跑個無窮迴圈吧,doExecute() 是空方法,看你想做什麼事都好。

$this->addLog('Daemonized');

fwrite(STDOUT, "Daemon Start\n-----------------------------------------\n");

$this->registerSignalHandler();

// Declare ticks to start signal monitoring. When you declare ticks, PCNTL will monitor
// incoming signals after each tick and call the relevant signal handler automatically.
declare (ticks = 1);

while(true)
{
    $this->doExecute();
}

訊號註冊

我們其實很簡單的寫成三行來分別註冊三個不同的方法,範例程式也就三個就夠用了,SIGINTSIGTERM 都是程序終止,交給 $this->shutdown() 方法處理。SIGUSR1 則是使用者自訂訊號,給 $this->customSignal() 方法處理。

/**
 * Method to attach signal handler to the known signals.
 *
 * @return  void
 */
protected function registerSignalHandler()
{
    $this->addLog('registerHendler');

    pcntl_signal(SIGINT, array($this, 'shutdown'));

    pcntl_signal(SIGTERM, array($this, 'shutdown'));

    pcntl_signal(SIGUSR1, array($this, 'customSignal'));
}

Shutdown 流程

我們一開始在 storeProcessId() 中就先儲存好 pid 到暫存檔了,接下來就把他讀出來,並根據 pid 刪除程序。

/**
 * Shut down our daemon.
 * 
 * @param   integer  $signal  The received POSIX signal.
 */
public function shutdown($signal)
{
    $this->addLog('Shutdown by signal: ' . $signal);

    $pid = file_get_contents($this->pidFile);

    // Remove the process id file.
    @ unlink($this->pidFile);

    passthru('kill -9 ' . $pid);
    exit;
}

同樣用 $this->addLog() 做個 log 紀錄,才知道發生了什麼事。最後記得要 exit 阿。

日誌檔

我稍微偷懶,直接將日誌寫在網頁根目錄 /var/www/daemon.log 中,方便查詢。

/**
 * Add a log to log file.
 * 
 * @param   string  $text  Log string.
 */
protected function addLog($text)
{
    $file = $this->logFile;

    $time = new Datetime();

    $text = sprintf("%s - %s\n", $text, $time->format('Y-m-d H:i:s'));

    $fp = fopen($file, 'a+');
    fwrite($fp,$text);
    fclose($fp);
}

測試我們的 Daemon

執行

一切就緒後,我們在命令列鍵入:

$ php helloworld.php

輸出結果:

$ php helloworld.php 
Daemon Start
-----------------------------------------
$

p2013-10-14-1.jpg

檢查程序存在背景中

輸入:

$ ps -ef | grep hello

輸出結果:

$ ps -ef | grep hello
apache    8845     1 99 16:04 ?        00:01:21 php helloworld.php
apache    8847  8410  0 16:06 pts/0    00:00:00 grep --color=auto hello
$

p2013-10-14-2.jpg

打開日誌檔

輸入:

$ vim /var/www/daemon.log

內容:

Daemonized - 2013-10-13 16:04:54
registerHendler - 2013-10-13 16:04:54

p2013-10-14-4.jpg

可以看到 Daemon 的啟動與 Signal handler 的註冊都被記錄下來了。

終止程序

我們現在要藉由 kill 來終止程序,如果成功覆蓋預設的 SIGTERM ,應該會在日誌檔中有紀錄,否則便直接刪除了。

輸入:

$ kill 8845

再次打開 daemon.log,成功多出第三條紀錄:

Daemonized - 2013-10-13 16:04:54
registerHendler - 2013-10-13 16:04:54
Shutdown by signal: 15 - 2013-10-13 16:12:51

p2013-10-14-5.jpg

使用其他訊號

剛剛用的 SIGTERM 只有在 kill 時才發送,其實除此之外,我們還可以用 kill 來發送其他訊號,例如 kill -sigusr1 xxxx 就是送出 SIGUSR1 訊號,就這麼簡單。

範例程式中的 registerSignalHandler() 有寫一個 pcntl_signal(SIGUSR1, array($this, 'customSignal')); 來註冊自定義訊號的處理,而方法內也非常簡單,只是在日誌中寫一條新紀錄而已:

/**
 * Hendle the SIGUSR1 signal.
 * 
 * @param   integer  $signal  The received POSIX signal.
 */
public function customSignal($signal)
{
    $this->addLog('Execute custom signal: ' . $signal);
}

所以我們依照剛剛的過程重新開啟 Daemon ,並輸入 kill -sigusr1 {pid} 試試看:

p2013-10-14-6.jpg

接著再度打開 daemon.log

p2013-10-14-7.jpg

確實出現一條 Execute custom signal: 10 - 2013-10-13 16:17:27 的紀錄,表示訊號監聽成功。

結語

其實除非我們要寫的是公開發行的 Daemon 處理函式庫或框架,不然把 Signal 全搞熟似乎對 PHP 開發者來說沒有特別實用。而對於原先就寫 C 語言或 Linux 程式的人來說,這似乎也只是小兒科。總之因為很少看到中文文章中把 PHP 的訊號處理講的比較完整的,所以就趁機寫一篇留底吧。

其他參考資源

Control Tools

WS-logo