目录

一、为什么需要守护进程

1. 当前服务器的问题

2. 什么是守护进程

二、作业控制

1. 什么是作业

2. 前台与后台作业

3. jobs 命令

4. fg / bg

5. 作业状态

三、进程组

1. 为什么需要进程组

2. PGID

3. 组长进程的本质

四、会话

1. 为什么需要会话

2. Session 与 SID

3. 控制终端

4. Ctrl+C 与信号

五、setsid

1. setsid 作用

2. 为什么组长不能调用 setsid

3. fork + setsid

六、守护进程

1. 守护进程特点

2. 双重 fork

2. 守护化

重置文件权限掩码

切换工作目录

重定向

七、改造网络计算器

1. 守护进程化步骤

2. 守护进程代码实现 (Daemon.hpp)

3. 集成与测试

总结


一、为什么需要守护进程

在上一篇文章中,我们成功构建了一个具备双层防御机制的高性能网络计算器。通过采用 JsonCpp 实现业务层解析,并设计自定义长度报头方案,解决了 TCP 粘包和半包问题

本篇博客中,我们将深入探索 Linux 操作系统的底层管理机制,解析进程生命周期原理。揭示程序无法在线上持续运行的根本原因,并为我们的网络计算器赋予持久运行的 "守护进程" 能力


1. 当前服务器的问题

我们在上篇实现的服务端程序 CalServer 在测试阶段表现完美。但这里有一个开发中常见的高频场景:

通过终端远程连接 Linux 服务器后,在命令行界面输入 ./cal_server 8080 成功启动服务。此时任务已完成,可以关闭电脑安心下班了

当我们关闭终端窗口,或者网络发生抖动突然断开连接的那一瞬间,线上的网络计算器服务会瞬间崩溃

因为我们的程序此时还依赖控制终端。 在 Linux 系统中,当一个用户登录时,系统会为该用户分配一个会话和控制终端。默认情况下,该终端启动的所有进程都会归属于这个终端会话

一旦你关闭终端窗口,或者网络断开导致 SSH 会话结束,Linux 内核就会向该终端的所有进程发送 SIGHUP 信号。这个信号默认直接终止进程

真正的线上企业级微服务(如数据库、Web 服务器),必须具备 7×24 小时后台独立吞吐的能力。绝不能因为某个研发人员的登出、网线抖动、或者控制终端的关闭而终止

为了让程序独立于用户的登录状态而长久运行,我们必须将其守护进程化


2. 什么是守护进程

守护进程(Daemon),是 Linux 操作系统中一种后台服务进程

在古希腊神话中,Daemon 代表着一种 "守护神" 或 "精灵",它们在暗处默默地履行着守护世界的职责。计算机科学家借用这个概念,来命名那些生存周期极长、没有控制终端、在后台提供服务的进程

在 Linux 系统中,守护进程始终在幕后默默工作,为日常操作提供支持。这类进程的命名遵循一个经典惯例:名称通常以字母 "d" 结尾

我们可以通过 ps axj 命令,在系统里抓出几个典型的守护进程:

进程名 底层职责
systemd Linux 系统的 1号进程(Init 进程)。它是所有其他进程的始祖,负责在系统启动时拉起所有的系统服务
sshd

SSH 守护进程在后台持续监听 22 端口,负责处理所有通过 Xshell 等客户端建立的远程连接请求

crond

定时任务守护进程。每分钟都会准时检查是否有待执行的备份或清理任务到期

mysqld / nginx

数据库守护进程与 Web 服务器主进程独立于用户登录状态运行,只要操作系统保持运行,这些后台服务就会持续处理数据请求

Linux 内核中创建守护进程需要经历四个关键步骤:作业控制、进程组设置、会话创建,最终实现与终端的分离

二、作业控制

在深入研究进程组和会话这些内核概念之前,我们先来看一个我们在 Linux 命令行中天天打交道,却未必真正看透的机制 —— 作业控制

Shell 的作业控制功能是其核心特性之一,它支持用户在单一终端窗口内同时启动、暂停、恢复和切换多个任务


1. 什么是作业

在操作系统的角度,我们最熟悉的是 "进程"。但在用户的使用视角里,Shell 管理的基本单位其实是作业

作业的定义: 作业是指用户为了完成某项特定任务,而提交给 Shell 执行的一个或一组进程的集合

  • 单进程作业:比如 ls -R /,这只是一个简单的命令,Shell 会拉起一个进程来执行,这个进程本身就构成了一个作业

  • 多进程协作作业:比如你执行一条复杂的管道命令:

    cat log.txt | grep "ERROR" | wc -l

    此时,系统会并行启动 cat、grep 和 wc 三个相互独立的进程。然而,Shell 会将它们视为一个整体作业:这三个进程协同工作,共同完成统计错误日志行数的任务


2. 前台与后台作业

在控制终端的会话中,作业可以分为两类:前台作业和后台作业

① 前台作业

当你在终端敲下 ./cal_server 8080 时,这个网络服务器就成了当前终端的前台作业

  • 特征:它将完全接管终端控制权。此时你的 Shell 提示符(如 [root@localhost]#)会立即消失,无论输入什么命令,Shell 都不会做出任何响应

  • 排他性:在同一个控制终端里,在任意一个时间点,能且只能有一个前台作业在运行

  • 信号接收:由于前台作业占据终端,因此你在键盘上按下的 Ctrl+C 或 Ctrl+\,都会被内投递给当前的前台作业,并杀死改进程

② 后台作业

如果你的程序运行需要很长时间,你不想让它占据终端,你可以在命令的末尾加上 "&":

./cal_server 8080 &

此时,这个程序就变成了后台作业

  • 特征:Shell 提示符会立马弹回来,你可以继续在终端里敲击其他命令、丝毫不受影响

  • 并存性:与前台作业不同,后台作业是非常宽容的。同一个终端下,可以有成百上千个后台作业同时在跑

  • 无视信号:由于后台作业没有占领终端,此时即便按下 Ctrl+C,接收到信号的其实 Shell 进程,因此无法通过键盘信号直接杀死后台作业


3. jobs 命令

既然后台进程在后台运行而不可见,我们该如何监测它们的状态呢?可以使用 Shell 提供了 jobs 命令

我们在同一个终端里,先后启动两个网络计算器和另一个长耗时任务,均丢入后台:

上面输出的 [1] 1457237 中,1 代表作业号,1457237 代表该作业中主进程的 PID

此时敲下 jobs 命令,终端会打印出当前会话管理的全部作业状态:

jobs 输出字段解析

  • [1]、[2]、[3](作业号/Job ID):这是 Shell 内部为了方便用户管理而分配的编号,注意它不是操作系统的 PID。在后续的操作中,我们可以通过 %1、%2 来指代它们

  • + 与 -(当前默认作业标记)

    • +(Plus):代表当前默认作业。如果直接输入 fg 不带任何参数,Shell 就会默认把带 + 的作业拉到前台。它通常指代最近一个被放入后台或者最近一个状态发生改变的作业

    • -(Minus):代表次默认作业。如果带 + 的作业死掉了,带 - 的作业会瞬间自动晋升为 +

  • Running / Stopped(作业状态):代表作业当前是在执行,还是处于被暂停挂起的状态


4. fg / bg

在实际开发和运维中,我们经常需要在前后台之间频繁调度作业。Linux 为我们准备了一套前后台转换命令:

Ctrl+Z:将前台作业转换到后台并暂停

假设你启动了一个前台作业 ./cal_server 8080,突然发现它占据了终端导致你无法敲其他命令。你可以直接在键盘上按下:

Ctrl+Z

此时,终端会立马回显:

底层原理:内核在捕捉到 Ctrl+Z 后,会向当前的前台作业投递一个 SIGTSTP(Terminal Stop)信号。程序收到该信号后会被强行挂起(暂停执行),释放终端控制权,并自动变成作业号为  [1]的后台暂停作业

bg:拉起后台暂停的作业

上面的计算器虽然退到了后台,但它现在是 Stopped(暂停)状态,无法接收网络连接。我们要让它在后台重新跑起来,只需执行:

底层原理:bg 命令后面跟上 %作业号,Shell 会向该作业的所有进程发送一个 SIGCONT 信号。程序在后台复活,状态由 Stopped 变回 Running,开始正常提供计算服务

fg:拉起后台作业到前台

如果你想观察某个后台作业的日志输出,或者想用 Ctrl+C 杀掉进程,你可以把它重新切回前台:

fg 命令会把指定的后台作业拉回前台,并重新占据你的终端


5. 作业状态

由此或许可以得出一个结论:在部署线上服务器时,只需在启动命令后添加 "&" 符号,或者使用 Ctrl+Z 配合 bg 命令将其放入后台运行,不就能就能实现无人值守操作了吗?

答案是否定的

正因如此,我们必须在开篇部分重点探讨 "守护进程" 的研究意义。请务必深入领会以下核心观点:

通过 & 或 bg 运行的后台作业,虽然不占用终端,但它依然没有脱离当前控制终端会话

后台作业在内核中的进程仍归属于当前 SSH 登录终端。当你关闭 Xshell 窗口退出登录时,系统将终止整个控制终端。在终止前,系统会进行彻底清理:它不仅会向前台作业发送 SIGHUP 信号,还会向该终端关联的所有后台作业(无论处于运行还是暂停状态)一并发送 SIGHUP 信号

三、进程组

理解了上层的作业控制机制后,我们需要从 Linux 内核的角度重新审视这个问题。对于内核工程师而言,并不存在抽象的 "作业" 概念,取而代之的是一个更加精确的实体——进程组(Process Group)


1. 为什么需要进程组

如果我们执行 cat log.txt | grep "ERROR" | wc -l,系统瞬间拉起了三个进程。如果这时候用户按下了 Ctrl+C,操作系统需要瞬间终结整个任务

  • 如果没有进程组的概念,内核就必须逐个查找这三个进程的 PID,然后分别发送 SIGINT 信号。在这短暂的时间差内,某些进程可能已经创建了子进程,导致清理不彻底,最终引发 "僵尸进程风暴"

  • 通过进程组机制,内核只需将这三个紧密关联的进程绑定到同一个单位中。信号可以基于进程组进行批量传递。当内核发出 "终止PGID为1024的整个进程组" 指令时,该组所有进程将同步终止,实现了高效的批量操作


2. PGID

在 Linux 系统中,每一个进程在诞生时,它的 PCB 里除了记录自己的 PID 和父进程的 PPID 之外,还会记录一个属性 —— PGID(Process Group ID,进程组 ID)

我们可以通过工命令 ps -axj 来观察这一内核属性:

在该核心字段矩阵中,PGID 用于标识进程所属的进程组。当两个或多个进程拥有相同的 PGID 时,系统即判定它们属于同一进程组


组长进程

Linux 内核判定谁是组长的规则非常简单:

如果一个进程的 PID 刚好等于它自身的 PGID,那么该进程就是当前进程组的组长

例如在我们刚刚看到的单进程作业中:

  • 进程的 PID 是 1475504

  • 进程的 PGID 也是 1475504 因为 PID == PGID,所以这个 sleep 进程就是这个单人进程组的组长。如果我们用管道启动了多个进程,那么管道中的第一个进程通常会成为该进程组的组长


3. 组长进程的本质

这是整篇博客中最核心的部分

在学习 "组长进程" 这个概念时,许多人的第一反应是将其想象成一个特权进程。往往会错误地认为组长进程在内核中拥有某种 "超级PCB",或者拥有管理、控制甚至终止组内其他进程的特权

这是一个典型的直觉误区。在 Linux 内核中,组长进程的原理其实非常简单:

① 组长没有任何额外权限

在内核的 task_struct 结构体里,组长进程没有任何特殊的标志位。它和组内的其他普通成员进程在调度、内存分配、权限管理上完全平等。 组长既不能窥探组员的私有栈内存,也不能在没有父子关系的情况下强行 wait 组员。它之所以被称为组长,纯粹是因为在这个进程组建立的那一瞬间,内核借用了它的 PID 作为这个组的 PGID 。它只是一个数字的提供者

② 组长进程的死亡,不会导致进程组的解散

为了证明组长进程没有任何内核特权,我们可以来看一个经典案例 —— 组长先死

在多进程协作中,组长进程完全可以因为执行完毕、或者误触信号而提早退出。当组长进程死掉后,会发生什么?

  1. PGID 不会改变:剩下的组员进程,它们 PCB 里的 PGID 依然是死去的组长的 PID。内核绝不会因为组长死了,就把所有组员的 PGID 改掉

  2. 进程组依然存在:只要进程组中还有哪怕一个活着的组员进程,这个进程组在内核的生命周期就依然在延续

  3. 不会诞生新组长:剩下的组员里,不会去推举出一个 PID 等于 PGID 的新组长。那个死去的组长只不过会变成一个符号而已

核心结论: 组长进程不是 "管理者",它只是一个 "锚点"

四、会话

正是会话机制的存在,让我们的每一次登录、键盘敲击和终端关闭,都能在 Linux 内核中引起一系列连锁反应


1. 为什么需要会话

在多用户、多任务的 Linux 操作系统中,每天都有成百上千的人通过网络的连接进来。 有人用管理员账号在部署 Nginx,有人用普通账号在查日志,有人正准备退出登录

如果系统没有一个宏观的最高级编制,那么当一个用户退出登录时,系统该如何界定哪些进程是他拉起来的?又该如何安全、彻底地把属于他的资源清理干净,而不会误伤正在跑业务的其他用户?

为了实现这种用户级的隔离与统一管理,Linux 引入了会话机制

会话的本质: 会话是一个或多个进程组的集合。它代表着一个完整的人机交互生命周期 —— 从你输入密码成功登录系统开始,到你主动退出或断开连接结束


2. Session 与 SID

当你通过 Xshell 输入 ssh root@ip 成功登录时,,Linux 内核会在后台自动执行以下登录流程:

  1. 内核会创建一个新的会话(Session)

  2. 内核启动 bash 程序作为用户的操作界面

  3. 这个 bash 进程会自动成为这个新会话的会话首进程(Session Leader)

什么是 SID?

就像进程组需要借用组长的 PID 一样,会话也需要一个唯一的身份标识,这就是 SID(Session ID)

会话首进程(通常是 bash)的 PID,会被内核直接采纳为整个会话的 SID

我们再次使用 ps -axj 来查看会话的构成:

观察上面的数据,我们可以发现:

  • 我们启动的 sleep 的父进程(PPID)是 1474912

  • 这个 sleep 所在的会话 ID(SID)也是 1474912

  • 如果去查 1474912 究竟是谁,你会发现它正是当前终端里的 bash 进程

这意味着,你当前终端中运行的所有命令和启动的进程组,其会话 ID(SID) 都指向 bash 进程的PID。在内核层面,这些进程都属于该会话首进程的后代


3. 控制终端

建立会话后,为了实现与用户的物理交互,该会话必须与控制终端建立固定关联。在远程连接场景下,控制终端通常以 /dev/pts/x 形式的虚拟终端呈现

Linux 对标准会话的控制终端实行严格的 "一对一" 管理机制:

  • 唯一的前台进程组:会话中有且只能有一个前台进程组。它拥有对控制终端的绝对控制权。只有它能从键盘读取输入,也只有它能在你按下键盘组合键时,第一时间收到内核派发的信号

  • 多个后台进程组:会话中可以容纳多个后台进程组。它们在后台运行,虽然能向终端打印输出日志,但如果尝试从终端读取输入,内核就会向其发送 SIGTTIN 信号将其强行暂停

注意查看 TPGID 字段。在 ps 命令的输出中,TPGID 表示当前控制终端被哪个进程组占用


4. Ctrl+C 与信号

理解了会话与控制终端的绑定关系,我们就能彻底看懂键盘信号的运作真相了

当你在 Xshell 里按下了 Ctrl+C

这个物理按键的电信号会被控制终端的驱动程序瞬间捕获。驱动程序在内部查表,得知 Ctrl+C 对应的是 SIGINT(Interrupt,中断信号)

内核不会广播这个信号,而是会精准定位当前终端的 TPGID。当发现当前 TPGID 是 xxxxx(即前台进程组)时,内核就将 SIGINT 信号精确地发送到该进程组的所有成员。该进程因此被立即终止

为什么 Ctrl+C 对后台作业无效?

执行./cal_server 8080 命令后,该进程组被切换到后台运行,控制终端的控制权立即交还给 bash 进程。此时,终端的前台进程组 ID(TPGID)变为 bash 的进程 ID,不再指向计算器进程的 PID

当你再次按下 Ctrl+C 时,内核会将中断信号发送给当前拥有 TPGID 的 bash 进程。作为系统 shell 程序,bash 内部已预设了对 Ctrl+C 信号的捕获处理机制,其默认行为是忽略该信号并仅输出一个新行提示符。而后台进程组中的计算器进程由于不持有 TPGID,完全不会接收到该信号,因此能够继续正常运行不受影响

既然无视 Ctrl+C,为什么关闭窗口后台作业还是会终止?

当你关闭 Xshell 窗口时,远端的 sshd 进程感知到了网络连接断开。它知道这个会话已经没有存在的意义了,于是它会开始销毁这个会话

在销毁过程中,系统会向整个会话的首进程(Session Leader,即 bash)发送 SIGHUP 信号。 bash 在销毁前,会遍历自己名下的所有作业(Jobs)表,无论是前台的还是后台的,一律派发 SIGHUP 信号。 即便后台计算器进程组能够忽略键盘信号,最终仍无法逃脱会话终止时的清理

程序与终端之间的强制绑定关系,让我们陷入了困境:只要程序仍在当前会话中运行,就只能随终端的关闭而终止

五、setsid

要让网络计算器与控制终端彻底分离,使其成为不受终端退出影响的守护进程,Linux 提供了一个强大的系统调用 —— setsid

不过,这个系统调用有其特殊限制。如果在不了解其工作原理的情况下直接调用,它不仅不会创建新会话,反而会直接返回 EPERM(权限不足)错误


1. setsid 作用

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

pid_t setsid(void);

返回值:
成功时,返回新会话 ID。出错时,返回 -1,并设置 errno

当一个进程成功调用 setsid 后,Linux 内核会为该进程创建一个全新的独立环境,主要体现在以下三个方面:

  1. 创建新会话:该进程将脱离原会话(如由 bash 管理的会话),成为新会话的首进程(Session Leader),新会话的会话 SID 即为该进程的 PID

  2. 创建新进程组:该进程将脱离原进程组,在新会话中创建一个新的进程组并成为组长进程(Process Group Leader),新进程组的组 PGID 同样等于该进程的 PID

  3. 脱离控制终端:新会话默认不与任何控制终端关联。因此,即使原终端关闭、SSH 连接断开或接收到 SIGHUP 信号,也不会影响该会话中的进程,从而实现了进程的完全独立运行


2. 为什么组长不能调用 setsid

查阅 setsid 的内核文档说明时,你会发现一条不可违背的核心准则:

ERRORS:

EPERM: The process group ID of any process equals the PID of the calling process. (如果调用进程本身已经是它所在进程组的组长,那么 setsid 就会直接报错)

许多初学者对此感到困惑:作为原进程组的组长,为何不能在新会话中自立门户?为何系统要限制组长的这项权利?

我们可以从 Linux 内核的架构来剖析这个原因:

核心真相:一个进程组绝不能跨越两个会话

在 Linux 内核的设计哲学里,空间的层级关系是极其严密的:会话包裹着进程组,进程组包裹着进程。 这意味着,任何一个进程组,它名下的所有成员进程,都必须在同一个会话里。绝对不允许出现 "一个进程组,一半成员在会话 A,另一半成员在会话 B" 的场景

让我们推演一下:如果允许组长进程调用 setsid,可能引发怎样的内核逻辑悖论?

反例:假设允许组长进程调用 setsid

假设在一个控制终端里,用户利用管道启动了两个进程(进程 A 和 进程 B)协同工作:

  • 进程 A:PID = 5000,PGID = 5000。因为 PID == PGID,它是该进程组的组长

  • 进程 B:PID = 5001,PGID = 5000。它是该进程组的普通组员。 这两个进程目前都在 bash 掌管的会话(假设 SID = 4000)中

此时,组长进程 A 在代码里强行调用了 setsid。 如果内核允许它调用成功,根据 setsid 的定义,进程 A 将自立门户:

  1. 进程 A 成立新会话,新会话的 SID 变成进程 A 的 PID,即 5000

  2. 进程 A 成立新进程组,新进程组的 PGID 变成进程 A 的 PID,即 5000

灾难发生了!如果进程 A 带着 PGID=5000 转入新会话(SID=5000),而进程 B 仍留在原会话(SID=4000)却保持 PGID=5000,此刻进程 B 将面临什么处境?此时进程组 5000 实际上已被物理分割——组长进程 A  带着 PGID = 5000 在新会话(SID=5000)中自立门户,而组员进程 B 的PGID 依然是 5000

这种异常情况导致进程组 5000 同时存在于 4000 和 5000 两个会话中,直接违反了 Linux 内核 "进程组不得跨会话" 的核心原则。结果就是,内核的会话管理机制跨组信号广播功能将立即崩溃并陷入死锁状态

内核防护机制:

组长进程的 PID 已经被绑定为了当前旧进程组的 ID。为了防止进程组发生横跨会话导致分裂,Linux 内核硬性规定组长禁止调用 setsid


3. fork + setsid

如何合法地调用 setsid

既然组长无法调用,那么当网络计算器服务启动时,不就自动成为一个单进程组长了吗?这种情况下,我们该如何解决这个困局?

我们可以让程序在启动时,首先执行一次 fork 操作,而不是立即处理业务逻辑

  1. 父进程(组长):PID = 5000,PGID = 5000

  2. 子进程:PID = 5001,PGID = 5000。子进程继承了父进程的进程组 ID,所以它的 PGID 是 5000。但是它自己的 PID 是全新的 5001

此时,由于 5001 (PID) != 5000 (PGID),子进程在内核眼里是非组长进程

接下来,我们在代码里执行分流:

  • 父进程(组长):主动调用 exit 退出。组长退出不会解散 5000 进程组

  • 子进程(非组长):在代码里调用 setsid

由于子进程不是组长,其可以脱离旧会话,带着全新的 5001 编号自立门户,成为了新会话的首进程和新进程组的组长,并脱离了控制终端

代码实现:

#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>

void PerfectDaemon() {
    pid_t pid = fork();
    
    if (pid < 0) {
        std::cerr << "fork failed" << std::endl;
        exit(1);
    }
    
    if (pid > 0) {
        // 父进程(旧组长)在这里直接退出
        // 这一步不仅让子进程变成了非组长从而具备调用 setsid 的资格,
        // 同时也让 Shell 以为当前前台作业已经结束,把 Shell 提示符还给用户
        exit(0); 
    }

    // 自立门户,脱离终端
    pid_t sid = setsid();
    if (sid < 0) {
        std::cerr << "setsid failed" << std::endl;
        exit(2);
    }

    // 3. 再次 fork
    pid = fork();
    if (pid < 0) exit(3);
    if (pid > 0) exit(0); 

    // 此时的代码只有孙子进程在执行
    // 它不是组长,不是会话首进程,脱离了终端,100% 安全
}

目前只完成了一半的工作。一个真正可靠的守护进程,不仅要脱离终端会话控制,还需要在文件系统操作、运行规范以及标准 I/O 流处理等方面做好完善工作。否则,这些未处理的问题仍可能在生产环境中引发严重的连锁反应

六、守护进程

在上一节中,我们通过巧妙组合 fork 和 setsid,成功使进程实现了独立运行。但这仅是守护进程化进程的基础框架

在实际生产环境中,一个完善的守护进程不仅要确保会话独立性,更要能够从容应对操作系统的各种潜在机制。本节将重点剖析为何采用 "双重fork" 方案,同时完善标准流重定向等细节处理


1. 守护进程特点

在深入探讨底层原理前,让我们先明确守护进程的特质:

  • 不占据文件系统:如果服务器从一个挂载的 U 盘或者临时目录启动,它必须主动离开,否则会导致该 U 盘无法被系统安全弹出

  • 不输出日志到屏幕:既然没有终端,它的标准输入、标准输出、标准错误就必须被妥善安置,否则一旦尝试向不存在的屏幕打印,可能会引发内核 SIGPIPE 信号或者导致底层 I/O 崩溃


2. 双重 fork

在很多开源项目的源码中,我们会看到两次 fork。 你可能会疑惑:第一次 fork 为了让子进程变成非组长从调用 setsid,这我可以理解。那为什么调用完之后,还要再执行一次 fork

潜在风险:System V 的控制终端复活规则

在基于 System V 倾向的 Linux/Unix 系统内核中,存在这样一条关于控制终端的规则:

一个脱离了终端的会话,如果它的会话首进程尝试打开一个终端设备文件(例如 /dev/tty、或某个伪终端设备),且打开时没有显式指定 O_NOCTTY 标志,那么系统内核就会将这个终端重新分配给该会话,作为它的控制终端

这意味着,我们在第一次 fork 后调用 setsid 成功自立门户的子进程,虽然暂时没有终端,但因为它是新会话的 Session Leader,它依然有控制终端的可能

一旦后续业务代码中不慎调用了第三方流媒体库,或是误打开了字符设备文件,服务器就会重新绑定终端,导致系统再次面临 SIGHUP 信号的威胁

二次 fork

为了防止这种潜在的风险,工业界演进出了第二次 fork 方案:

// 此时已经成功执行了第一次 fork + setsid,当前进程是新会话的 Session Leader (PID == SID)

pid_t pid = fork();
if (pid < 0) exit(3);
if (pid > 0) {
    // 关键:Session Leader 主动退出
    exit(0); 
}

// 此时的代码只有孙子进程在执行

当这段代码执行完毕后我们可以看到:

  1. 会话身份不变:孙子进程依然待在刚才创建出来的新会话里

  2. 孙子进程的 PID 是全新的,导致 PID != SID。也就是说,孙子进程虽然在独立会话,但它在当前会话不是 Session Leader

根据 Linux 内核规则,一个不是 Session Leader 的普通进程,哪怕去打开终端文件,系统内核也不会给它分配控制终端

  • 第一次 fork:为了让子进程有资格调用 setsid

  • 第二次 fork:为了让孙子进程没资格重新获取控制终端


2. 守护化

重置文件权限掩码

进程在运行中如果需要创建日志文件或配置,会受到继承自父进程 Shell 的 umask 的限制。为了防止某些权限被意外剥夺(比如创建出来的日志文件写不进数据),守护进程一般会主动清空掩码:

umask(0); // 使守护进程在未来创建文件时,拥有最大权限

切换工作目录

守护进程通常随系统启动而长期存在。如果它是在用户的个人挂载目录下启动的,那么该目录就会一直被进程的 task_struct->cwd 引用。导致系统管理员在尝试卸载(umount)该设备时提示 Device busy

所以我们通常将其切换到根目录或特定的系统专属目录:

chdir("/");

重定向

一个进程启动时,默认会打开文件描述符 0 (stdin)、1 (stdout)、2 (stderr)。既然我们已经脱离了终端,那这三个描述符对应的物理屏幕和键盘就已经不复存在了

我们会下意识觉得:直接调用 close(0) close(1) close(2) 不就行了吗

绝对不行! 在 Linux 内核的文件描述符分配规则中,当你打开新文件时,系统会默认把当前最小且未被占用的数字分配出去。 如果把 0, 1, 2 彻底关掉了,紧接着你的网络计算器代码里调用了 socket() 创建了网络通信套接字,内核就会把数字 0 分配给这个网络套接字! 如果此时,你代码里或者调用的第三方库里,有一句打印语句,它们底层默认是向文件描述符 1 或 0 输出数据的。这就会导致原本要打印到屏幕上的日志,直接被当成网络数据传进了客户端的套接字里!直接引发线上严重的协议解析崩溃

为了解决这个问题,Linux 为我们准备了 /dev/null(数据黑洞)。 任何写入 /dev/null 的数据都会被内核直接无情丢弃;而从 /dev/null 中尝试读取数据,会立刻读到 EOF(返回 0)

int fd = open("/dev/null", O_RDWR); // 以读写方式打开
if (fd >= 0) {
    // 利用 dup2 将标准输入、输出、错误重定向到黑洞中
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);
    if (fd > 2) close(fd); // 释放多余的临时描述符
}

七、改造网络计算器

在前述章节中,我们已经完整实现了网络计算器的服务端与客户端逻辑。然而,当前的服务器实现还存在一个关键的缺陷:它是作为前台进程或普通后台进程运行在当前的终端会话之中的。这意味着,一旦关闭了运行该程序的 SSH 终端窗口,操作系统就会向该会话中的所有进程发送 SIGHUP 信号,从而导致服务器进程异常终止

为了保证网络计算器能够长期、稳定地在 Linux 后台运行,我们需要将其改造为守护进程


1. 守护进程化步骤

将一个普通进程改造为守护进程,在 Linux 下有一套标准的系统编程范式。具体步骤如下:

① 忽略可能导致程序退出的信号

服务器在后台运行时,需要忽略 SIGHUP 和 SIGPIPE 。忽略 SIGHUP 可以防止终端退出时对进程造成影响;忽略 SIGPIPE 能够防止在客户端异常断开连接时,服务端因继续写入套接字而遭到系统强制终止

② 调用 fork 并使父进程退出

这是实现守护进程最关键的技术手段之一。调用 fork 产生的子进程会继承 PGID,但会获得一个新的独立 PID。因此,子进程绝对不可能是当前进程组的组长

③ 创建新会话

子进程调用 setsid  后,会发生以下三个重要变化:

  • 该进程将成为一个全新会话的会话首进程

  • 该进程将成为一个全新进程组的组长进程

  • 该进程将正式脱离原本的控制终端,不再受任何终端关闭的影响

④ 更改当前工作目录

通常建议将守护进程的工作目录切换到根目录 /(或其他特定安全目录)。因为如果守护进程在某个挂载的文件系统下运行,该文件系统将因被进程占用而无法被系统成功卸载

⑤ 重定向标准输入、标准输出与标准错误

由于守护进程已经脱离了控制终端,原本的文件描述符 0 (stdin)、1 (stdout) 和 2 (stderr) 将失去实际的对应。因此,标准的做法是打开 /dev/null,并利用 dup2 将标准输入、输出、错误全部重定向到该设备中


2. 守护进程代码实现 (Daemon.hpp)

基于上述步骤,我们将守护进程化的逻辑封装在一个独立的头文件中,提供一个 Daemon 接口:

#pragma once

#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

// 默认的黑洞设备路径
const char* dev_null = "/dev/null";

void Daemon(bool chdir_to_root = true, bool close_fds = true) {
    // 1. 忽略相关信号
    signal(SIGPIPE, SIG_IGN);
    signal(SIGHUP, SIG_IGN);

    // 2. 清除继承自父进程的文件权限掩码
    umask(0);

    // 3. 第一次 fork:让原父进程退出
    pid_t pid = fork();
    if (pid < 0) {
        exit(1); // fork 失败,异常退出
    } 
    else if (pid > 0) {
        exit(0); // 引导该会话的原前台进程退出
    }

    // 4. 建立新会话
    setsid();

    // 5. 第二次 fork:防止守护进程在后续运行中误开控制终端
    pid = fork();
    if (pid < 0) {
        exit(1);
    } 
    else if (pid > 0) {
        exit(0); // 留下的子进程不再是会话首进程
    }

    // 6. 更改当前工作目录到根目录,防止占用可卸载的文件系统
    if (chdir_to_root) {
        int ret = chdir("/");
        (void)ret; // 消除编译器未引用变量的警告
    }

    // 7. 重定向标准输入、标准输出、标准错误
    if (close_fds) {
        int fd = open(dev_null, O_RDWR);
        if (fd >= 0) {
            dup2(fd, STDIN_FILENO);
            dup2(fd, STDOUT_FILENO);
            dup2(fd, STDERR_FILENO);
            
            // 重定向完成后,原 fd 即可关闭
            close(fd);
        }
    }
}

3. 集成与测试

最后,我们只需要在服务端的总装中心 CalServer.cc 中引入 Daemon.hpp。在服务器完成初始化、正式进入 accept 业务主循环之前,调用一次 Daemon() 函数即可

修改后的 CalServer.cc 代码如下:

#include "TcpServer.hpp"
#include "Daemon.hpp" // 引入守护进程化模块
#include <memory>

static void Usage(std::string proc) {
    std::cout << "Usage:\n\t" << proc << " local_port\n\n";
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = atoi(argv[1]);

    std::unique_ptr<TcpServer> svr(new TcpServer(port));

    // 1. 初始化物理套接字
    svr->InitServer();
    
    // 2. 进入守护进程模式,程序将切入后台运行,终端会立即返回
    // 注意:在此之后的标准输出将不再打印到屏幕,而是重定向至 /dev/null
    // 若需观察运行状态,应依赖日志文件或系统日志机制
    Daemon();

    // 3. 驱动服务,在后台独立运行
    svr->Start(CalculatorService);

    return 0;
}

观察运行状态

编译并运行服务端程序后,你会发现终端立即返回了提示符,这说明程序已经成功切入后台。我们可以通过 Linux 系统的 ps 命令来观察该进程的底层状态:

ps -axj | head -n 1 && ps -axj | grep cal_server

在输出的属性列中,你可以观察到以下符合守护进程特征的现象:

  • PID 与 PGID、SID 的关系:该进程的 PID、PGID 与 SID 是完全相等的,这证明它确实是一个独立的会话首进程和进程组组长

  • TTY 字段:该进程的 TTY 字段显示为 ?,这表明它已经脱离了任何物理或虚拟控制终端

  • PPID 字段:该进程的父进程 ID(PPID)变为了 1(即 init 进程或 systemd 进程),说明原本的父进程退出后,它已被系统核心正式收养

总结

综上所述,从 Socket 编程、TCP 通信,到应用层协议设计、序列化与反序列化,再到基于自定义协议实现网络计算器,我们已经逐步完成了从 "网络传输" 到 "网络应用" 的跨越

其中,TCP 负责提供可靠的字节流传输,而应用层协议则负责定义业务数据的组织方式。通过网络计算器项目,我们第一次完整体验了:

请求构建
    ↓
序列化
    ↓
协议封装
    ↓
TCP传输
    ↓
协议解析
    ↓
业务处理
    ↓
结果返回

这一整套现代网络程序的运行流程

与此同时,回顾整个网络计算器项目的架构设计,我们会发现,它实际上已经在代码层面体现出了 OSI 七层模型所倡导的分层设计思想

其中:

Calculate 模块
负责具体业务逻辑处理,
类似于应用层(Application)

Protocol 模块
负责协议组织、序列化、反序列化以及协议解析,
承担了表示层(Presentation)的职责

TcpServer 模块
负责连接管理、请求接收与响应发送,
承担了部分会话层(Session)的职责

而在更底层:

Socket API
负责向应用程序提供网络通信接口;

TCP协议
负责可靠传输;

IP协议
负责网络寻址与路由;

以太网协议
负责局域网内的数据传输。

最终共同构成了:

业务逻辑
    ↓
协议解析
    ↓
连接管理
    ↓
   TCP
    ↓
   IP
    ↓
Ethernet

这样一条完整的数据通信链路

虽然现代互联网实际采用的是 TCP/IP 五层模型,但通过这个项目,我们已经能够清晰地看到:OSI 七层模型并不是书本上的抽象概念,而是真实地体现在每一个网络程序的架构设计之中

至此,我们已经完成了从网络基础、Socket 编程、自定义协议设计,到应用层业务开发的第一阶段学习。而进一步思考会发现,现实世界中的浏览器、搜索引擎、视频网站、电商平台等系统,并不会使用我们自己设计的协议进行通信

它们所使用的,是互联网历史上最成功、应用最广泛的应用层协议之一:HTTP 协议

在下一篇中,我们将正式进入 HTTP 协议的学习,深入理解浏览器访问网页时究竟发生了什么,以及现代 Web 服务背后的核心通信机制

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐