本文介绍一个从 Linux 的 shell 诞生的进程,要经历怎样的“考验”,才能成为一个 daemon 进程。
后台进程,顾明思议,在后台执行,没有终端,没有 Login shell。当某些 Event 发生的时候进行处理,或者定期执行某项任务。通常,daemon 进程以 d 结尾,但不是必须的,比如 Redis 和 Nginx 的 daemon 进程就没有以 d 结尾。
后台进程最明显的特征,是 shell (通过 ssh 或者 terminal app 打开的终端)退出之后,后台进程不会退出,而是继续运行,提供服务。
简单来说,daemon 需要具备以下两项基本条件:
- 是 init 进程的子进程;
- 没有连接到任何 terminal;
此外,daemon 进程通常还会做以下几个事情:
- 关闭所有的 file descriptors,除了 input, output, error 这三个。因为这些 file descriptors 可能是 shell,或者其他进程。而后台进程最关键的就是不连接 shell 和 terminal。可以使用
open_max
或getrlimit
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 个概念:
- pid 是什么?进程 ID,一个进程最基本的标志。创建新的进程的时候 kernel 会分配一个 pid。
- ppid 是什么?创建此进程的进程,即父进程,这里就是 shell 的 pid,因为进程是从 shell 创建的。
- sid 是什么?sid 指的是 session id,本文不作过多介绍,读者可以认为是和 shell 在一组 session 的进程,这样 shell 退出的时候会给 session leader id 为 shell id 的进程都发送 SIGHUP,将自己产生的子进程都一并退出,方便管理。所以,新创建进程的 sid 也是 shell pid,自动加入 shell 的 session。
- 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 的变化,得到的结果会和上图一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#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; } |
运行结果如下:
1 2 3 4 5 6 |
$ 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 的使用,又要写一篇文章了。
- https://unix.stackexchange.com/a/200365/5132
- https://unix.stackexchange.com/a/194653/5132
- https://unix.stackexchange.com/a/211126/5132
- https://unix.stackexchange.com/a/336067/5132
- https://unix.stackexchange.com/a/283739/5132
参考资料
- What is the reason for performing a double fork when creating a daemon?
- What are “session leaders” in
ps
? - Daemonize a process in shell?
- daemonize — A tool to run a command as a daemon 非常值得一读,代码只有几十行,对理解 daemonize 很有帮助。
- Linux System Programming P172
- Orphaned Process Groups
- TUE Linux Kernel
- Can systemd handle double-fork daemons?
- man 7 daemon 介绍了新式的 systemd daemon,和之前的 SysV 有什么不同。systemd 不会进行第二次 fork() ,所以你会发现用 systemd 管理的服务都是 session leader。这是因为这些服务不是从 shell 启动的,而是 systemd 启动的。
- daemon-skeleton-linux-c 另一个比较简单的 daemon 代码,可以直接编译运行
- Linux-UNIX-Programmierung – German
- Unix Daemon Server Programming
Pingback: 研究了下 daemon 进程的条件,以及为什么那么多地方说要 fork 两次,来分享下 - 社交电商
> 关闭所有的 file descriptors,尤其是 input output error。
原文是 except,表示关闭除了这三个之外的 fd。
感谢,已经改正。