Linux网络编程(十一):守护进程、进程组与会话机制
守护进程(Daemon),是 Linux 操作系统中一种后台服务进程在古希腊神话中,Daemon代表着一种 "守护神" 或 "精灵",它们在暗处默默地履行着守护世界的职责。计算机科学家借用这个概念,来命名那些生存周期极长、没有控制终端、在后台提供服务的进程在 Linux 系统中,守护进程始终在幕后默默工作,为日常操作提供支持。名称通常以字母 "d" 结尾进程名底层职责systemdLinux 系统
目录
一、为什么需要守护进程
在上一篇文章中,我们成功构建了一个具备双层防御机制的高性能网络计算器。通过采用 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 。它只是一个数字的提供者
② 组长进程的死亡,不会导致进程组的解散
为了证明组长进程没有任何内核特权,我们可以来看一个经典案例 —— 组长先死
在多进程协作中,组长进程完全可以因为执行完毕、或者误触信号而提早退出。当组长进程死掉后,会发生什么?
-
PGID 不会改变:剩下的组员进程,它们 PCB 里的 PGID 依然是死去的组长的 PID。内核绝不会因为组长死了,就把所有组员的 PGID 改掉
-
进程组依然存在:只要进程组中还有哪怕一个活着的组员进程,这个进程组在内核的生命周期就依然在延续
-
不会诞生新组长:剩下的组员里,不会去推举出一个 PID 等于 PGID 的新组长。那个死去的组长只不过会变成一个符号而已

核心结论: 组长进程不是 "管理者",它只是一个 "锚点"
四、会话
正是会话机制的存在,让我们的每一次登录、键盘敲击和终端关闭,都能在 Linux 内核中引起一系列连锁反应
1. 为什么需要会话
在多用户、多任务的 Linux 操作系统中,每天都有成百上千的人通过网络的连接进来。 有人用管理员账号在部署 Nginx,有人用普通账号在查日志,有人正准备退出登录
如果系统没有一个宏观的最高级编制,那么当一个用户退出登录时,系统该如何界定哪些进程是他拉起来的?又该如何安全、彻底地把属于他的资源清理干净,而不会误伤正在跑业务的其他用户?
为了实现这种用户级的隔离与统一管理,Linux 引入了会话机制
会话的本质: 会话是一个或多个进程组的集合。它代表着一个完整的人机交互生命周期 —— 从你输入密码成功登录系统开始,到你主动退出或断开连接结束
2. Session 与 SID
当你通过 Xshell 输入 ssh root@ip 成功登录时,,Linux 内核会在后台自动执行以下登录流程:
-
内核会创建一个新的会话(Session)
-
内核启动 bash 程序作为用户的操作界面
-
这个 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 内核会为该进程创建一个全新的独立环境,主要体现在以下三个方面:
-
创建新会话:该进程将脱离原会话(如由 bash 管理的会话),成为新会话的首进程(Session Leader),新会话的会话 SID 即为该进程的 PID
-
创建新进程组:该进程将脱离原进程组,在新会话中创建一个新的进程组并成为组长进程(Process Group Leader),新进程组的组 PGID 同样等于该进程的 PID
-
脱离控制终端:新会话默认不与任何控制终端关联。因此,即使原终端关闭、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 将自立门户:
-
进程 A 成立新会话,新会话的 SID 变成进程 A 的 PID,即 5000
-
进程 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 操作,而不是立即处理业务逻辑
-
父进程(组长):PID = 5000,PGID = 5000
-
子进程: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);
}
// 此时的代码只有孙子进程在执行
当这段代码执行完毕后我们可以看到:
-
会话身份不变:孙子进程依然待在刚才创建出来的新会话里
-
孙子进程的 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 服务背后的核心通信机制

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

所有评论(0)