说到进程,绝大多数人第一反应是"进程就是一个跑起来的程序,它有一个 PID"。这句话没错,但太"孤岛"了。真实系统里的进程从来不是孤立存在的——它们有爹(父进程)、有兄弟(同组进程)、有朋友圈(会话)、甚至有可能变成没人要的孤儿,或者死了还赖在进程表里的僵尸。而你天天用的 SSH、nginx、MySQL、你写的一个 HttpServer,最后几乎都要"躲在后台默默干活",这就是守护进程。

这篇文章我们把这些东西一次性讲透。从进程组、会话这些"组织关系",到控制终端、作业控制这些"终端下的玩法",再到回炉补一刀的孤儿子进程与僵尸进程,最后亲手写一个守护进程并验证它真的脱离了终端、藏进了后台。所有概念第一处出现都会解释,所有代码都是完整可编译可运行的。开始吧。

进程组

什么是进程组

每个进程除了有一个进程 ID(PID)之外,还属于一个进程组(process group)。进程组是一个或多个进程的集合,一个进程组可以包含多个进程,它们之间通常是"一个有协作关系的小团体"。

每一个进程组也有一个唯一的进程组 ID(PGID,Process Group ID)。这个 PGID 和进程 ID 一样,也是一个正整数,同样可以用 pid_t 数据类型来存放。pid_t 是 POSIX 定义的一种类型,通常是 int 的别名,专门用来装各种"进程相关的 ID"。

怎么用命令看到进程组归属?ps 提供 -e 和 -o 两个选项来控制输出:-e 表示 every,输出系统中每一个进程;-o 用逗号作为分隔符,可以指定你要输出的列。看看下面这条:

# -e 表示输出每一个进程;-o 指定要输出的列:PID / PGID / PPID / COMMAND
$ ps -eo pid,pgid,ppid,comm | grep test
# 结果(PID 和 PGID 相同的是组长进程)
PID   PGID   PPID COMMAND
2830   2830  2259 test

第 1 列是 PID(进程 ID,Process ID),第 2 列是 PGID(进程组 ID),第 3 列是 PPID(父进程 ID,Parent Process ID),第 4 列是命令名。grep test 只是在 ps 的输出里筛出名字里带 test 的行,方便聚焦观察。

组长进程

每个进程组都有一个组长进程(group leader)。判断组长进程的标准很简单:组长进程的进程 ID 等于它的进程组 ID,即 PID == PGID。我们用一个实际例子观察一下:

# 单独用 -o 指定列,查看当前会话里的几个进程
$ ps -o pid,pgid,ppid,comm | cat
# 输出结果
PID   PGID   PPID COMMAND
2806   2806   2805 bash
2880   2880   2806 ps
2881   2880   2806 cat

这里 ps 进程的 PID 和 PGID 都是 2880,说明 ps 是该进程组的组长进程,这个进程组里包含 ps 和 cat 两个进程(它们俩的 PGID 都是 2880)。为什么 ps | cat 能编成一组?因为管道 | 让 ps 和 cat 建立了协作关系,shell 通常会把一条管道里的所有进程放进同一个进程组——这一点到"会话"和"作业控制"会详细展开。

关于组长进程,还有两条结论要记牢:

  • 组长进程的作用:一个进程组的组长可以创建这个进程组,或者创建 / 加入这个组中的进程。说得直白点,组长是这个进程组的"牵头人"。
  • 进程组的生命周期:从进程组创建开始,一直持续到其中最后一个进程离开为止。注意这句话的潜台词——只要进程组里还有任何一个进程存活,这个进程组就存在,这跟组长进程是否已经终止没有关系。组长先挂了,剩下的成员照样待在组里,组依然存在。

会话

什么是会话

上面讲到进程组,那么把这个"组"再往上包一层,就是会话(session)。会话可以看成是一个或多个进程组的集合,一个会话可以包含多个进程组。每个会话同样有一个会话 ID(SID,Session ID)。

通常用管道把几个进程编成一个进程组。比如下面两个命令,分别创造了两个不同的后台进程组:

# 用管道连接 proc2 和 proc3,整体放到后台执行
$ proc2 | proc3 &
# 用管道连接 proc4、proc5、proc6,整体放到后台执行
$ proc4 | proc5 | proc6 &

在 shell 里,& 表示把这条命令放到后台执行,并且 shell 会为这条管道单独开一个进程组。我们用一个可以慢慢观察的经典实验来亲眼看这个现象,用三个 sleep 通过管道连在一起放到后台:

# 用管道把三个 sleep 连成一个进程组,放到后台运行
$ sleep 100 | sleep 200 | sleep 300 &
 
# 查看 ps 命令打出来的列描述信息(看表头都有哪些列)
$ ps axj | head -n1
 
# 过滤 sleep 相关的进程信息
$ ps axj | grep sleep | grep -v grep

这里顺便把 ps axj 的三个字母解释清楚:

  • a 选项:不仅列出当前用户的进程,也列出所有其他用户的进程;
  • x 选项:不仅列出有控制终端的进程,也列出所有没有控制终端的进程(有些进程是"藏起来"的);
  • j 选项:列出与"作业控制"相关的字段,比如 PGID、SID、TPGID,作业控制我们后面会讲。

grep 后面那个 -v 表示反向过滤,意思是"把带 grep 关键字的行排除掉"——因为 ps axj | grep sleep 这条命令本身也会产生一个名字里带 grep sleep 的进程,grep -v grep 就是要把这个冒充者过滤掉,只留真正叫 sleep 的进程。

结果大致是这样(具体 PID 会随系统不同而不同):

# 不同发行版 / 不同 ps 版本,列名略有差异,以你机器上的表头为准
PPID    PID   PGID    SID TTY       TPGID STAT   UID   TIME COMMAND
2806   4223   4223   2780 pts/2      4229 S     1000   0:00 sleep 100
2806   4224   4223   2780 pts/2      4229 S     1000   0:00 sleep 200
2806   4225   4223   2780 pts/2      4229 S     1000   0:00 sleep 300

注意三个 sleep 进程的 PGID 都是 4223,TTY(终端)都是 pts/2,SID 都是 2780,说明它们三个属于同一个进程组,而这个进程组又归属于同一个会话。你看,进程组和会话的概念都在这里落地了。

如何创建会话

可以用 setsid 函数来创建一个新会话。setsid 的声明在 <unistd.h> 里,原型如下:

#include <unistd.h>
/*
 * 功能:创建会话
 * 返回值:创建成功返回新的会话 ID(SID),失败返回 -1
 */
pid_t setsid(void);

调用 setsid 之后会发生三件事:

  • 调用进程会变成新会话的会话首进程(session leader,也叫会话领导者)。此时,这个新会话里只有它唯一一个进程;
  • 调用进程会变成进程组组长。新进程组的 ID 就是当前调用进程的进程 ID;
  • 该进程没有控制终端。如果调用 setsid 之前该进程有控制终端,调用之后会切断这条联系。

这里有个特别容易踩的坑:如果调用进程本身已经是进程组的组长,setsid 会直接报错、返回 -1。为什么它这么倔?因为会话首进程必须同时也是进程组组长(这两个身份被绑定了),而一个进程组组长是无法"换个组重新当组长"的,它已经占了这个组的坑,不能把整个组带入新会话。标准做法就是先用 fork 创建子进程,让父进程终止,子进程继续执行——因为子进程会继承父进程的进程组 ID,而子进程的进程 ID 是新分配的,所以子进程的 PID 一定不等于继承来的 PGID,子进程就不是组长进程,setsid 就一定不会失败。这正是后面写守护进程时"第一次 fork"的意义所在。

会话 ID(SID)

上面的讨论里反复出现"会话首进程",那会话 ID 到底是什么?先说会话首进程:会话首进程是"这个会话中具有唯一进程 ID 的单个进程",我们就把会话首进程的进程 ID 当作会话 ID。

这里有个等价关系值得点破:会话 ID 在有些资料里也被称为"会话首进程的进程组 ID"——因为会话首进程总是某个进程组的组长进程,而组长进程的 PID 等于它所在进程组的 PGID,所以"会话首进程的 PID""会话首进程所在进程组的 PGID""会话 ID",这三者在数值上是同一个东西。这也是为什么你在 ps 的输出里,经常能看到某个会话首进程的 PID、PGID、SID 三列完全相同。

思考题一:小明的程序是由前台 shell 直接启动的,他在程序里直接调用 setsid() 想让自己变成守护进程,结果 setsid 返回了 -1。为什么?

详解:凡是"前台运行"的作业,shell 都会为它新开一个进程组,并把该作业的首个进程设为组长进程。于是小明的程序运行时,它的 PID 就等于 PGID,也就是它自己已经是组长进程了。而 setsid 明确要求"调用进程不能是进程组组长",所以直接调用会失败返回 -1。解决办法就是标准做法:fork 一次,父进程 exit,由子进程去调用 setsid。因为子进程的 PID 是新的、PGID 是继承父进程的,二者必然不等,子进程不是组长,setsid 就能成功。

控制终端

先回答"什么是控制终端"这个问题。

在 Unix 系统中,用户通过终端登录系统之后,会得到一个 Shell 进程,这个终端就成了该 Shell 进程的控制终端(controlling terminal)。终端信息是保存在进程的 PCB(进程控制块,内核为每个进程保存的那一坨元数据)里的,而 fork 会复制父进程 PCB 里的信息,因此由 Shell 启动的其他进程,它们的控制终端自然也是这个终端。

默认情况下如果没有做重定向,每个进程的标准输入、标准输出、标准错误都指向这个控制终端:进程从标准输入读,读的是用户敲键盘的输入;向标准输出或标准错误写,写到的就是显示器。我们在平常写代码时不写 scanf / printf 也能看到屏幕上出现东西,正是这个机制在起作用。

会话、进程组、控制终端三者之间还有其他更细的关系,我们把它们详列如下:

  • 一个会话可以有一个控制终端,通常会话首进程打开一个终端(终端设备或伪终端设备)之后,该终端就成为会话的控制终端;反过来,一个控制终端同时只归属于一个会话;
  • 建立与控制终端连接的会话首进程,被称为控制进程(controlling process);
  • 一个会话里的几个进程组,可以分成一个前台进程组(foreground process group)和一个或多个后台进程组(background process group);
  • 如果一个会话有控制终端,它就有一个前台进程组,会话中的其他进程组就是后台进程组;
  • 无论何时,敲下终端的中断键(Ctrl+C)或退出键(Ctrl+\),终端驱动程序就会把相应的中断信号发送给前台进程组的所有进程,后台进程组不受影响;
  • 如果终端接口检测到调制解调器(或网络连接)已经断开,内核就会把挂断信号(SIGHUP)发送给控制进程(也就是会话首进程)。

你可以在脑中画一张图:最外层是会话,会话里横着排着几个进程组,其中最靠前、和终端"面对面"的那个进程组叫前台进程组,其余都待在后台。终端的键盘按键产生的信号,只砸向前台进程组;终端断线产生的 SIGHUP,则砸向会话首进程这个"总负责人"。这张图就是后面理解守护进程为什么要点与承重墙。

作业控制

什么是作业和作业控制

作业(job) 是针对用户来讲的:用户为了完成某项任务而启动的进程,就是一个作业。一个作业既可以只包含一个进程,也可以包含多个进程,这些进程相互协作完成任务,最常见的形式就是一个进程管道。比如下面这条命令就是一个作业,它包含两个进程——cat 把 /etc/filesystems 的内容读出来,head 截取前 51 行:

$ cat /etc/filesystems | head -n 51
# 运行结果示例
xfs
ext4
ext3
ext2
nodev proc

关键点在于:Shell 分前后台来控制的,不是进程,而是作业(或者说进程组)。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成。Shell 可以同时运行一个前台作业和任意多个后台作业,这种对作业进行前后台调度管理的机制,就叫作业控制(job control)。

作业号

放在后台执行的程序或命令叫后台命令:只要在命令末尾加一个 & 符号,Shell 就会识别出这是一个后台命令,不用等它执行完就可以立即接收新的命令。后台进程执行完后,还会返回一个作业号以及一个进程号(PID)。

# 在后台启动一个作业,该作业由两条命令(两个进程)组成
$ cat /etc/filesystems | grep ext &
# 执行结果
[1] 2202
ext4
ext3
ext2
 
# 按下回车后 Shell 回显作业结束信息
[1]+  完成                  cat /etc/filesystems | grep --color=auto ext

逐条拆解输出:

  • 第 1 行 [1] 2202:[1] 是作业号,2202 是进程号(PID)。注意一个后台作业是"两个进程"(cat 和 grep),但 Shell 报告的 PID 只是这条管道里某一位代表进程的 PID;
  • 第 2、3、4 行:程序运行的结果,即筛选 /etc/filesystems 中带 ext 的行;
  • 第 5、6 行(你敲回车触发 Shell 刷新提示时出现):依次表示作业号、默认作业标记、作业状态、以及所执行的命令。

关于默认作业:对于一个用户来说,同一时刻只能有一个默认作业(标记为 +),也同时只能有一个"即将成为默认作业"的作业(标记为 -)。默认作业退出之后,那个标记为 - 的作业就会成为新的默认作业。具体标记含义是:

  • +:表示该作业号是默认作业;
  • -:表示该作业即将成为默认作业;
  • 无符号:表示其他普通作业。

作业状态

作业的状态通常有下列几种常见的:运行中(正在前台或后台执行)、停止/挂起(被 Ctrl+Z 暂停,进程还在但暂时不执行)、完成(正常或异常结束,Shell 已经回收)。在 jobs 的输出里,你会看到不同类型作业的当前状态。

作业的挂起与切回

(1)作业挂起

我们在执行某个前台作业时,可以通过 Ctrl+Z 键把它挂起(暂停),挂起后 Shell 会显示这个作业的作业号、状态以及所执行的命令。比如我们写一个"死循环打印"的程序:

// test.c —— 一个无限打印的程序,用来演示作业挂起
#include <stdio.h>   // 引入标准输入输出头文件,printf 声明在这里
int main()           // 程序入口
{
    while (1)        // 死循环,永不退出
    {
        printf("hello\n");   // 拼命往标准输出写 hello
    }
    return 0;        // 这一行永远执行不到,写在这里保证函数有返回值
}

编译运行它,然后敲 Ctrl+Z:

# 编译并运行可执行程序
$ gcc test.c -o test
$ ./test
# 此时屏幕上疯狂滚动 hello,键入 Ctrl + Z 观察现象
^Z
# 运行结果:结果是"作业号 / 默认作业标记 / 作业状态 / 运行的程序信息"
[1]+  已停止               ./test

可以看到通过 Ctrl+Z 挂起之后,这个作业的状态已经变为停止(Suspended / Stopped)。进程并没有被杀死,只是被"冻"住了,随时可以唤醒。

(2)作业切回

想把挂起的作业切回前台,用 fg 命令。fg 后面可以跟作业号或作业的命令名称;参数缺省时,默认把作业号为 1 的作业切到前台执行——更准确地说,是把当前的默认作业(带 + 的那个)切到前台。如果系统当前只有一个后台作业,直接敲 fg 不带参数就能切回来。

# 把刚刚挂起来的作业切回到前台(%%1 指作业号 1 的那个作业)
$ fg %1
# 结果:又开始无限循环打印 hello,说明作业已经回到前台
hello
hello
...

注意命令里那个 %1:在 shell 的作业控制语法里,% 后面跟作业号用来指代某个作业,%% 是"相当于 %+",即默认作业的另一种写法。这里写 fg %1 就是明确指定切回作业号 1 的那个作业。

(3)让后台作业恢复运行但不切前台

如果你不想把它完全切回前台,只想让它"在后台继续跑",可以用 bg 命令。挂起后敲 bg %1,这个作业就转入后台运行。

查看后台执行或挂起的作业

用 jobs 命令可以查看本用户当前在后台执行或挂起的作业。常用参数有:

  • -l:显示作业的详细信息(会多带出一个 PID);
  • -p:只显示作业的 PID。
# 先在后台运行一个 sleep 作业
$ sleep 300 &
# 运行刚才那个死循环程序
$ ./test
# 键入 Ctrl + Z 把它挂起
^Z
# 使用 jobs 命令查看后台及挂起的作业
$ jobs -l
# 运行结果:分别对应 作业号 / 默认作业标记 / PID / 作业状态 / 命令行
[1]-  2265 运行中               sleep 300 &
[2]+  2267 停止                  ./test

你看,sleep 300 & 是运行中的后台作业,./test 是刚被 Ctrl+Z 挂起的停止作业,而 + 标记告诉了我们当前的默认作业是谁。

作业控制相关的信号

前面说到 Ctrl+Z 可以挂起前台作业,其实它的本质是:把 SIGTSTP 信号发送到前台进程组的所有进程,把整个前台作业"踩刹车"。后台进程组里的作业不受影响。在 Unix 系统中,存在三个特殊字符,可以让终端驱动程序产生信号,并把信号发送给前台进程组的所有进程:

  • Ctrl+C:中断字符,产生 SIGINT 信号;
  • Ctrl+\:退出字符,产生 SIGQUIT 信号;
  • Ctrl+Z:挂起字符,产生 SIGTSTP 信号(注意是 SIG,不是写作资料里偶尔出现的错别字 STG——它们的全称是 SG、TSTP 的拼接 Sound 无关,记 SIGINT 这个模式就好)。

终端的 I/O(即标准输入和标准输出)和终端产生的这些信号,都只作用于前台进程组这一年与终端实际相接的进程组。后台进程既不接收这些按键信号,默认也不能往终端写(写会收到 SIGTTOU)。这正是"控制终端 + 前台进程组"这套机制所要保证的基本秩序。

孤儿与僵尸:把进程的"出生与死亡"再讲透

有了上面进程组、会话、终端的铺垫,我们回过头来把两个高频概念——孤儿进程和僵尸进程——彻底讲清楚,因为它们和"父进程会不会回收子进程"这件事直接相关,而它正是守护进程代码里 SIGCHLD 那一行的由来。

孤儿子进程:被父进程"丢下"之后

孤儿进程(orphan) 是指:父进程先于子进程终止,子进程失去了父进程,就成了孤儿。

问题是,一个进程必须有一个父进程(PPID 要列得出来),那孤儿谁来养?答案:操作系统会把孤儿进程"领养",挂到 PID 为 1 的进程名下。在传统 System V 系统里 PID 1 是 init,在现代很多发行版里是 systemd,它们都承担"收养所有孤儿"的职责。被收编之后,孤儿进程的 PPID 就变成 1。

为什么必须收养孤儿?因为如果没人管一个已经死去的子进程,它就会变成僵尸(下一个概念);而 init 长驻系统、永不退出,它会在合适的时机调用 wait 把收养来的子进程的"尸首"回收掉。这就保证了"父进程挂了,子进程不会变成没人收尸的僵尸"。

你可以运行下面命令观察:

# 找一个孤儿进程来观察,比如某个被 init 收养的后台进程
$ ps -eo pid,ppid,pgid,sid,comm | awk '$2 == 1'
# 输出里会出现很多 PPID 为 1 的进程,它们都是被 1 号进程收养的

僵尸进程:死了却还挂在进程表里

僵尸进程(zombie) 是指:子进程已经终止,但它的父进程还没调用 wait / waitpid 来回收它的退出状态。此时这个子进程的绝大部分资源(内存、文件描述符等)已经被释放,唯独在进程表里还留着一个表项——里面存着 PID、退出状态这些信息,这个"半死未清理"的进程就叫僵尸进程。

为什么内核不大方地把表项直接删掉?因为父进程需要知道子进程的退出码:子进程到底正常退出(exit(0))还是崩了(非零退出码)?wait 系列函数就是用来拿这个退出状态的。所以子进程死后,内核不能立刻清掉表项,必须等父进程来 wait 收割。父进程如果一直不 wait,这个僵尸就一直占着进程表里的一行。

僵尸进程的特征和危害要分清:

  • 它已经死了,不能被调度执行,kill 也杀不掉它——对一个尸体发信号没有意义;
  • 它还占着一个 PID。如果僵尸大量堆积,会耗尽系统的 PID 号(/proc/sys/kernel/pid_max 一般默认几万),导致新进程创建失败;
  • 想清理僵尸,只有两条路:让它的父进程调用 wait 回收它;或者让它的父进程退出,这样僵尸被 init 收养,由 init 来回收。

把孤儿和僵尸放在一起对比,是最不容易记混的记法:孤儿是"父先死、子后活",子被 init 收养;僵尸是"子已死、父不收",子赖在进程表里。一个是没爹了,一个是死了没人埋。两者都能通过 PID 1 这个"最终兜底"来化解:孤儿被收养,僵尸被 init 回收。

思考题二:守护进程代码里常见一行 signal(SIGCHLD, SIG_IGN);,它的作用是"忽略子进程退出信号"。这行代码和"避免僵尸进程"有什么关系?

详解:SIGCHLD 是"子进程状态发生变化(通常是终止)"时内核发给父进程的信号。父进程默认收到它却什么也不做,于是父进程如果不主动 wait,子进程就滞留成僵尸。但是我们可以告诉内核,"我不在乎子进程的退出状态,你直接替我收拾干净吧"——做法就是把 SIGCHLD 的处置方式设为 SIG_IGN(忽略)。一旦这样设置,内核在子进程终止时会自动回收它,不再产生僵尸,父进程也就不用(也不能)再用 wait 去收割了。代价是:父进程将永远拿不到子进程的真实退出状态。对于一个只负责"拉起一堆干活进程、不关心它们具体怎么退"的守护进程来说,这个代价完全可以接受,所以守护进程通常都会这么一行。

守护进程:藏在后台的"隐身服务"

什么是守护进程

守护进程(daemon) 是指长期运行在后台、不跟用户交互、不依赖控制终端、也不被会话/终端关停所波及的进程。你熟悉的大部分"服务"都是守护进程:sshd(SSH 服务)、nginx、mysqld、httpd 等等。它们名字里常带一个 d 结尾,既是 daemon 的缩写,也是一种命名惯例。

守护进程为什么必须"脱离终端"?因为只要它还挂着一个控制终端:

  • 终端断线或用户退出登录时,内核会发送 SIGHUP 给控制进程(会话首进程),服务可能被牵连退出;
  • 用户敲 Ctrl+C / Ctrl+Z,会往前台进程组发 SIGINT / SIGTSTP。如果服务恰好在那个前台进程组里,就会被"一个不留神"打断。

一个正常做后台服务的进程,显然不希望因为终端上的某个按键、或一次断线,就意外地退出。所以守护进程的"隐身"是有明确目的的。

守护进程的几个特征

一个"标准"的守护进程,通常具备下面这些特征(前几条和我们的演示强相关):

  • 没有控制终端:通过 setsid 脱离原来的会话与终端,成为新会话的会话首进程,斩断和终端的联系;
  • 父进程被 init 收养:因为守护进程会 fork 出子进程并让父进程退出,于是这个"真正干活"的子进程成了孤儿,被 PID 1 收养,PPID 为 1;
  • 工作目录被更改:通常把工作目录改到 /(根目录),避免占用某个普通目录导致该文件系统无法卸载;
  • 标准输入 / 输出 / 错误被重定向:通常重定向到 /dev/null 或日志文件,防止误读写终端;
  • umask 被设置为合理值:守护进程常把 umask 设为 0,以便创建的文件(比如日志、socket)权限不被系统默认的 umask 意外收紧。

先厘清:nohup 和 & 是一回事吗

写守护进程之前,先澄清两个天天能见到、却总被混为一谈的东西:nohup 和 &。

  • &(后台执行):把作业放到后台,赋予它一个作业号,让它不再占据你的输入提示符。但 & 并没有改变进程"还挂在会话里、还有控制终端"的性质。 当你关闭终端会话时,shell 退出往往会向它还能管理的作业发送 SIGHUP,后台作业一样可能被牵连退出。
  • nohup(不挂断):nohup 是"no hang up"的缩写,它的功能是让进程忽略 SIGHUP 信号,这样即使终端会话关闭,进程收到 SIGHUP 也不会退出。

所以最佳实践是组合使用:nohup 你的命令 &——& 负责让它到后台去,nohup 负责让它对终端的"挂断信号"免疫。& 管的是"后台"这件事,nohup 管的是"忽略 SIGHUP"这件事,两者不是一回事,常搭档出现。

手写一个守护进程

下面我们把上面所有知识揉进一个完整的 C 程序里:手写一个守护进程,并逐行注释、可编译可运行。核心函数就是我们熟悉的 Daemon,但为了让你完整跑起来,我额外加了一个"每秒写一次日志"的业务循环,方便你验证它真的在后台干活。

// mydaemon.c —— 一个完整可编译、可运行、可验证的守护进程示例
#include <stdio.h>     // snprintf 声明,用于格式化时间戳
#include <stdlib.h>    // exit 声明,父进程用来退出
#include <string.h>    // 可选,这里没用到但常配合使用,保留无妨
#include <unistd.h>    // fork / setsid / chdir / dup2 / close / sleep / write 声明
#include <fcntl.h>     // open / O_WRONLY / O_CREAT / O_APPEND / O_RDWR 等常量声明
#include <signal.h>    // signal / SIGCHLD / SIGPIPE / SIG_IGN 声明
#include <time.h>      // time / localtime / struct tm 声明,用于打日志
#include <sys/types.h> // pid_t / ssize_t 等类型定义
#include <sys/stat.h>  // open 时用到文件权限位,如 0644
 
// ---------------------------------------------------------------------
// 制作守护进程的核心函数
// ischdir: 是否把当前工作目录切到根目录(传 1 表示要,0 表示不要)
// isclose: 是否直接关闭 0/1/2 三个 fd(1 是"粗暴关闭",0 是重定向到 /dev/null)
// ---------------------------------------------------------------------
void Daemon(int ischdir, int isclose)
{
    // 1. 忽略两个可能让进程提前"非正常退出"的信号
    signal(SIGCHLD, SIG_IGN);   // 忽略子进程退出信号:避免产生僵尸子进程(见思考题二)
    signal(SIGPIPE, SIG_IGN);   // 忽略管道破裂信号:向已关闭的管道写数据会触发,网络服务常忽略
 
    // 2. 第一次 fork:让父进程退出,子进程继续
    if (fork() > 0)             // fork 返回 > 0 说明当前是父进程
        exit(0);                //    父进程立即退出,返回到 shell 的提示符
    //    到这里开始只有"子进程"在往下走。
    //    子进程的 PID 是新分配的,但 PGID 是继承父进程的,所以:
    //    子进程 PID != 子进程 PGID  => 子进程不是组长进程
    //    这样第 3 步的 setsid 就一定能成功。
 
    // 3. 创建新会话:让自己成为会话首进程 + 新进程组组长,并脱离控制终端
    setsid();                   // 成功返回 SID;失败返回 -1(此时不会失败)
 
    // 4. 可选:把工作目录改到根目录,避免"占着某个目录导致文件系统卸载不掉"
    if (ischdir)
        chdir("/");             // chdir 把进程的当前工作目录改成 / 根目录
 
    // 5. 可选:处理标准输入 / 输出 / 错误
    if (isclose)
    {
        // 方式一(不推荐直接用):粗暴地把 0/1/2 全部关闭
        close(0);               // 关闭标准输入
        close(1);               // 关闭标准输出
        close(2);               // 关闭标准错误
    }
    else
    {
        // 方式二(推荐):把 0/1/2 全部重定向到 /dev/null——“黑洞”设备
        int fd = open("/dev/null", O_RDWR);  // 以可读可写方式打开黑洞设备
        if (fd > 0)                          // open 成功返回一个 > 0 的 fd
        {
            dup2(fd, 0);        // 让 0 号 fd(stdin)指向 /dev/null
            dup2(fd, 1);        // 让 1 号 fd(stdout)指向 /dev/null
            dup2(fd, 2);        // 让 2 号 fd(stderr)指向 /dev/null
            close(fd);          // 关闭拷贝出来那个原始 fd,只留 0/1/2
        }
    }
}
 
// ---------------------------------------------------------------------
// 程序入口
// ---------------------------------------------------------------------
int main(void)
{
    // ischdir 传 1(把工作目录切到 /),isclose 传 0(用 /dev/null 而不是粗暴关闭)
    Daemon(1, 0);
 
    // 下面是用一个"每秒写一条日志"的循环,来扮演守护进程要干的"业务活"
    // 注意:由于 0/1/2 已经指向 /dev/null,我们另开一个日志文件来写内容
    int fd = open("/tmp/mydaemon.log",
                  O_WRONLY | O_CREAT | O_APPEND,    // 只写 + 不存在则创建 + 追加写
                  0644);                            // 文件权限:-rw-r--r--
    if (fd < 0)
        return 1;               // 日志文件都打不开,直接返回失败
 
    while (1)                   // 守护进程一般是个永不退出的长循环
    {
        char buf[64];                       // 用来装格式化后的时间戳文本
        time_t t = time(NULL);              // 取当前时间(从 1970 起的秒数)
        struct tm *p = localtime(&t);       // 转成"年月日时分秒"这种可读的结构
        int n = snprintf(buf, sizeof(buf),  // 把时间戳格式化成字符串,返回写入长度
                         "[%04d-%02d-%02d %02d:%02d:%02d] tick\n",
                         p->tm_year + 1900, p->tm_mon + 1, p->tm_mday,
                         p->tm_hour, p->tm_min, p->tm_sec);
        write(fd, buf, n);                  // 把这一秒的时间戳写进日志文件
        sleep(1);                           // 睡一秒,下一轮再写
    }
    // 理论上是死循环,这里不写 return,是符合现实的(守护进程不主动退出)
}

我把刚才讲到的几个坑在这段代码里都对应上,你回头看会更有"豁然开朗"的感觉:

  • 第一次 fork 脱离对应第 2 步:父进程退出、子进程继承 PGID 却拿到新 PID,于是不是组长,setsid 才可能成功;
  • setsid 成为会话首进程对应第 3 步:一次调用干三件事(成为会话首进程、成为组长进程、脱离控制终端);
  • 重定向 /dev/null对应第 5 步:dup2 把 0/1/2 全指到 /dev/null,就算出错也不会往终端乱打;
  • 改工作目录对应第 4 步:chdir("/") 让进程不占着某个普通目录。

其中第 5 步我又特意给出两种写法:isclose 传 1 是"直接 close",传 0 是"重定向到 /dev/null"。工程上更推荐后者——因为直接 close 掉 0/1/2 后,下一次 open 很可能把新文件描述符分配成 0/1/2(内核总是分配最小的空闲 fd),如果此时库函数里有人假设"0 号 fd 是标准输入"就会出岔子;而把 0/1/2 都指向 /dev/null,它们永远"看起来是合法的标准流",更稳妥。我们演示里 Daemon(1, 0) 正是选了这种推荐写法。

编译、运行与验证

# 第 1 步:编译
$ gcc mydaemon.c -o mydaemon
 
# 第 2 步:运行。父进程 immediately 返回,命令马上回到提示符
$ ./mydaemon
$            # 立刻又出现提示符,说明跑在前台的"父进程"已经退出了
 
# 第 3 步:验证它真变成了"孤儿 + 新会话 + 无终端"的守护进程
$ ps -eo pid,ppid,pgid,sid,comm | grep mydaemon
# 输出示例(数字会不同)
  PID  PPID  PGID  SID  COMMAND
 1337    1   1337  1337 mydaemon

重点看这行的四个数字:

  • PPID 是 1:父进程退出后它成了孤儿,被 init 收养了;
  • PID 和 PGID 相等(都是 1337):它是自己所在进程组的组长;
  • SID 也等于 1337:它同时是会话首进程(新增会话的 ID 就是它自己的 PID)。

三个"相等"全部命中,说明 setsid + 第一次 fork 这套组合完全按预期工作。

# 第 4 步:验证它真在后台"干活"。开着一个终端盯着日志文件
$ tail -f /tmp/mydaemon.log
[2025-11-12 10:00:01] tick
[2025-11-12 10:00:02] tick
[2025-11-12 10:00:03] tick
...   # 每秒多一行,说明守护进程活得很健康

这时你按 Ctrl+C 去看日志——日志依然在涨,因为守护进程不在任何前台进程组里,终端的 Ctrl+C 够不着它。这也是"脱离终端"最直观的体验。

一个更稳妥的进阶:要不要再来一次 fork

到这里,单次 fork + setsid 已经做出一个像样的守护进程了。但严谨的资料往往还会补上第二次 fork,这是很多人在"边界与坑"上栽跟头的地方,值得讲清楚。

第 3 步 setsid 让我们变成了会话首进程。而 POSIX 有一条规则:一个会话首进程如果之后去打开一个终端设备,它可能因此重新获得一个控制终端。也就是说,单次 fork 后的守护进程,如果恰好在后面的业务里 open 了某个终端(哪怕是无意的),可能"重新捡回"一个终端——这跟守护进程"彻底不要终端"的初衷相悖。

解决办法就是再做一次 fork,并让这一次的父进程(也就是 setsid 后的那个会话首进程)退出。于是最后活下来的进程既不是会话首进程(不能重新获得控制终端),又仍是新会话 / 新进程组里的成员。这就是经典的 double-fork 守护进程模式。

// 相比上面的成品,这是加了"第二次 fork"的更稳妥版本(示意)
if (fork() > 0)          // 第一次 fork:父进程退出,为 setsid 铺路
    exit(0);
setsid();                // 创建新会话,成为会话首进程 + 组长,脱离终端
if (fork() > 0)          // 第二次 fork:让会话首进程退出
    exit(0);
// 现在活着的进程:不是会话首进程,也永远不可能是组长(PID != PGID)
// 它不会再获得控制终端,是一个"更彻底"的守护进程

为什么要让"会话首进程"退出?因为"会话首进程打开终端会重新获得控制终端"这个坑,只对会话首进程存在;普通非会话首进程去 open 终端(在会话没有控制终端的前提下)不会被内核强制赋予控制终端。第二次 fork 一劳永逸地避开了这个雷。我们的初级演示只做了一次 fork,完全能满足绝大多数教学与简单服务场景;生产环境追求严谨时,把第二次 fork 加上准没错。

守护进程的启动与关闭

守护进程既然"脱离了终端、躲进了背景",那怎么把它拉起来、又怎么把它关掉?这里有一套固定的操作习惯,也藏着不少坑。

启动:三种打法对比

打法一:作为程序自带的后台化(本课程的做法)。程序自己在 main 里调用 Daemon 完成 fork/setsid/重定向,你在命令行只要 ./mydaemon 一敲,命令立刻返回,而真正的守护进程已经在后台了。我们上面的演示就是这个模式。它的好处是"后台化"这件事由程序自己掌控,用户无感;问题是它不太方便在启动前检查参数、加载配置时往终端打一句"正在启动……"。

打法二:nohup + &。不想改一行代码,纯靠 shell 把任何程序"扔到后台并防挂断":

# nohup 让它忽略 SIGHUP;& 让它进后台;>& 把输出重定向到日志
$ nohup ./mydaemon > /tmp/mydaemon.out.log 2>&1 &
[1] 1371      # nohup 仍返回作业号与 PID

这适合"程序自己没有做 daemon 化"的场景。注意 2>&1 是把错误输出也并进同一个日志,避免错误信息无处安放(nohup 默认会把输出写到 nohup.out)。

打法三:写 init/systemd service 文件。生产环境最正规的做法是交给 systemd 管理(.service 单元),由它负责开机自启、崩溃重启、日志收集。这一块属于"服务部署"范畴,换个专题再展开,这里知道"守护进程化"除了代码层还有系统层这条路即可。

关闭:用 kill 优雅地停

守护进程没有前台作业,自然不能用 Ctrl+C 停它,标准关闭手段是 kill。

# 找到它的 PID(名字过滤一下)
$ pgrep -x mydaemon
1337
 
# 发送 SIGTERM(15 号信号,默认终止信号),恳请它优雅退出
$ kill -TERM 1337
 
# 如果程序没有装 SIGTERM 处理器,默认行为就是退出;再确认一下
$ pgrep -x mydaemon          # 没有输出,说明已经停了,或者直接:
$ ps -p 1337                 # 提示 process not found

关于 kill 有两个细节要记住:其一,kill 默认发的就是 SIGTERM(15),所以 kill 1337 和 kill -TERM 1337 等价;如果程序完全卡死不响应 SIGTERM,才会升级到 kill -KILL 1337(SIGKILL,9 号,内核直接强制干掉,不给清理机会),那是最后的杀手锏。其二,守护进程通常会把自身 PID 写进一个 .pid 文件(比如 /run/mydaemon.pid),方便启动脚本读取后 kill $(cat /run/mydaemon.pid)。我们这里的演示程序没写 pidfile,直接 pgrep 找到 PID 再 kill 即可。

# 完整跑一遍"启动日志 -> 验证 -> 优雅关闭 -> 确认停止"的闭环
$ nohup ./mydaemon >/tmp/out.log 2>&1 &
[1] 1371
$ sleep 2 && tail -n 3 /tmp/mydaemon.log
[2025-11-12 10:05:01] tick
[2025-11-12 10:05:02] tick
[2025-11-12 10:05:03] tick
$ pgrep -x mydaemon && kill -TERM $(pgrep -x mydaemon)   # 找到 PID 再发 TERM
$ ps -p 1371        # 确认:进程已不存在

思考题三:你直接 ./mydaemon 跑起了这个守护进程,然后关掉了那个终端窗口。等下次登录再来看,发现 /tmp/mydaemon.log 还在稳定增长,为什么?如果 mydaemon 当初是没有做 setsid 的"普通后台程序"(只是 ./mydaemon &),关闭终端后它会怎样?

详解:第一个问题:我们的 Daemon 已经在 main 里完成了 fork + setsid,真正干活的进程成了新会话的会话首进程、脱离了终端、且父进程已被 init 收养。终端窗口关闭时,内核给"控制进程"(旧的会话首进程)发 SIGHUP,但我们这个干活进程早已不在那个会话、也没有控制终端,收不到信号,所以安然无恙地继续写日志。 第二个问题:如果没做 setsid,./mydaemon & 只是把进程放到后台,它仍挂在原来那个会话、仍有控制终端。当你关闭这个 shell(终端窗口)时,bash 退出会向它还能管理的作业发送 SIGHUP,这个没有忽略 SIGHUP、也没有脱离会话的进程,大概率就这样被挂断信号带走了——这正是挥手守护进程"必须脱离终端 / 忽略 SIGHUP"的根本原因,也是 nohup 要配合 & 而非单独使用的原因。


到这里,我们从"进程孤岛",一路走到了"进程并不孤单、且可以优雅隐身"。现在往回看,整条线索其实是一条完整的逻辑链:进程组把管道里协作的兄弟进程编成一队,会话把若干进程组收纳成一层更大的容器,控制终端和前台进程组决定了终端的按键与信号砸向谁,作业控制是 shell 在终端上调度这些作业的用户可见玩法,而孤儿与僵尸则揭示了进程在生死之际被谁接管、谁来收尸。最后,一个程序要变成常年潜伏后台的守护进程,靠的恰恰就是这套关系网络:用 fork 让父进程退出、用 setsid 自立门户、用 chdir 换根目录、用重定向把标准流丢进 /dev/null——每一步,都踩在上面每一节的某个知识点上。

这堂课的知识点不是零散的。你亲手编译、运行、用 ps 和 tail 验证过那个守护进程之后,这些概念就扎根了:下次你再写一个需要长期跑的服务、再看到一个 &、再遇到一个怎么 kill 都灭不掉的进程,你会本能地先去想它的父进程是谁、它挂在哪个会话、哪个终端。把"进程间关系"这层网织在脑子里的程序员,排查 bug 的速度和对系统的掌控感,是会明显不一样的。

下一讲,我们会把这张网接到你真正关心的网络编程上:一个 TcpServer 怎么借助守护进程化 + 线程,优雅地同时承担"收请求、处理业务、常驻后台"这三重身份。准备好了吗?