Daemon Process

本文介绍一个从 Linux 的 shell 诞生的进程,要经历怎样的“考验”,才能成为一个 daemon 进程。

后台进程,顾明思议,在后台执行,没有终端,没有 Login shell。当某些 Event 发生的时候进行处理,或者定期执行某项任务。通常,daemon 进程以 d 结尾,但不是必须的,比如 Redis 和 Nginx 的 daemon 进程就没有以 d 结尾。

后台进程最明显的特征,是 shell (通过 ssh 或者 terminal app 打开的终端)退出之后,后台进程不会退出,而是继续运行,提供服务。

简单来说,daemon 需要具备以下两项基本条件:

  1. 是 init 进程的子进程;
  2. 没有连接到任何 terminal;

此外,daemon 进程通常还会做以下几个事情:

  • 关闭所有的 file descriptors,除了 input, output, error 这三个。因为这些 file descriptors 可能是 shell,或者其他进程。而后台进程最关键的就是不连接 shell 和 terminal。可以使用 open_maxgetrlimit syscall 获取当前打开的最大的文件描述符,依次关闭。也可以遍历 /proc/self/fd 下的文件,依次关闭。
  • 将 working directory 切换到 / 目录。daemon 的生命周期一般伴随整个操作系统的工作时间,如果一直在继承自父进程的 working directory 工作的话,就会影响操作系统运行期间的文件系统 mount 操作。某些进程也可以切换到自己的特定目录工作;
  • 将 umask 置为默认值,通常为 0。因为 daemon 进程创建文件的时候,会想自己设置文件的权限,而不受 umask 的干扰。如果使用的第三方库的话,daemon 可以自己设置 umask 的值,自己限制使用的第三方库的权限;
  • 离开父进程的 process group,这样就不会收到 SIGHUP 信号;
  • 离开 controling terminal,并确保以后也不会再被重新分配到;
  • 处理 SIGCLD 信号;
  • 将 stdin stdout stderror 重定向到 /dev/null。因为后台运行的进程不需要用户输入,也没有标准输出。这样也可以确保当其他用户 login shell 的时候,不会看到 daemon 的输出。

这是 daemon 进程通常会做的事情,man 7 daemon 中有更详细的描述。 接下来,主要讨论 daemon 最精彩的部分,即如何通过两次 fork() 来完成脱离 terminal。

两次 fork()

前面介绍了一些比较简单的处理,比如 chdir,reset umask。接下来讨论如何脱离 terminal。

为了方便读者理解,我先画一张图,并标出每一步动作发生了哪些变化,然后再具体解释。

Shell 创建进程的过程如上图。这里先解释一下 4 个概念:

  1. pid 是什么?进程 ID,一个进程最基本的标志。创建新的进程的时候 kernel 会分配一个 pid。
  2. ppid 是什么?创建此进程的进程,即父进程,这里就是 shell 的 pid,因为进程是从 shell 创建的。
  3. sid 是什么?sid 指的是 session id,本文不作过多介绍,读者可以认为是和 shell 在一组 session 的进程,这样 shell 退出的时候会给 session leader id 为 shell id 的进程都发送 SIGHUP,将自己产生的子进程都一并退出,方便管理。所以,新创建进程的 sid 也是 shell pid,自动加入 shell 的 session。
  4. pgid 是什么?pgid 是 process group id,是一组进程id。考虑这种命令:grep GET accessl.log | awk '{print $1}' | sort | uniq ,如果我们想结束这个命令的时候,不会想 grep,awk.. 这样一个一个的结束,而是想将他们一次性全部结束。为了方便管理,shell 会将这种管道连接的进程置为一组,这样可以通过 pgid 一并结束,方便管理。所以,新创建的进程的 pgid 是自己,它自己也叫做 group leader。

第一次 fork()。 调用 fork(),父进程立即退出(为了方便后续讨论,我们将这次的子进程称为 child1)。这里的作用有3个:首先,进程是从 shell 启动的,如果进程不结束,那么 shell 的命令行将 block 在这里,这一次 fork() 让 shell 认为父进程已经正常结束了。其次,child1 fork 出来的时候,默认加入了父进程的 progress group,这让 child1 不再是一个 group leader(它的 pgid 不等于 pid),这是调用 setsid 的必备条件。实际上,由于父进程退出,child1 所在的 process group 已经是一个 Orphaned Process Group。第三,由于父进程已经退出,所以 child1 的父进程是 init 进程。

setsid。由于 child 的 sid 依然是 shell 的 id,所以当 shell 退出的时候依然会被带走。所以这里要调用 setsid ,脱离 shell 所在的 session。但是 setsid 之后,它的 pgid 和 sid 都等于它的 pid 了,这意味着它成为了 session leader 和 group leader。这其实就是为什么要 fork 第二次的原因,也是我最大的困惑,和花了最多时间去理解的地方。

第二次 fork() 。为什么要第二次 fork() ?这个问题我读了很多不正确的 Stack Overflow 讨论,以及没有第二次 fork() 的实现,比如 Linux System Programming 5.7 Daemons 中的 daemon 代码就是没有第二次 fork() 的,Kernel 提供的 man 3 daemon 也没有第二次 fork。需要做第二次 fork() 的原因很简单:如果一个进程是 session leader,那么它依然有机会获得 tty 的(在基于 SysV 的系统下),我们要确保它不可能获得 tty,就再 fork() 一次,并且退出 child1,确保最终提供 daemon 服务的 child2 不是一个 session leader。

这个过程也可以看下  daemonize 里面的 daemon 函数,和上述过程一样。

我写了一段代码演示两次 fork() 各种 pid 的变化,得到的结果会和上图一样。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

void printid() {
    pid_t pid = getpid();
    pid_t ppid = getppid();
    pid_t sid = getsid(pid);
    pid_t pgid = getpgid(pid);
    printf("pid=%d, ppid=%d, sid=%d, pgid=%d\n", pid, ppid, sid, pgid);
}

int main(){
    printf("parent process: ");
    printid();

    if ((fork()) != 0)
        exit(0);
    printf("child1: ");
    printid();

    setsid();
    printf("after setsid: ");
    printid();

    if ((fork()) != 0)
        exit(0);
    printf("child2: ");
    printid();
    return 0;
}

运行结果如下:

$ gcc fortest.c
$ ./a.out
parent process: pid=2680, ppid=2622, sid=2622, pgid=2680
child1: pid=2681, ppid=1, sid=2622, pgid=2680
after setsid: pid=2681, ppid=1, sid=2681, pgid=2681
child2: pid=2682, ppid=1, sid=2681, pgid=2681

Protocol Mismatch

如果使用 systemd 这种任务控制机制的话,注意需要按照这些系统规定的 readiness protocol 来设定你的程序,即你要可以将 chdir,umask 这种事情托付给 systemd 来做,但是你要遵守 systemd 的协议来告诉它你的进程就绪,可以提供服务了。

常见的一种错误是在自己的进程中 fork() 了两次,但是在 systemd 中使用了 Type=simple ,并认为这样是告诉 systemd 自己的进程是一个普通进程,自己处理了 daemon。而实际上,这是在告诉 systemd 你的进程是启动后立马 ready,ExecStart 的进程就是目标进程,所以在第一次父进程 fork() 并退出的时候,systemd 认为你的进程挂了。 很多时候,比如用 systemd 控制 Redis Nginx 这种服务,总是启动超时,一般也是因为这个问题。 这里有很多常见错误的例子,就不一一解释了,介绍 systemd 的使用,又要写一篇文章了。

感谢依云和@mrluanma解答我百思不得其解的一些困惑。

参考资料

  1. What is the reason for performing a double fork when creating a daemon?
  2. What are “session leaders” in `ps`?
  3. Daemonize a process in shell?
  4. daemonize — A tool to run a command as a daemon 非常值得一读,代码只有几十行,对理解 daemonize 很有帮助。
  5. Linux System Programming P172
  6. Orphaned Process Groups
  7. TUE Linux Kernel
  8. Can systemd handle double-fork daemons?
  9. man 7 daemon 介绍了新式的 systemd daemon,和之前的 SysV 有什么不同。systemd 不会进行第二次 fork() ,所以你会发现用 systemd 管理的服务都是 session leader。这是因为这些服务不是从 shell 启动的,而是 systemd 启动的。
  10. daemon-skeleton-linux-c 另一个比较简单的 daemon 代码,可以直接编译运行
  11. Linux-UNIX-Programmierung – German
  12. Unix Daemon Server Programming


Daemon Process”已经有3条评论

  1. > 关闭所有的 file descriptors,尤其是 input output error。

    原文是 except,表示关闭除了这三个之外的 fd。

Leave a comment

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