在PHP中生成守护进程(Daemon Process)

前两天看到一篇文章《如何使用PHP编写daemon process》,其中对核心代码却没有细说,我又查了一些资料,还看了一本《理解Unix进程》,才搞明白生成守护进程的时候发生了什么。

这段代码是这个样子的:

function run()
{
    //第一次fork,父进程与子进程在此分开
    if(($pid1 = pcntl_fork()) === 0)
    {
        //子进程在此成为会话组leader
        posix_setsid();

        //第二次fork,子进程与孙子进程在此分开
        if(($pid2 = pcntl_fork()) === 0)
        {
            //孙子进程成为守护进程,开始处理任务
            handle_http_request('www.codinglabs.org', 9999);
        }
        else
        {
            //子进程退出,将孙子进程交由init托管
            exit;
        }
    }
    else
    {
        //父进程在此等待子进程的退出信号
        pcntl_wait($status);
    }
}

如果不理解这段代码的话,可能需要了解一些Unix系统进程的知识。

0. 系统调用

Unix系统由用户空间和内核组成,系统调用允许用户空间的程序通过内核与计算机硬件交互。不同的语言可以通过不同的函数使用系统调用,例如系统调用fork在C中的函数是fork(),在PHP中的函数是pcntl_fork(). PCNTL是Process Control的缩写,PHP中与进程有关的函数都以此开头。

1. 子进程

系统中的每个进程都有对应的父进程,进程的ppid标识符即为其父进程的pid。当子进程执行完毕之后,父进程应该通过wait请求这些信息,否则内核会一直保留子进程的状态信息。如果父进程没有调用wait来等待它,子进程就会变成僵尸进程。
如果父进程先于子进程结束,子进程通常并不会受影响,而会一直执行下去。父进程结束时系统会扫描其子进程,并将其ppid置为1,成为init进程的子进程。

2. fork

系统调用fork可以使进程衍生一个子进程。两个进程除了pid与ppid之外一模一样。PHP中可以通过pcntl_fork()实现fork,执行完该函数之后,代码会在父子两个进程中继续执行下去,pcntl_fork()在子进程中的返回值为0,在父进程中的返回值为子进程的pid。因此pcntl_fork()经常被放在if条件里,以实现父子进程执行不同的代码,例如:

if(($pid1 = pcntl_fork()) === 0)
{
    //子进程执行此处的代码
}
else
{
    //父进程执行此处的代码
}

3. 进程组

每个进程都属于某个组,进程组是一个相关进程的集合,通常是父进程与其子进程。每个进程组都有一个整数id,可以通过posix_getpgrp()函数获得,通常进程组的id和进程组leader的pid相同。当终端收到终止信号(Ctrl-C)时,会转发给进程组中的所有进程。因此,如果你在终端执行一个PHP脚本,并在脚本中fork了子进程。当在终端中按Ctrl-C终止脚本执行时,子进程会同时被中止。

4. 会话组

会话组是进程组的集合,当在shell中执行命令:git log | grep shipped | less时,每个命令都有一个进程组,三个进程组属于同一个会话组。当终端收到Ctrl-C时,发送给会话leader的信号会被转发给该会话组的每个进程组,然后再被转发到进程组中的所有进程。

5. setsid

setsid系统调用会使衍生进程成为一个新会话组leader(同时也是新进程组leader),并返回新建的会话组id。在PHP中对应的函数是posix_setsid();

6. 为何要fork两次。

这个问题就是这段代码中最难解释的部分了,有一些人认为只fork一次就够用了,剩下的观点分为两种,一种是两次fork是为了杜绝守护进程控制终端的可能,另一种认为两次fork是为了避免产生僵尸进程。

6.1. 防止守护进程控制终端

如果要生成一个守护进程,我们应该确保它不会控制终端,完全在后台运行。而只有会话组leader才能控制终端,因此就要确保守护进程不是会话组leader,这就是第二次fork的作用。

第一次fork并setsid使进程脱离当前终端,第二次fork确保进程永远不会控制终端。

6.2. 避免守护进程成为僵尸进程

网上另有一种说法是,父进程的存在并非只为生成守护进程。如果只fork一次,且父进程不退出,那么守护进程终止之后就会成为僵尸进程。因此要生成一个子进程来fork出守护进程,fork出守护进程之后子进程就exit,以便将守护进程交由init进程托管。我不是很倾向于这种说法,因为很多fork两次的代码中都不存在「父进程生成守护进程后,还有自己的事要做,它的人生意义并不只是为了生成守护进程」这种事。

关于这个问题,也可以参考stackoverflow上的一个讨论

现在再看回头看一下那段代码,应该很容易理解了。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注