深蓝黑背景封面:左侧原创示意图是一个三层嵌套结构——最外层紫色大圆角框标注「会话 session」,内部上方青色框是「前台进程组」(含 shell、vim 两个进程芯片,标注独占终端输入),下方灰色框是「后台进程组」(含 build、test 两个进程芯片,标注 Ctrl+C 只发给前台组),框底注明一个会话可绑定一个控制终端;右侧大标题「进程组、会话 与守护进程」,副标题「Linux 进程家谱一次理清」,并提示 fork 两次才能彻底切断控制终端;左上标注「知识延伸 · 进程管理」,右上圆角胶囊标注「Day 05 · 11-12」 图 1:本文封面,一个会话里的前台组与后台组,Ctrl+C 只发给前台组。

同天的真题解析讲了孤儿进程:父先退、子被 1 号进程收养。那篇文章回答的是「一个进程没爹了怎么办」,今天往后退一步,看清它所在的家谱:Linux 的进程不是一盘散沙,而是按「进程 → 进程组 → 会话」三层组织起来的。看懂这三层,再回答一个面试与工程都绕不开的问题:一个进程如何正确 daemonize 成守护进程,为什么惯例要 fork 两次?

第一层:进程组,信号的分发单位。

每个进程必属于一个进程组,组 ID(PGID)等于组长的 PID。组存在的意义是「整体接收信号」:你在 shell 里跑一条管道,cat、grep、less 就被划进同一个进程组,按下 Ctrl+C 时 SIGINT 发给整个组,三个进程一起退场,而不是只杀掉排在最前面的那个。kill 一个负数的 PID,就是给整个组发信号。

第二层:会话,终端的归属单位。

一批进程组构成一个会话,会话 ID(SID)等于会话首进程的 PID。一次登录(一个终端上的 shell 及其拉起的所有作业)通常就是一个会话。会话可以绑定一个控制终端,而终端的输入有个铁律:同一时刻只有前台进程组能读终端,Ctrl+C、Ctrl+Z 这类按键信号也只发给前台组;后台组敢读终端,就会被 SIGTTIN 挂起。vim 在前台、构建任务在后台互不抢键盘,靠的就是这套归属规则。

守护进程的目标:把这三层关系全部斩断。

守护进程(daemon)要长期在后台运行,就必须脱离「用户注销、终端关闭就跟着死」的命运。拆解下来,它要摆脱三样东西:父进程(不能挂着等收尸)、进程组与会话(不能收到发给原组的信号)、控制终端(终端挂断时 SIGHUP 找不到它)。经典的 daemonize 流程就是对着这三样逐一开刀,一共六步。

daemonize 六步,每一步都有明确分工。

第一步,fork 后父进程退出。一举两得:shell 认为命令结束了,子进程被 1 号收养;更关键的是子进程一定不是进程组组长(它继承了父的 PGID,不等于自己的 PID),为下一步铺路——setsid 手册明确规定,调用者是进程组组长时会以 EPERM 失败。

第二步,setsid 创建新会话。调用者成为新会话的首进程兼新进程组的组长,SID、PGID 都变成自己的 PID,并且新会话没有任何控制终端——终端这一关正式切断。

第三步,再次 fork 并让中间进程退出。这一步最容易被问住:已经是新会话了,为什么还要 fork?因为在 System V 语义下,会话首进程将来打开某个终端设备时,可能重新把它 acquire 成控制终端。再 fork 一次,干活的孙进程不再是会话首进程,就永远失去了重获终端的资格。两次 fork 的分工是:第一次为 setsid 创造条件,第二次永久切断与控制终端的联系。

第四步,chdir 到根目录,避免守护进程占着某个挂载点导致它无法卸载。第五步,umask(0) 重置文件掩码,不让继承来的权限限制坑了自己创建的文件。第六步,关闭或重定向标准输入、输出、错误到 /dev/null——终端都没了,往 stdout 打印只会出错。glibc 的 daemon() 函数封装了这套动作的大部分,但它的手册在 BUGS 一节坦白:实现没有采用 double-fork,生成的守护进程仍是会话首进程,可能意外重获终端——所以严肃的服务仍然手写六步。

把六步跑出来验证。

下面用 Python 3 完整执行这套流程,并把各阶段的 pid、ppid、pgid、sid 写进日志(python3 daemon-demo.py):

import os, sys, time

LOG = "/tmp/daemon-demo.log"
log = open(LOG, "w")

def show(tag):
    log.write(f"{tag}: pid={os.getpid()} ppid={os.getppid()} pgid={os.getpgid(0)} sid={os.getsid(0)}\n")
    log.flush()

show("启动时(继承 shell 的组与会话)")

if os.fork() > 0:
    time.sleep(1)                 # 父进程:等守护进程写完日志再打印
    print(open(LOG).read())
    sys.exit(0)

# 子进程不是进程组组长,setsid 才能成功
os.setsid()
show("setsid 后(新会话首进程)")
try:
    os.close(os.open("/dev/tty", os.O_RDONLY))
    log.write("异常:还能打开控制终端\n")
except OSError as e:
    log.write(f"打开 /dev/tty 失败:{e.strerror}\n")
log.flush()

if os.fork() > 0:
    os._exit(0)                   # 第二次 fork:中间进程退出
time.sleep(0.1)
show("第二次 fork 后(非会话首进程)")
os.chdir("/")
os.umask(0)
devnull = os.open("/dev/null", os.O_RDWR)
for fd in (0, 1, 2):
    os.dup2(devnull, fd)          # 标准流全部指向 /dev/null
log.write(f"daemon 就绪:pid={os.getpid()} 永不能再获得控制终端\n")
log.close()

实测输出(PID 以实际运行为准):

启动时(继承 shell 的组与会话): pid=28899 ppid=23035 pgid=28899 sid=28899
setsid 后(新会话首进程): pid=28901 ppid=28899 pgid=28901 sid=28901
打开 /dev/tty 失败:Device not configured
第二次 fork 后(非会话首进程): pid=28902 ppid=1 pgid=28901 sid=28901
daemon 就绪:pid=28902 永不能再获得控制终端

每一行都对应一步语义:启动时进程继承 shell 的组与会话;setsid 后 pgid 和 sid 都变成了自己的 PID,说明它同时成了组长与会话首进程,而打开 /dev/tty 直接失败,终端已脱离;第二次 fork 后,孙进程的 PPID 变成 1(中间进程已退出、被 1 号收养),但它的 sid 仍不等于自己的 PID——不是会话首进程,重获终端的资格被永久吊销。

左侧「进程的三层组织」图:三条逐级缩进的横向色带以包含符号连接——会话是一批进程组的集合、可绑定一个控制终端,SID 等于会话首进程 PID;进程组是一组可整体接收信号的进程,PGID 等于组长 PID;进程是最小执行单位,必属于一个组、组必属于一个会话。右侧「daemonize 六步」纵向流程:第一步 fork 后父进程退出,使子进程不再是进程组组长,为 setsid 铺路;第二步 setsid 创建新会话,子进程成为会话首进程兼新组组长并脱离控制终端;第三步再次 fork 后父进程退出,孙进程不是会话首进程,永远无法重获控制终端;第四步 chdir 到根目录避免占用挂载点;第五步 umask(0) 重置文件掩码;第六步关闭标准输入输出错误或重定向到 /dev/null。第一、三步用青色高亮,第二步用紫色高亮。底部总结条:两次 fork 的分工——第一次为 setsid 创造条件,第二次永久切断与控制终端的联系 图 2:三层组织结构与 daemonize 六步流程,两次 fork 各有分工。

最后收个尾:三层组织解释了「为什么」——信号按组发、终端按会话归属,守护进程才需要逐层斩断;六步流程回答了「怎么做」——fork、setsid、再 fork、chdir、umask、重定向,每步对应斩断一层关系。下次再看到服务脚本里的 double-fork,你就能说出每一步在防什么了。你维护的服务是手写 daemonize、用 daemon(),还是直接交给 systemd 这类进程管家?不同选择踩过哪些坑?欢迎聊聊。