首頁  >  文章  >  web前端  >  PHP實作daemon - 後端

PHP實作daemon - 後端

coldplay.xixi
coldplay.xixi轉載
2020-06-16 17:22:511571瀏覽

PHP實作daemon - 後端

TL;DR

PHP實作守護程式可以透過 pcntlposix 擴充實作。

程式設計中需要注意的地方有:

  • 透過二次化pcntl_fork() 以及posix_setsid 讓主行程脫離終端機
  • 透過pcntl_signal() 忽略或處理SIGHUP 訊號
  • 多重行程程式需要透過二次pcntl_fork()pcntl_signal() 忽略SIGCHLD 訊號防止子程序變成Zombie 程序
  • 透過umask() 設定檔案權限掩碼,防止繼承檔案權限而來的權限影響功能
  • 將運行進程的STDIN/STDOUT/STDERR 重定向到/dev/null 或其他流上

#如果要做的更好,還需要注意:

  • 如果透過root 啟動,運行時更換到低權限使用者身分
  • chdir() 防止操作錯誤路徑
  • 多進程程式考慮定時重啟,防止記憶體外洩

#什麼是daemon

文章的主角守護程式(daemon),Wikipedia上的定義是:

在一個多任務的電腦作業系統中,守護程式(英文:daemon,/ˈdiːmən/或/ˈdeɪmən/)是一種在後台執行的電腦程式。此類程序會以進程的形式初始化。守護程式程式的名稱通常以字母「d」結尾:例如,syslogd就是指管理系統日誌的守護程式。
通常,守護程式沒有任何存在的父程式(即PPID=1),且在UNIX系統程式層級中直接位於init之下。守護程式程式通常透過以下方法讓自己成為守護程式:對一個子程式執行fork,然後使其父程式立即終止,使得這個子程式能在init下運作。這種方法通常被稱為“脫殼”。

UNIX環境進階程式設計(第二版)(以下使用簡稱APUE 指代) 13章有雲:

守護程式也成精靈程式( daemon )是生存週期較長的一種進程。它們常常在系統自舉時啟動,僅在系統關閉時才終止。因為他們沒有控制終端,所以說他們是在後台運行的。

這裡注意到,daemon有以下特徵:

  • 沒有終端機
  • 後台運行
  • 父程式pid 為1

想要查看運行中的守護程式可以透過ps -axps -ef 查看,其中-x 表示會列出沒有控制終端的進程。

實作關注點

二次fork 與setsid

fork 系統呼叫

fork 系統呼叫用於複製一個與父進程幾乎完全相同的進程,新生成的子進程不同的地方在於與父進程有著不同的pid 以及有不同的記憶體空間,根據程式碼邏輯實現,父子進程可以完成一樣的工作,也可以不同。子行程會從父行程繼承例如檔案描述子一類的資源。

PHP 中的 pcntl 擴充功能中實作了 pcntl_fork() 函數,用於在 PHP 中 fork 新的進程。

setsid 系統呼叫

setsid 系統呼叫則用於建立一個新的會話並設定進程組 id。

這裡有幾個概念:會話進程組

在 Linux 中,使用者登入產生一個會話(Session),一個會話中包含一個或多個進程組,一個進程組又包含多個進程。每個進程組都有一個組長(Session Leader),它的 pid 就是進程組的組 id。進程組長一旦打開一個終端,這一個終端就稱為控制終端。一旦控制終端發生異常(斷開、硬體錯誤等),會發出訊號到進程組組長。

後台執行程式(如shell 中以&結尾執行指令)在終端關閉之後也會被殺死,就是沒有處理好控制終端斷開時發出的SIGHUP 訊號,而SIGHUP訊號對於進程的預設行為則是退出進程。

呼叫setsid 系統呼叫之後,會讓目前的進程新建一個進程組,如果在目前進程中不開啟終端機的話,那麼這一個進程組就不會存在控制終端,也就不會出現因為關閉終端機而殺死進程的問題。

PHP 中的 posix 擴充功能中實作了 posix_setsid() 函數,用於在 PHP 中設定新的進程組。

孤兒程序

父行程比子程序先退出,子程序就會變成孤兒程序。

init 進程會收養孤兒進程,即孤兒進程的 ppid 變成 1。

二次 fork 的作用

首先,setsid 系統呼叫不能由進程組組長調用,會回傳-1。

二次fork 操作的範例程式碼如下:

$pid1 = pcntl_fork();

if ($pid1 > 0) {
    exit(0);
} else if ($pid1 < 0) {
    exit("Failed to fork 1\n");
}

if (-1 == posix_setsid()) {
    exit("Failed to setsid\n");
}

$pid2 = pcntl_fork();

if ($pid2 > 0) {
    exit(0);
} else if ($pid2 < 0) {
    exit("Failed to fork 2\n");
}

   

假定我們在終端機中執行應用程序,進程為a,第一次fork 會產生子進程b,如果fork 成功,父進程a 退出。 b 作為孤兒進程,被 init 進程所託管。

此時,進程 b 處於進程組 a 中,進程 b 呼叫 posix_setsid 要求產生新的進程組,呼叫成功後目前進程組變成 b。

此時進程b 事實上已經脫離任何的控制終端,例程:

<?php

cli_set_process_title('process_a');

$pidA = pcntl_fork();

if ($pidA > 0) {
    exit(0);
} else if ($pidA < 0) {
    exit(1);
}

cli_set_process_title('process_b');

if (-1 === posix_setsid()) {
    exit(2);
}

while(true) {
    sleep(1);
}

   

執行程式之後:

➜  ~ php56 2fork1.php
➜  ~ ps ax | grep -v grep | grep -E 'process_|PID'
  PID TTY      STAT   TIME COMMAND
28203 ?        Ss     0:00 process_b

   

從ps 的結果來看,process_b 的TTY 已經變成了,即沒有對應的控制終端。

程式碼走到這裡,似乎已經完成了功能,關閉終端機之後 process_b 也沒有被殺死,但是為什麼還要進行第二次 fork 操作呢?

StackOverflow 上的一個回答寫的很好:

The second fork(2) is there to ensure that the new process is not a session leader, so it won't be able to (accidentally) allocate a controlling terminal, since daemons are not supposed to ever have a controlling terminal.

#這是為了防止實際的工作的進程終端關聯或意外關聯控制,再次關聯控制,再次關聯控制fork 之後產生的新進程由於不是進程組組長,是不能申請關聯控制終端的。

綜上,二次 fork 與 setsid 的作用是產生新的進程組,防止工作進程關聯控制終端。

SIGHUP 訊號處理

一個行程收到 SIGHUP 訊號的預設動作是結束行程。

SIGHUP 會在以下情況發出:

  • #控制終端機斷開,SIGHUP 傳送到進程組組長
  • 進程組群組長退出,SIGHUP 會傳送到進程群組中的前台進程
  • SIGHUP 常被用來通知進程重載設定檔(APUE 中提及,daemon 由於沒有控制終端,被認為不可能會收到這一個訊號,所以選擇複用)

由於實際的工作進程不在前台進程組中,而且進程組的組長已經退出並且沒有控制終端,不處理正常情況下當然也沒有問題,然而為了防止偶然的收到SIGHUP 導致進程退出,也為了遵循守護程序程序設計的慣例,還是應當處理這一信號。

Zombie 進程處理

何為Zombie 進程

#簡單來說,子進程先於父進程退出,父進程沒有呼叫wait 系統調用處理,進程變成Zombie 進程。

子行程先於父行程退出時,會傳送 SIGCHLD 訊號,如果父行程沒有處理,子行程也會變成 Zombie 行程。

Zombie 進程會佔用可 fork 的進程數,Zombie 進程過多會導致無法 fork 新的進程。

此外,Linux 系統中 ppid 為 init 進程的進程,變成 Zombie 後會由 init 進程回收管理。

Zombie 進程的處理

從Zombie 進程的特點,對於多進程的daemon,可以透過兩個途徑解決這一問題:

  • 父進程處理SIGCHLD 訊號
  • 讓子程序被init 接手

父行程處理訊號不需要多說,註冊訊號處理回呼函數,呼叫回收方法即可。

對於讓子進程被init 接管,則可以透過2次fork 的方法,讓第一次fork 出的子進程a 再fork 出實際的工作進程b,讓a 先行退出,使得b 成為孤兒進程,這樣就能被init 進程託管了。

umask

umask 會從父行程繼承,影響建立檔案的權限。

PHP 手冊上提到:

umask() 將 PHP 的 umask 設定為 mask & 0777 並傳回原來的 umask。當 PHP 被當作伺服器模組使用時,在每個請求結束後 umask 會被恢復。

如果父親進程的umask 沒有設定好,那麼在執行一些檔案操作時,會出現意想不到的效果:

➜  ~ cat test_umask.php
<?php
        chdir('/tmp');
        umask(0066);
        mkdir('test_umask', 0777);
➜  ~ php test_umask.php
➜  ~ ll /tmp | grep umask
drwx--x--x 2 root root 4.0K 8月  22 17:35 test_umask

   

所以,為了保證每次都能依照預期的權限操作文件,需要置0 umask 值。

重定向0/1/2

這裡的0/1/2分別指的是STDIN/STDOUT/STDERR,即標準輸入/輸出/錯誤三個流。

範例

首先來看一個範例:

<?php

// not_redirect_std_stream_daemon.php

$pid1 = pcntl_fork();

if ($pid1 > 0) {
    exit(0);
} else if ($pid1 < 0) {
    exit("Failed to fork 1\n");
}

if (-1 == posix_setsid()) {
    exit("Failed to setsid\n");
}

$pid2 = pcntl_fork();

if ($pid2 > 0) {
    exit(0);
} else if ($pid2 < 0) {
    exit("Failed to fork 2\n");
}

umask(0);
declare(ticks = 1);
pcntl_signal(SIGHUP, SIG_IGN);

echo getmypid() . "\n";

while(true) {
    echo time() . "\n";
    sleep(10);
}

   

上述程式碼幾乎完成了文章最開始部分提及的各個方面,唯一不同的是沒有對標準流做處理。透過 php not_redirect_std_stream_daemon.php 指令也能讓程式在背景進行。

sleep 的间隙,关闭终端,会发现进程退出。

通过 strace 观察系统调用的情况:

➜  ~ strace -p 6723
Process 6723 attached - interrupt to quit
restart_syscall(<... resuming interrupted call ...>) = 0
write(1, "1503417004\n", 11)            = 11
rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
rt_sigaction(SIGCHLD, NULL, {SIG_DFL, [], 0}, 8) = 0
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
nanosleep({10, 0}, 0x7fff71a30ec0)      = 0
write(1, "1503417014\n", 11)            = -1 EIO (Input/output error)
close(2)                                = 0
close(1)                                = 0
munmap(0x7f35abf59000, 4096)            = 0
close(0)                                = 0

   

发现发生了 EIO 错误,导致进程退出。

原因很简单,即我们编写的 daemon 程序使用了当时启动时终端提供的标准流,当终端关闭时,标准流变得不可读不可写,一旦尝试读写,会导致进程退出。

在信海龙的博文《一个echo引起的进程崩溃》中也提到过类似的问题。

解决方案

APUE 样例

APUE 13.3中提到过一条编程规则(第6条):

某些守护进程打开 /dev/null 时期具有文件描述符0、1和2,这样,任何一个视图读标准输入、写标准输出或者标准错误的库例程都不会产生任何效果。因为守护进程并不与终端设备相关联,所以不能在终端设备上显示器输出,也无从从交互式用户那里接受输入。及时守护进程是从交互式会话启动的,但因为守护进程是在后台运行的,所以登录会话的终止并不影响守护进程。如果其他用户在同一终端设备上登录,我们也不会在该终端上见到守护进程的输出,用户也不可期望他们在终端上的输入会由守护进程读取。

简单来说:

  • daemon 不应使用标准流
  • 0/1/2 要设定成 /dev/null

例程中使用:

for (i = 0; i < rl.rlim_max; i++)
	close(i);

fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);

   

实现了这一个功能。dup() (参考手册)系统调用会复制输入参数中的文件描述符,并复制到最小的未分配文件描述符上。所以上述例程可以理解为:

关闭所有可以打开的文件描述符,包括标准输入输出错误;
打开/dev/null并赋值给变量fd0,因为标准输入已经关闭了,所以/dev/null会绑定到0,即标准输入;
因为最小未分配文件描述符为1,复制文件描述符0到文件描述符1,即标准输出也绑定到/dev/null;
因为最小未分配文件描述符为2,复制文件描述符0到文件描述符2,即标准错误也绑定到/dev/null;

   

开源项目实现:Workerman

Workerman 中的 Worker.php 中的 resetStd() 方法实现了类似的操作。

/**
* Redirect standard input and output.
*
* @throws Exception
*/
public static function resetStd()
{
   if (!self::$daemonize) {
       return;
   }
   global $STDOUT, $STDERR;
   $handle = fopen(self::$stdoutFile, "a");
   if ($handle) {
       unset($handle);
       @fclose(STDOUT);
       @fclose(STDERR);
       $STDOUT = fopen(self::$stdoutFile, "a");
       $STDERR = fopen(self::$stdoutFile, "a");
   } else {
       throw new Exception('can not open stdoutFile ' . self::$stdoutFile);
   }
}

   

Workerman 中如此实现,结合博文,可能与 PHP 的 GC 机制有关,对于 fd 0 1 2来说,PHP 会维持对这三个资源的引用计数,在直接 fclose 之后,会使得这几个 fd 对应的资源类型的变量引用计数为0,导致触发回收。所需要做的就是将这些变量变为全局变量,保证引用的存在。

推荐教程:《php教程

以上是PHP實作daemon - 後端的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.im。如有侵權,請聯絡admin@php.cn刪除