守護程式(daemon)是一類在背景運行的特殊進程,用於執行特定的系統任務。本篇文章帶大家了解PHP中實作daemon的方法,介紹一下程式設計中需要注意的地方。
PHP實作守護程式可以透過 pcntl
與 posix
擴充功能實作。
程式設計中需要注意的地方有:
pcntl_fork()
以及posix_setsid
讓主行程脫離終端機pcntl_signal()
忽略或處理SIGHUP
訊號pcntl_fork()
或pcntl_signal()
忽略SIGCHLD
訊號防止子程序變成Zombie 程序umask()
設定檔案權限掩碼,防止繼承檔案權限而來的權限影響功能STDIN/STDOUT/STDERR
重定向到/dev/null
或其他流上#如果要做的更好,還需要注意:
chdir()
防止操作錯誤路徑文章的主角守護程式(daemon),Wikipedia 上的定義是:
在一個多任務的電腦作業系統中,守護程式(英文:daemon,/ˈdiːmən/或/ˈdeɪmən/)是一種在後台執行的電腦程式。此類程序會以進程的形式初始化。守護程式程式的名稱通常以字母「d」結尾:例如,syslogd就是指管理系統日誌的守護程式。
通常,守護程式沒有任何存在的父程式(即PPID=1),且在UNIX系統程式層級中直接位於init之下。守護程式程式通常透過以下方法讓自己成為守護程式:對一個子程式執行fork,然後使其父程式立即終止,使得這個子程式能在init下運作。這種方法通常被稱為“脫殼”。
UNIX環境高階程式設計(第二版)(以下使用簡稱APUE 指代) 13章有雲:
守護程式也成精靈進程( daemon )是生存週期較長的一種過程。它們常常在系統自舉時啟動,僅在系統關閉時才終止。因為他們沒有控制終端,所以說他們是在後台運行的。
這裡注意到,daemon有以下特徵:
想要查看運行中的守護程式可以透過ps -ax
或ps -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(&#39;process_a&#39;); $pidA = pcntl_fork(); if ($pidA > 0) { exit(0); } else if ($pidA < 0) { exit(1); } cli_set_process_title(&#39;process_b&#39;); if (-1 === posix_setsid()) { exit(2); } while(true) { sleep(1); }
執行程式之後:
➜ ~ php56 2fork1.php ➜ ~ ps ax | grep -v grep | grep -E &#39;process_|PID&#39; 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
導致進程退出,也為了遵循守護程序程序設計的慣例,還是應當處理這一信號。
Zombie 進程處理
#何為Zombie 進程
#簡單來說,子進程先於父進程退出,父進程沒有呼叫wait
系統呼叫處理,進程變成Zombie 進程。
子行程先於父行程退出時,會傳送 SIGCHLD
訊號,如果父行程沒有處理,子行程也會變成 Zombie 行程。
Zombie 進程會佔用可 fork 的進程數,Zombie 進程過多會導致無法 fork 新的進程。
此外,Linux 系統中 ppid 為 init 進程的進程,變成 Zombie 後會由 init 進程回收管理。
Zombie 進程的處理
從Zombie 進程的特點,對於多進程的daemon,可以透過兩個途徑解決這一問題:
SIGCHLD
訊號父程式處理訊號無需多說,註冊訊號處理回呼函數,調用回收方法即可。
對於讓子進程被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(&#39;/tmp&#39;); umask(0066); mkdir(&#39;test_umask&#39;, 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
指令也能让程序在后台进行。在
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,这样,任何一个视图读标准输入、写标准输出或者标准错误的库例程都不会产生任何效果。因为守护进程并不与终端设备相关联,所以不能在终端设备上显示器输出,也无从从交互式用户那里接受输入。及时守护进程是从交互式会话启动的,但因为守护进程是在后台运行的,所以登录会话的终止并不影响守护进程。如果其他用户在同一终端设备上登录,我们也不会在该终端上见到守护进程的输出,用户也不可期望他们在终端上的输入会由守护进程读取。简单来说:
例程中使用:
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(&#39;can not open stdoutFile &#39; . self::$stdoutFile); } }
Workerman 中如此实现,结合博文,可能与 PHP 的 GC 机制有关,对于 fd 0 1 2来说,PHP 会维持对这三个资源的引用计数,在直接 fclose 之后,会使得这几个 fd 对应的资源类型的变量引用计数为0,导致触发回收。所需要做的就是将这些变量变为全局变量,保证引用的存在。
推荐学习:《PHP视频教程》
以上是什麼是daemon? PHP中如何實作daemon?的詳細內容。更多資訊請關注PHP中文網其他相關文章!