先看一个几乎每个工程师都踩过的现场:你 SSH 登上服务器,敲下 ./worker & 启动了一个后台任务,看到输出 worker 进程的 PID,确认它跑起来了,然后关掉终端下线。十分钟后同事告诉你——进程死了。你没做错什么明显的事:进程已经在后台运行了,为什么终端断开会把它带走?再看第二个现场:一个用 C 写的老服务需要以守护进程方式运行,代码里老老实实 fork() 了两次,年轻同事接手后觉得第二次 fork 纯属多余删掉了,测试时一切正常,上线三个月后在一个偶然的时点进程莫名挂掉。两个现场的答案都藏在进程的三张身份表里——PID、进程组、会话。这一篇把它们彻底讲清:ps 里那些 PGID、SID、STAT 列到底是什么,SIGHUP 从哪来、打到谁,孤儿进程由谁收养、僵尸由谁埋葬,以及一份 daemon 化代码里每个看似玄学的步骤到底在防什么。
一、三张身份表:PID、进程组、会话
每个进程除了 PID 和 PPID,还挂着两个容易被忽略的整数:PGID(进程组 ID)与 SID(会话 ID)。三者的关系是一条单向的归属链:若干进程构成一个进程组,若干进程组构成一个会话。用一条命令就能看清全貌:
$ ps -eo pid,ppid,pgid,sid,tty,stat,cmd | head -9
PID PPID PGID SID TT STAT CMD
1 0 1 1 ? Ss /sbin/init splash
543 1 543 543 ? Ss /lib/systemd/systemd-journald
890 1 890 890 ? Ss /usr/sbin/sshd -D
1234 890 1234 1234 ? Ss sshd: alice [priv]
1237 1234 1237 1237 ? S sshd: alice@pts/0
1238 1237 1238 1238 pts/0 Ss -bash
1456 1238 1456 1238 pts/0 S ./worker
1457 1238 1457 1457 pts/0 R+ ps -eo pid,ppid,pgid,sid,...
逐行读这张表,信息量很大。STAT 列的第一个字符是进程状态:S 是可中断睡眠,R 是运行或就绪;第四个字符小写 s 表示会话首进程(session leader),大写 + 表示前台进程组成员。你会注意到一个规律:bash(PID 1238)的 PGID 和 SID 都等于它自己的 PID——它是这次 SSH 登录会话的会话首进程,同时它自己也是一个进程组的组长。./worker(PID 1456)的 PGID 是 1456、SID 却是 1238:它自成一个进程组,但归属于 bash 所在的会话。注意 TT 列:会话 1238 的所有进程都挂在 pts/0 这个伪终端上,这个 tty 就是该会话的控制终端。
把这张表抽象成结构,就是下面这棵归属树:
flowchart TD
SID1238["会话 1238(bash)<br/>控制终端 pts/0"] --> FG["前台进程组 1457<br/>ps"]
SID1238 --> BG["后台进程组 1456<br/>./worker"]
SID1238 --> LEADER["进程组 1238<br/>bash(会话首兼组长)"]
SID1["会话 1(systemd)<br/>无控制终端"] --> G543["进程组 543<br/>systemd-journald"]
SID1 --> G890["进程组 890<br/>sshd -D"]
为什么要做这两层分组?答案几乎全部关于信号投递的粒度。kill 不只接受 PID,还接受负数形式的 PGID:kill -TERM -1456 会把 SIGTERM 发给进程组 1456 里的每一个进程,而不是单个。shell 里 Ctrl+C(SIGINT)、Ctrl+Z(SIGTSTP)也从不打给单个进程,而是投给前台进程组的全体成员——你在终端里跑 make,Ctrl+C 之所以能同时杀掉 make 和它派生的 gcc、as,正是因为它们同属一个前台进程组。API 层面对应的调用是 getpgrp()/setpgid():子进程在 fork 之后、exec 之前调用 setpgid(0, 0) 把自己立为新进程组的组长,这是 job control 协议里 shell 与子进程的一次「双向奔赴」——shell 和子进程谁先调用都有竞态,POSIX 的做法是两边都调用,保证无论谁先执行,子进程都已脱离原进程组。
二、会话与控制终端:SIGHUP 的源头
会话是比进程组更高一层的聚合,它的核心属性是至多一个控制终端。建立会话的系统调用是 setsid():调用进程必须不是进程组组长(否则失败返回 EPERM),调用成功后它成为新会话的会话首、新进程组的组长,并且脱离原来的控制终端。这条「脱离控制终端」正是 daemon 化的关键一步——daemon 不该被任何终端的关闭事件波及。
SIGHUP 的投递规则值得单独背下来,因为它是「SSH 一断进程就死」的直接原因。SIGHUP 在三种情形下被发出:其一,终端驱动检测到控制终端的连接断开(SSH 会话结束、窗口关闭、调制解调器掉线的年代遗名 hang up),内核把 SIGHUP 发给会话首进程;其二,会话首进程终止时,内核把 SIGHUP 发给该会话前台进程组的所有进程;其三,若会话首进程终止后仍有进程组挂着控制终端,终端会重新归属到新的前台进程组。也就是说,./worker & 看似进了后台,它依然留在 bash 的会话里——bash 作为会话首收到 SIGHUP 时,默认行为是把自己的 job 一个个清掉:bash 会向它管理的后台 job 显式重发 SIGHUP。这正是进程死掉的完整链条:SSH 断开 → sshd 关闭 pty → 内核通知会话首 bash → bash 向后台 job 重发 SIGHUP → worker 默认对 SIGHUP 的动作是终止。
几种流行的「防死」手段在这个链条上各站一个位置。nohup ./worker & 的原理是把 SIGHUP 的处置改为 SIG_IGN——信号来了也不死,同时把 stdout/stderr 重定向到 nohup.out(这是 fd 层面的操作,下一篇文件描述符的主题)。disown 则是从 bash 的 job 表里把该 job 摘除,bash 退出时就不再给它补发 SIGHUP。setsid ./worker & 最釜底抽薪:worker 直接进入一个全新的会话,从此和 bash 的会话、pts/0 终端再无隶属关系。而现代工程里最根本的答案是——不要用 SSH 会话承载长生命周期的服务,交给 systemd。
三、孤儿与僵尸:进程死后世界的一进一出
进程的死亡分两步:第一步是它自己调用 exit_group() 退出,内核释放它的大部分资源——内存、文件描述符、地址空间;第二步是父进程对它调用 wait() 家族的系统调用,内核才把剩余的 task_struct 残骸(退出码、rusage 统计)清掉。两步之间横着一个窗口,窗口里的进程叫僵尸(zombie):它已经死了,却在进程表里占着一个 PID 和一条记录,ps 里 STAT 显示 Z。大量僵尸堆积会耗尽 PID 空间(默认 /proc/sys/kernel/pid_max 上限 4194304),新的 fork 开始报 EAGAIN——这是僵尸真正的杀伤力,不是内存,是「进程表座位」。
反过来,如果父进程先死,子进程就成了孤儿。孤儿不是病态,是常态:内核把孤儿的 PPID 改写为 1(或最近的子进程再父化者 subreaper),由它承担 wait 职责,孤儿得以正常善终。systemd(PID 1)对收养的孤儿有完备的 SIGCHLD 处理,所以孤儿不是泄漏;僵尸堆积的根因从来都是父进程活着却不 wait。在容器里这个话题尤其敏感:容器内的 PID 1 如果是你的业务进程(比如 docker run myapp 直接跑主程序),它往往不处理 SIGCHLD——它派生的任何子进程退出后都变成僵尸堆积在容器里,这正是 tini 这类 init 存在的理由。
还有一个反直觉的点:孤儿不会自动变僵尸,但父进程可以先于子进程「忘记」wait。验证孤儿收养只需要三条命令:
$ cat orphan.py
import os, time
if os.fork() == 0: # 子进程
time.sleep(300) # 活得比父进程久
os.getppid() # 此时已经是 1
else:
os._exit(0) # 父进程立即退出
$ python3 orphan.py & sleep 1
$ ps -eo pid,ppid,stat,cmd | grep '[p]ython3'
1502 1 Ss python3 orphan.py # PPID 已变成 1,被 systemd 收养
值得注意的是 systemd 的 subreaper 机制:进程可以调用 prctl(PR_SET_CHILD_SUBREAPER, 1) 把自己注册为「子树里的代理收养人」,此后该进程所在子树里产生的孤儿不再远行到 PID 1,而是就近收养给 subreaper。systemd 用户管理器、某些进程管理器(如 s6、supervisord 的部分模式)会用这个特性,保证自己能看见并收割自己管理范围内所有进程的退出事件。
四、经典 daemon 化七步:每一步都在防什么
把前面的知识拼起来,就是经典的 daemon 化流程。它像是民间口传的七步咒语,但每一步都有明确的防御目标:
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
void daemonize(void) {
/* 1. 第一次 fork,父进程立即退出:
- 让 shell 认为命令已结束(交互回到提示符)
- 子进程被收养,PPID 变为 1,不再是 shell 的 job */
pid_t pid = fork();
if (pid < 0) _exit(1);
if (pid > 0) _exit(0);
/* 2. setsid:成为新会话首 + 新进程组组长 + 无控制终端
——从根上斩断与旧终端、旧会话的全部 SIGHUP 链路 */
setsid();
/* 3. 第二次 fork:会话首进程若主动打开任何终端设备
(且没有 O_NOCTTY),会意外重新获得控制终端;
再 fork 一次让daemon只是「普通进程组成员」,永远
拿不回控制终端。这就是「第二次 fork 纯属多余」传闻
的反面——它防的是一个极低频但真实存在的场景 */
if (fork() > 0) _exit(0);
/* 4. umask(0):清除从父进程继承的权限掩码,
daemon 之后新建文件的权限不该被启动环境的 umask 意外裁剪 */
umask(0);
/* 5. chdir("/"):脱离工作目录——若工作目录在某块被 umount
的文件系统上,daemon 会把它钉住导致卸载失败 */
chdir("/");
/* 6. 关闭继承的打开文件描述符(遍历 /proc/self/fd 或 close_range) */
long max = sysconf(_SC_OPEN_MAX);
for (int fd = 0; fd < max; fd++) close(fd);
/* 7. 重定向 0/1/2 到 /dev/null:
防止后续任何 read stdin / write stdout 落到错误或已回收的 fd 上 */
int nul = open("/dev/null", O_RDWR);
dup2(nul, 0); dup2(nul, 1); dup2(nul, 2);
if (nul > 2) close(nul);
}
第一次 fork 还附带一个历史细节:在没有 capabilities 的年代,daemon 要绑定 1024 以下端口必须保持 root 身份,但它又不该以 root 跑业务逻辑——于是「fork 两次」之外还有「先绑定端口、再 setuid 降权」的顺序约定。这个顺序错了(先降权后绑定)会直接 EACCES。细节不必全背,理解每一刀砍断的是哪条链路即可:第一刀砍父子关系,第二刀砍终端与会话,第三刀防终端卷土重来,后面三刀是环境与 fd 的卫生工作。
五、systemd 时代:还需要自己 daemon 化吗
答案已经很明确:新写的服务一律不要自己 daemon 化。systemd 的服务单元里,推荐配置是 Type=simple(或 Type=exec):主进程前台运行,systemd 自己就是它的父进程、它的收养人、它的 wait 调用者和它的日志接收者。自己 daemon 化反而与 systemd 的管理模型对着干:fork 出去后 systemd 追不到主进程 PID,Type=forking 只能靠猜测 PIDFile 来管理;journal 收不到日志,因为你的 stdout 已经重定向进了 /dev/null;进程退出后变成僵尸没人收割,因为你的父进程是自定义的中间层。
Type=forking 是为兼容遗留 daemon 保留的模式:它期望服务自己完成 daemon 化,然后原始父进程退出,systemd 通过 PIDFile= 记录的 pid 文件找到真正的主进程。这是 nginx、crond 这类老牌守护进程的打包方式。两者的观察差异一目了然:Type=simple 服务在 systemctl status 里主 PID 就是你的进程,其 PPID 恒为 1;Type=forking 服务若忘了配 PIDFile,systemd 停服时可能只杀掉了「壳」,业务进程成了无主孤儿继续监听端口,下次启动报「Address already in use」。另外 systemd 的 KillMode(默认 control-group)利用了 cgroup 这个比进程组更强的分组机制——stop 时杀掉整个 cgroup 内所有进程,无论它们 fork 到多深、脱离进程组与否。进程组挡不住「深水区的孙子进程」,cgroup 挡得住,这是服务管理从 SysV 时代走到 systemd 时代的本质升级。
小结
进程生命周期管理的三张身份表构成了终端时代的作业控制体系:进程组是信号的批量投递单位(kill -PGID、Ctrl+C 打前台组),会话是控制终端的归属单位(SIGHUP 沿着「终端断开 → 会话首 → 前台组 → bash 补发后台 job」的链条传播),孤儿由 PID 1 或 subreaper 收养兜底,僵尸由父进程 wait 埋葬。经典 daemon 化七步的每一步都在切断一条继承链——fork 断父子、setsid 断终端与会话、二次 fork 断控制终端回收、chdir 断文件系统、fd 重定向断 IO 残留。而在 systemd 时代,正确的姿势是反向的:服务前台运行、什么都不自己了断,把收养、收割、信号、日志全部交还给 PID 1。理解了这套体系,nohup、disown、setsid、Type=forking 就不再是需要背的咒语,而是一张链路图上的不同断点。
读者留言
COMMENTS 暂无还没有留言,来说第一句?