守护进程

2020/06/30 操作系统

守护进程(daemon),简单来说就是可以脱离终端在后台运行的进程,脱离终端后成为内核初始进程的子进程。常见的服务如 web 服务都以守护进程的方式运行,它们的特点就是需要长时间或者永久运行,持续对外提供服务。

进程包括以下几个 ID:

  • PID:进程 ID,进程的唯一标识。
  • PPID:父进程 ID。
  • PGID:进程组 ID,每个进程都会有进程组 ID,表示该进程所属的进程组。默认情况下新创建的进程会继承父进程的进程组 ID。
  • SID:会话 ID,每个进程都有会话 ID。默认情况下,新创建的进程会继承父进程的会话 ID。

在 Linux 中可以通过ps ajx查看这几个 ID。一般 PPID 为 0 的,都是内核态进程;一般 PPID 为 1 且 PID == PGID == SID 的,都是守护进程,当然还有一些特殊情况,PID 跟 PGID、SID 不一致。

守护进程的创建流程如下:

  • fork 一个子进程,退出主进程,目的是让子进程继承进程组 ID,并获取一个新的进程 ID,这保证了子进程一定不是进程组组长,因为进程组组长不能创建新会话。
  • setsid 创建新会话以及设置进程组 ID,成为会话组长和进程组组长,脱离原会话、进程组、终端控制,这样该进程就不会被原终端的控制信号中断。使得进程完全独立出来,摆脱其它进程的控制。
  • 再次 fork 出子进程,退出父进程(前一个子进程)。通过再次创建子进程结束当前进程,使进程不再是会话首进程来禁止进程重新打开控制终端。这个步骤并不是必须的,只是在基于 System-V 的系统上,有人建议再 fork 一次,避免打开终端设备,使程序的通用性更强。
  • 设置文件创建掩码,在子进程中调用 umask(0) 重设文件权限,这是因为子进程继承了父进程的文件权限掩码,带来一定麻烦。这个并不是必要的,需要操作文件才需要

补充 APUE 中的守护进程编程规则

以上守护进程的创建流程可能不是太规范。以下是 APUE 中给出的在编写守护进程程序时需遵循的基本规则。

  1. 首先要做的是调用 umask 将文件模式创建屏蔽字设置为一个已知值(通常是 0)。由继承得来的文件模式创建屏蔽字可能会被设置为拒绝某些权限。如果守护进程要创建文件,那么它可能要设置特定的权限。
  2. 调用 fork,然后使父进程 exit。这样做实现了下面几点。第一,如果该守护进程是作为一条简单的 shell 命令启动的,那么父进程终止会让 shell 认为这条命令已经执行完毕。第二,虽然子进程继承了父进程的进程组 ID,但获得了一个新的进程 ID,这就保证了子进程不是一个进程组的组长进程。这是下面将要进行的 setsid 调用的先决条件。
  3. 调用 setsid 创建一个会话。使调用进程:(a)成为新会话的首进程,(b)成为一个新进程组的组长进程,(c)没有控制终端。在基于 System V 的系统中,有些人建议在此时再次调用 fork,终止父进程,继续使用子进程中的守护进程。这就保证了该守护进程不是会话首进程,于是按照 System V 规则,可以防止它取得控制终端。
  4. 将当前工作目录更改为根目录。从父进程处继承过来的当前工作目录可能在一个挂载的文件系统中。因为守护进程通常在系统再引导之前是一直存在的,所以如果守护进程的当前工作目录在一个挂载文件系统中,那么该文件系统就不能被卸载。或者,某些守护进程还可能会把当前工作目录更改到某个指定位置,并在此位置进行它们的全部工作。
  5. 关闭不再需要的文件描述符。这使守护进程不再持有从其父进程继承来的任何文件描述符。
  6. 某些守护进程打开/dev/null使其具有文件描述符 0、1 和 2,这样,任何一个试图读标准输入、写标准输出或标准错误的库例程都不会产生任何效果。因为守护进程并不与终端设备相关联,所以其输出无处显示,也无处从交互式用户那里接收输入。即使守护进程是从交互式会话启动的,但是守护进程是在后台运行的,所以登录会话的终止并不影响守护进程。如果其他用户在同一终端设备上登录,我们不希望在该终端上见到守护进程的输出,用户也不期望他们在终端上的输入被守护进程读取。

因此,以下程序也并不太规范,缺少了第 4-6 步。

守护进程示例

以下是 C 语言的示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    umask(0);

    pid_t pid;
    pid = fork();

    if (pid < 0) {
        printf("fork error\n");
        exit(1);
    } else if (pid > 0) {
        sleep(10);
        printf("first process exit\n"); // 2
        return 0;
    }

    printf("before setsid\n");  // 1
    sleep(20);
    setsid();
    printf("after setsid\n"); // 3

    pid = fork();
    if (pid < 0) {
        printf("fork error\n");
        exit(2);
    } else if (pid > 0) {
        sleep(10);
        printf("second process exit\n"); // 4
        return 0;
    }

    sleep(20);
    printf("Hello world\n"); // 5
    return 0;
}

附加个 PHP 版本的,跟 C 语言的大同小异,需要安装pcntlposix扩展。后面的说明以 C 语言的为准,当然 PHP 版本的试过也没有问题,效果是一致的。

<?php

umask(0);

$pid = pcntl_fork();

if ($pid < 0) {
    printf("fork error\n");
    exit(1);
} else if ($pid > 0) {
    sleep(10);
    printf("first process exit\n"); // 2
    exit(0);
}

printf("before setsid\n"); // 1
sleep(20);
posix_setsid();
printf("after setsid\n"); // 3

$pid = pcntl_fork();

if ($pid < 0) {
    printf("fork error\n");
    exit(2);
} else if ($pid > 0) {
    sleep(10);
    printf("second process exit\n"); // 4
    exit(0);
}

sleep(20);
printf("Hello world\n"); // 5
exit(0);

接着,根据以上程序的运行情况进行分析。

不同时间点的进程状态

需要额外说明一下,由于运行环境处于 docker 中,所以守护进程的 PPID 不是 1,而是 0。每个时间点顺序位于以上程序的注释处。以下内容以 C 语言实现的版本为准。

before setsid

root@4b3b2feeac63:/var/www/html# ps ajx | sort -k 10
   16   145   145    16 pts/1      145 S+       0   0:00 ./a.out
  145   146   145    16 pts/1      145 S+       0   0:00 ./a.out
    0    16    16    16 pts/1      145 Ss       0   0:00 /bin/bash
    0   134   134   134 pts/0      147 Ss       0   0:00 /bin/bash
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND

这时,第一次创建完子进程,子进程的 PPID、PID、PGID、SID 分别为 145、146、145、16。可以看到进程 16 和 145 的 PGID 都是 145,PGID 跟进程 145 的 PID 相同,两个进程位于同一个进程组;而 SID 则是为 16,跟 PID 为 16 的终端一致,进程 145、146、16 共享一个会话。

first process exit

root@4b3b2feeac63:/var/www/html# ps ajx | sort -k 10
    0   146   145    16 pts/1       16 S        0   0:00 ./a.out
    0    16    16    16 pts/1       16 Ss+      0   0:00 /bin/bash
    0   134   134   134 pts/0      149 Ss       0   0:00 /bin/bash
  134   150   149   134 pts/0      149 R+       0   0:00 /bin/bash
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND

第一次父进程退出后,子进程 PPID 变成了 0,这是因为子进程变成孤儿进程之后被内核初始进程收养了,docker 容器的内核初始进程 PID 就是 0;其余的 ID 并没有变化。

after setsid

root@4b3b2feeac63:/var/www/html# ps ajx | sort -k 10
    0   146   146   146 ?           -1 Ss       0   0:00 ./a.out
  146   153   146   146 ?           -1 S        0   0:00 ./a.out
    0    16    16    16 pts/1       16 Ss+      0   0:00 /bin/bash
    0   134   134   134 pts/0      156 Ss       0   0:00 /bin/bash
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND

调用 setsid 之后,同时第二次创建了子进程,也就是进程 153。这时,第一次创建的子进程(也就是 PPID 为 0 的进程)PGID 和 SID 都变成 146,跟其 PID 相同,实现了自己的进程组和会话,此时进程已经跟进程 16 (终端)脱离了;同时,进程 153 的 PGID 和 SID 跟进程 146 相同,也就是它们在同一个进程组中,并共享会话。

second process exit

root@4b3b2feeac63:/var/www/html# ps ajx | sort -k 10
    0   153   146   146 ?           -1 S        0   0:00 ./a.out
    0    16    16    16 pts/1       16 Ss+      0   0:00 /bin/bash
    0   134   134   134 pts/0      158 Ss       0   0:00 /bin/bash
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND

第一次创建的子进程退出,它的子进程保留原有的 PGID 和 SID,但是 PID 跟 PGID、SID 不相同的,也就是本文开头所说的特殊情况。

Hello world

root@4b3b2feeac63:/var/www/html# ps ajx | sort -k 10
    0    16    16    16 pts/1       16 Ss+      0   0:00 /bin/bash
    0   134   134   134 pts/0      162 Ss       0   0:00 /bin/bash
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND

守护进程完成使命,光荣退出。

当然常见的守护进程并不会自行退出。由于守护进程已经脱离终端了,因此不能直接使用 Ctrl+C 退出。Ctrl+C 本质上就是给进程发送一个 SIGINT 信号,当然我们还可以通过 kill 命令给进程发送信号,不过这方法不优雅。既然知道了退出进程的方法就是给进程发送信号,我们就可以通过命令参数传入命令,并写对应的信号处理器,在处理器中进行资源回收等操作并退出,本文就不多说了。

Search

    Table of Contents