目录
[那如何查看作业 (后台作业) ?](#那如何查看作业 (后台作业) ?)
[pts/ 和 SID](#pts/ 和 SID)
[五、守护进程化 HTTP 网络服务](#五、守护进程化 HTTP 网络服务)
[setsid() 系统调用](#setsid() 系统调用)
[方法一 : 手搓](#方法一 : 手搓)
[Daemon.hpp 文件](#Daemon.hpp 文件)
[第二种方法 : daemon() 系统调用](#第二种方法 : daemon() 系统调用)
一、前后台进程组
在 Linux 进程管理体系中,我们首先可以依据是否占有终端键盘输入的控制,来划分前台进程与后台进程。同一个终端会话里,同一时刻只能存在一个前台进程,只有前台进程可以响应键盘输入、输出打印信息;而后台进程可以同时存在多个,它们不会抢占终端控制权,在后台静默运行,不会阻塞当前终端操作。

当我们在终端单独启动 sleep 1000 进程时,通过进程查询指令可以看到,该进程的 PPID 父进程 ID 指向当前终端 bash 进程,说明 bash 是它的父进程。同时这个独立进程会自己形成一个专属进程组,Linux 规定单个进程默认自成进程组,因此它的进程组 PGID 就等于自身的 PID 编号。

当我们一次性批量启动 sleep 1000、sleep 2000、sleep 3000 三个进程时,这三个进程同源创建、互为兄弟进程 ,会被系统划分到同一个进程组当中。
Linux 默认约定:进程组的 ID 叫做 PGID ,这个 PGID 等于该组内第一个启动进程的 PID,因此这三个进程属于同一个进程组,共享同一个进程组 ID。同时这三个兄弟进程的父进程 PPID,依旧全部指向终端 bash 进程,父进程统一、进程组统一,体现出了 Linux 父子进程、进程组之间严谨的层级关系。
前台进程组
我们也把上面的进程组叫前台进程组,之所以叫前台进程组,因为它可以响应终端键盘信号。当我们在键盘上按下 Ctrl+C 时,系统会向前台进程组发送终止信号,组内所有进程都会同步收到信号并退出。
并且 Linux 系统对前后台进程的管理,是以进程组为单位进行管理的,而非单独的某一个进程。我们使用 Ctrl+C 结束程序的操作,本质并不是只杀死前台那一个进程,而是向那个前台进程组发送终止信号,组内所有成员进程都会同步响应信号、一并退出运行。
当单个进程独立占用一个进程组的情况时,我们就叫这个进程自成进程组 。我们在终端单独启动单个 sleep 程序时,就属于这类场景,进程 PID 与进程组 PGID 一致。同样即便只有一个进程,Ctrl+C 依旧是以进程组为单位发送终止指令,让整个进程组同步退出。
另外,父子进程也是一个进程组,父进程是组长。
后台进程组
上面我们讲了前台进程组,那什么是后台进程组呢? 后面我们统一进程组,因为单个进程也是进程组。
启动一个后台进程组就是在后面加上**&** 。
如上我们就启动了一个后台进程组,这个组内有三个进程 sleep 1000 / sleep 2000 / sleep 3000。
我们再启动一个后台进程组 sleep 4000。
这时我们再用 ps ajx 查看时就显示出了刚才启动的两个后台进程组,标识它们是后台进程组的标记就是它们的 STAT 是 S,S 是后台进程的标志,S+ 则是前台进程的标志。
我们再启动一个后台进程组,此时我们一共启动了 3 个后台进程组,它们的 STAT 都是 S,并且PID,PPID,PGID 都有严格的对应关系。并且同一个终端会话,只能同时存在1 个前台进程组 + N 个后台进程组,互不干扰。
二、作业/作业号
那这里的 [1] [2] [3] 又是什么? 每创建一个后台进程组就会有对应的数字,这些数字是什么?
像 [1] [2] [3] 这样的数字叫作业号。
那进程组和作业号又是什么关系?
我们可以把进程组和作业号理解为是一个硬币的两面,二者的关系就是作业是要由进程组来完成的,作业侧重是什么,进程组侧重怎么做。后面我们可以把二者高度统一, 也就可以分为前台作业和后台作业。因此前面我们启动多个 sleep 就是启动多个作业,这个作业就是由进程组来完成的。**启动一个作业就是启动一个进程组,一个作业就对应一个完整的进程组,**进程组与作业号是一一对应的绑定关系,在内核底层是以进程组的视角;而作业与作业号用户的视角。
并且我们知道 bash 本身就是一个进程,因此 bash 本身就是一个作业,终端所有进程调度、作业管理,都围绕 bash 来运行。
当终端空闲没有其他的前台进程组时,bash 此时就作为前台进程组 (Ss+) 掌控终端,其余 sleep 进程不带 +,均为后台作业;同一个会话永远只允许一个前台进程组,但是当我们使用 fg 将后台 sleep 切换至前台后,+ 标识会转移给 sleep 进程,bash 就会自动让出前台权限、切换为后台进程,等待前台程序结束后,再重新拿回前台权限变回 Ss+。
那如何查看作业 (后台作业) ?
使用 jobs 命令,jobs 命令用来查看当前终端会话中所有前后台作业 (进程组) 的状态与信息。
那如何将后台作业提到前台呢?
想要将指定后台作业切换到前台运行,需要使用 fg + 作业号指令。(front ground)
执行后对应的后台进程状态会从 S 变为 S+,成功成为终端唯一的前台进程组;
与此同时原本处于前台的 bash 进程,状态会由 Ss+ 变为 Ss,变为后台作业。
但是我们 Ctrl + C 后就相当于终止了这个 sleep 4000 的这个前台进程了,也就是说我们先把 sleep 4000 这个进程组提到前台,然后再终止这个前台进程组。
所以如果有任务是后台作业,我们想把它杀掉,就可以先通过 fg 将它切换到前台,再用 Ctrl + C 终止掉这个进程。
那如果要把前台进程再变回后台呢?
如果需要把正在前台运行的进程退回后台继续运行,就可以先按下Ctrl+Z 暂停前台作业,再使用bg + 作业号指令,bg 的作用就是让暂停的前台作业转入后台恢复运行,作业状态也会变回后台进程标识。同一个终端始终只会存在一个前台进程组,所有前后台作业的切换,都严格遵循这一底层规则。
命令总结
&:在命令末尾加上 &,即可直接将进程组作为后台作业启动,不占用终端前台交互。
jobs:查看当前终端会话中所有前后台作业(进程组)的状态、作业号与运行命令。
fg + 作业号:将指定的后台作业切换到前台运行,使其成为当前会话的唯一前台进程组,状态标记会同步从 S 变为 S+。
Ctrl + C:向前台进程组发送终止信号,直接结束当前正在前台运行的作业。
Ctrl + Z:将前台作业暂停并转为后台 Stopped 状态,同时将终端前台权限还给 bash,让 bash 重新接管交互。
bg + 作业号:让 Ctrl + Z 暂停过的前台作业切换为后台作业,在后台继续执行,不占用终端前台。
pts/ 和 SID
观察上图,最上面一行的 TTY 对应的 pts/0、pts/1 是什么?
pts/0、pts/1本质上就是一个独立的设备文件,Linux 会把每一个终端窗口都当成一个独立的设备文件。当只有 1 个终端窗口时就只有一个 pts/0,当有 2 个终端窗口时会多一个 pts/1,以此类推。并且所有的 pts/0 1 2... 底层都会指向同一个屏幕显示器文件,负责终端里字符的输入和打印输出。而上面我们启动的所有的 sleep 后台作业都是在同一个终端窗口启动的,所以它们 TTY 字段完全一致。
比如我们在第一个终端 /dev/pts/0 里,向另一个终端窗口 /dev/pts/1 写入内容,数据就能直接在第二个终端窗口屏幕上显示出来,这就说明所有 pts 终端本质都是共用同一个屏幕显示设备,所有进程的字符输入输出,都会通过对应的 pts 文件流转到同一个显示器界面。
再观察我们可以发现这些进程的的 SID 都一样,SID 又是什么 ?
SID 全称 Session ID,是进程的会话 ID ,同一个终端里所有进程组、所有前后台作业,SID 全部相同,代表它们归属于同一个终端会话。 这和我们前面 HTTP 网络里的 Session 会话不一样,二者没有任何关联。但二者逻辑性质十分相似:HTTP 会话代表一次完整的网页访问交互流程,Linux 进程会话代表一次完整的终端登录交互流程 ,我们登录终端的全过程,本质就是系统构建一个专属进程会话、分配唯一 SID 的过程。
sshd
sshd 是什么?
sshd (Secure Shell Daemon) 是 Linux 系统中一个重要的后台守护进程,就和我们之前的 HTTP 服务逻辑一样,sshd 一直常驻在 Linux 后台等待别人远程连接,就像 HTTP 服务器一直等着浏览器发来请求一样。
这里介绍两个配置文件 /etc/passwd 和 /etc/shadow :
当我们用 Xshell 远程登录服务器时,sshd 服务就会先去读取**/etc/passwd 这个文件,里面存着系统所有用户的账号、家目录、登录默认 Shell 这些基础账号信息** 。紧接着它再去读取
/etc/shadow 文件,这里安全存放着所有用户加密后的登录密码。
之后 sshd 就会把我们输入的账号密码,和这两个文件里保存的信息一一比对认证。账号密码核对无误、认证通过之后,Linux 就会自动给我们开启一个专属 bash 终端进程,我们的用户家目录、终端环境也都会提前配置完毕,我们就能正常操作终端、管理前后台作业与进程了。
三、会话
再账号密码经过 sshd 认证成功之后,系统就会为我们启动一个专属的 bash 进程。紧接着这个 bash 会在 Linux 的 /dev 目录里打开一个字符终端设备文件,这个文件就是我们之前说的 pts/... 公用的显示器输入输出文件。
这个文件和网络驱动挂钩,因为我们是远程 SSH 登录,终端里所有键盘输入、屏幕打印内容,都要通过这个文件走网络传输交互,只不过这里的底层网络驱动细节我们暂时不用深究。我们只需要知道,bash 就是靠着这个终端文件,和我们的 Windows 窗口来回读写数据,完成命令的输入、打印输出 IO 交流。
我们把bash 进程 + 它绑定的这个终端字符文件,打包在一起,就统称为一次用户登录会话。每一次有人远程登录服务器,都会新建一套独立的 bash + 专属终端文件,诞生一个全新、互相隔离的会话,不同用户、不同窗口的会话彼此独立,互不干扰。
所以服务器上一肯定会同时存在很多个登录会话,内核想要有序管理这么多会话,就用专门的内核数据结构来记录每一个会话,用 SID 会话 SID来区分标记每一个独立会话。这里会话底层的内核数据结构细节我们暂时不深入研究,只需要记住 SID 就是会话唯一标识,同一个会话里所有进程 SID 永远保持一致就可以。

在一次终端登录会话里,第一个被启动的进程就是 bash ,它是整个会话的老大,也叫会话首进程,因此 bash 进程自身的 PID 编号,就是当前整个会话的 SID 会话 ID,上图中所有 sleep 进程 SID 完全相同,也都和 bash 的 PID 一模一样,刚好就能证明这一点。
并且我们每断开一次连接、重新远程登录服务器,系统就会新建一个全新的独立会话,同时启动一个全新的 bash 进程,所以每一次登录对应的会话 SID 都不会重复。
我们怎么证明上面说的呢?
想要直观证明这个逻辑也特别简单:我们每复制 SSH 渠道多打开一个新的 SSH 终端窗口,系统就会多出一个对应的 pts 终端文件、同时新建一个 bash 进程;反过来关闭一个终端窗口,对应的 bash 进程就会销毁、pts 文件也会消失。bash 数量和 pts 终端文件数量永远一一对应,一个终端窗口就绑定一个专属 bash、一个独立会话 ID,就印证了登录、会话、bash 三者绑定的关系。
bashwhile :; do ps ajx | head -1 && ps ajx | grep bash | grep -v grep; ll /dev/pts ; sleep 1; echo "##########"; done我们可以使用上面的命令来验证具体的过程。
所以当我们登录成功启动 bash 进程后,我们在终端里执行命令创建的所有进程、作业,本质全都是在当前这个会话内部生成的,三者是层层包含的从属关系:一个会话可以同时包含多个相互独立的进程组(也就是作业),而这个会话里最少一定会存在 bash 这一个基础作业 / 进程组。同一个会话里严格遵循规则:永远只能有1 个前台进程组,却可以同时存在多个后台进程组。
还有这个字符终端文件,也就是我们之前一直说的 pts/... 公用的显示器输入输出文件。不管是前台还是后台进程组,组内所有进程都拥有向这个文件写入数据的权限,所以前后台进程都能在终端屏幕上打印输出内容。但读取键盘输入的权限是独占的,只有前台进程组,才有资格从这个字符终端文件里读取用户输入,后台进程无法抢占键盘交互。
因此我们用 fg、bg 切换前后台作业的操作,底层本质并不是单纯调换前后台顺序,而是内核在交接这个终端字符文件的读写控制权,把读取终端输入的专属权限,在前后台进程组之间来回转移。
所以 bash 所在的整个会话里,所有新启动的程序作业,都运行在当前会话内部。我们执行短指令、快速就能跑完、需要实时看交互反馈、要用键盘输入操作的简短程序 ,就直接以前台方式启 动;而那些运行耗时很长、长时间等待、不需要手动键盘输入交互的任务,就加上 & 放到后台运行。
四、注销会话
前面我们讲了登录的相关原理,那如果是用户退出或注销 Xshell 登录的场景呢?
这里我们把退出登录和注销 当成同一个操作,那它的本质就是关闭当前的这个终端会话 。
那会话关闭之后,原本在这个会话里面运行的前后台进程组、前后台作业,又会怎么样呢?
在 Linux 系统里,就算注销 bash、关闭对应的字符终端设备文件之后,这些进程也不一定会立刻死掉消失,但只要所属的会话被销毁,它们一定会受到影响。不管是前台任务还是后台任务,都可能受到影响,轻则进程父子关系错乱、进程工作路径失效出错,重则直接异常终止、无法正常继续运行。
也正因如此,我们平时在终端会话里启动的所有程序,都依附这个会话存活。而我们之前编写过的 HTTP 网络服务,对于这个服务器本身而言,本身就是一个需要长期持续运行、一直等待客户端连接的程序,它同样也是在用户登录后的 bash 会话里启动运行的。这就意味着这类网络服务,天生就绑定在了用户终端会话上,只要用户断开 Xshell或退出注销,会话就会消失,正在运行的网络服务就一定会跟着出问题,无法对外正常提供网络服务。
在 Windows 中的原理和 Linux 也大致相似,用户开机输入账号密码登录时,系统会校验账号密码,同时为用户新建一个登录会话,还会加载一个专属的用户桌面配置文件。我们在电脑上打开的所有软件、下载安装的程序、运行的任务,都依托这个桌面文件。但当后台进程堆积太多,电脑变得卡顿的时候,我们除了重启整台电脑,也可以选择注销账号。这个注销操作,本质就是关闭销毁当前会话,会话内绝大多数正在运行的进程、后台任务都会被强制终止,相当于重置当前用户的会话环境,重启一套会话。但是和 Linux 不同的是,Windows 没有强制统一会话的销毁规则,没办法保证注销会话后关联的进程是否被正常清理干净,还是依旧残留在系统里运行,所以无法确定注销操作一定会影响会话内的进程。
五、守护进程化 HTTP 网络服务
也正因如此,我们接下来的目标,就是让我们自己的这类 HTTP 网络服务,避免用户退出登录或注销会话的所带来的影响,也就是说无论用户什么时候打开和关闭 Xshell,登录或退出服务器,我们的 HTTP 网络服务都能正常运行,让服务可以一直在 Linux 后台持久稳定工作。也就是我们本篇文章的重点 : 将这个 HTTP 网络服务守护进程化

所以我们接下来要做的就是对我们之前编写的 HTTP 网络服务守护进程化,核心操作就是把 HTTP 网络服务从原本绑定的终端会话中剥离出去,让它自己单独成为一个全新独立的会话。
当 HTTP 服务拥有了专属独立会话之后,它就再也不属于用户的 Xshell 登录会话了,不管我们什么时候登录、退出、注销服务器,或是原来的用户会话怎么关闭销毁,都不会影响到这个独立会话里的网络服务,也就不会出现进程异常或服务中断的问题,从而也就可以实现长时间稳定持久的运行。
这个让进程脱离原有会话、自立全新会话的操作,本质是Linux 内核层面的进程会话管理的问题 ,但因为我们绝大多数时候都是用来保障 HTTP 这类网络服务长期可用,所以它和网络开发的场景紧密相关,同时也是网络服务部署的核心。
六、编码
在明白了具体的原理之后,我们就开始进行守护进程化的编码了 :
setsid() 系统调用
这里我们先介绍一个重要的系统调用 setsid(),这个系统调用是把 HTTP 服务改成守护进程的关键。
setsid() 是 Linux 内核提供的系统调用,它的功能就是创建一个全新独立的会话,同时设置当前进程对应的进程组 ID,刚好完美实现我们想要的把进程从原有用户登录会话里剥离出来,自立一个专属新会话。
它的函数格式是 pid_t setsid(void),不需要传入任何参数;调用成功时,会返回新会话的 SID 会话 ID;调用出错时,会返回-1,同时通过 errno 变量标记具体的错误原因。
同时要注意的是:setsid() 只会帮进程新建独立的会话,并不会自动创建 bash 进程、也不会绑定新的终端字符设备文件。而我们登录时终端里的 bash 会话,是 sshd 服务通过fork()+exec bash这套流程创建出来的,和 setsid 单独建会话不是一个逻辑。
但是 setsid() 系统调用有一个硬性限制:调用这个系统调用创建新会话的进程,不能是一个进程组的组长。
那我们怎么绕开这个限制呢?方法特别巧妙:我们先用 fork() 创建一个子进程,然后直接让父进程退出。父进程原本是进程组组长,它消失之后,新生成的子进程原本就不属于进程组的组长,完美满足了 setsid 的调用条件,接下来就让这个子进程去执行 setsid 新建独立会话就可以了。
通过这种方式创建出来的脱离了终端会话、自成新会话的进程,就是守护进程,也叫精灵进程 (daemon process)。它本质上是一种特殊的孤儿进程,因为 fork() 之后它的父进程就主动退出了,也就没有了父进程的管控,同时还脱离了原来的终端登录会话。
就像我们之前看到的 sshd 服务,名字末尾带的 d,就是精灵 daemon 的缩写,代表它是一个守护进程,能不受用户登录注销影响,一直在后台稳定运行网络服务。
总结 :
我们再整体总结一下上面的内容 : 我们想要改造自己的 HTTP 网络服务,核心诉求就是让它不受用户登录、退出、注销终端这些操作的影响,稳定的在后台运行。而服务原本依附于用户的登录会话里,只要这个会话关闭,服务就会终止。所以解决办法就是让这个网络服务脱离原本的用户会话,通过 setsid() 系统调用,给自己新建一个完全独立的全新会话。成功自立新会话之后,这个进程就彻底和原来的终端环境解绑了,所以我们把这个脱离终端、独立运行在后台、自成专属会话的特殊进程,就叫守护进程(也叫精灵进程 daemon)。
我们再补充两个关键细节:
第一个问题:进程组组长进程退出了,这个进程组还存在吗?
答案是依然存在。我们一定要分清:进程组 ≠ 组长进程。只要这个进程组里还有其他子进程存活,每个进程的 PCB 进程控制块里,都保存了所属进程组的信息,那么这个进程组就不会消失。只有当组内所有进程全部结束退出,没有进程留存时,这个进程组才会彻底销毁、消失。
第二个问题:进程组到底什么时候才算彻底消失?
结合上面的规则就很好理解:一个进程组,会一直存活到组内最后一个进程退出结束为止。不管最先退出的是不是组长,只要组里还有进程在运行,进程组的内核信息就一直保留;只有所有进程全部死亡,内核才会回收这个进程组的全部数据结构,这个进程组才算真正消失。
我想把我们之前写的 http 服务守护进程化 现在开始编码
写代码
其实这里守护进程化有两种方法 :
第一种方法就是我们自己手搓一个进程守护化的代码,用的就是我们上面讲的 fork() + setsid() 的思路。
第二种方法就是直接调用 Linux 内核给我们封装好了的现成的系统调用 daemon() ,用一行函数就能一键完成守护进程化,不用我们自己写 fork()、setsid() 等一堆步骤。不过这个系统调用的接口底层逻辑比较抽象,一上来直接讲很难理解原理。
所以我们就先采用第一种手搓的方式,搞懂守护进程每一步底层原理之后,再学习第二种 daemon() 快捷的系统调用接口。
方法一 : 手搓
我们手搓守护进程的方式很简单,先单独新建一个 Daemon.hpp 头文件,把一整套守护进程化的完整逻辑,全都封装在一个 Daemon() 函数里面。之后只需要在程序入口 main 函数的开始调用这个函数就可以。

调用这个函数的时候,最开始还是原来的父进程在执行代码。父进程走进函数内部,先做完信号忽略处理,紧接着执行 fork 创建子进程。创建完子进程之后,父进程就会立刻退出,到此为止父进程的使命就全部结束了。所以接下来所有的代码、我们 HTTP 网络服务的全部逻辑,就全都是刚刚 fork() 出来的子进程在独自运行了。
Daemon.hpp 文件

首先 Daemon() 函数的第一步是忽略 SIGPIPE (13 号) 和 SIGCHLD (17 号) 两个信号,为什么?
-
首先 SIGPIPE 13 号信号,网络服务向已经断开的管道 / 网络连接写数据时,系统会触发这个信号,默认会直接杀掉进程。HTTP 服务很容易遇到客户端异常断开连接,忽略这个信号,就能避免服务无故崩溃退出。SIGCHLD 17 号信号是子进程退出时,内核会给父进程发送这个信号。守护进程不需要管理子进程状态,不忽略的话会频繁被信号打断业务逻辑,还会产生僵尸进程,影响服务稳定。这里我们要清楚的是 fork() 创建子进程后,子进程就会完全继承父进程的信号处理方式。父进程提前设置好忽略这两个信号,子进程天生就继承这个规则,就不需要重复再写一遍忽略信号的代码。
-
第二步就是 fork() 创建子进程,父进程直接退出,这一步是为了满足 setsid() 的调用规则:进程组组长不能调用 setsid 创建新会话。原本运行 main 的父进程,就是自己进程组的组长,没法直接新建会话。所以我们 fork() 出一个全新子进程,子进程不属于任何进程组组长,符合调用条件;紧接着父进程立刻 exit() 退出,不再参与后续流程,把所有后续工作全部交给子进程处理。
-
第三步就是子进程调用 setsid (),创建全新独立会话。子进程顺利执行 setsid 系统调用,给自己新建一个完全独立的会话。自此这个进程和原来用户登录的 bash 终端会话解绑,不再归属原来的会话 ID,就算后续用户退出、注销 Xshell、关闭终端,也不会影响到这个进程,是守护进程最核心的会话剥离操作。
-
第四步是关于日志打印和代码中的出现的一些打印语句的修改,首先我们先处理守护进程的日志改动问题。原本我们的程序日志默认打印在终端屏幕上,但守护进程会脱离 Xshell 终端独立运行,一旦用户关闭登录窗口,终端就会消失,屏幕日志就会无法查看。因此我们要把日志策略改成写入本地磁盘文件,这也是企业级网络服务的通用做法,就算终端关闭、用户上下线,日志也能长久保存,方便后续排查服务故障。
接着我们处理代码里残留的 printf、cout 这类终端打印语句。这类语句没办法跟着日志一起写入磁盘文件,又不能留在终端里输出,所以 Linux 给我们提供了 /dev/null 黑洞特殊文件来统一处理。所有往 /dev/null 里写入的数据,都会被系统直接丢弃销毁,不会输出到其他地方,我们只需要把程序标准输入、输出、错误重定向到这个文件,就不用逐行删除、修改源码里所有零散的打印语句,大大简化守护进程改造工作量。
很多人都会疑惑,既然打印内容都会被 /dev/null 丢掉,看不见任何信息,那为什么不直接删掉所有打印代码?原因很现实:线上服务不能随意删除原生打印语句。这些调试打印在开发、排错阶段至关重要,删掉代码会破坏程序逻辑、后续出问题无法快速定位 bug;而重定向到黑洞文件,既能保留完整源码、不改动业务逻辑,又能让守护进程脱离终端后,不会因为无效终端打印频繁报错崩溃,是 Linux 行业标准的稳妥写法。
那我们可不可以直接关闭 0、1、2 号标准文件描述符呢? 因为这三个文件描述符都和显示器打印有关,我们 close 关掉的话不久避免了打印吗? 这里的答案是关闭它们确实能停止屏幕打印,但强烈不建议这么做,因为很多系统函数、第三方库、日志接口,默认都会使用这三个 fd,强行关闭后程序运行会频繁出现报错、异常崩溃。而我们把 0、1、2 重定向到 /dev/null,既保留了合法的文件描述符、程序不会报错崩溃,又能让所有终端打印全部被系统丢弃,完美适配守护进程。同时企业后端项目,全靠磁盘文件日志排查问题、用打印语句调试线上异常,绝对不能粗暴关闭标准 IO,必须用重定向黑洞文件的标准方案来处理终端输出。
5. 部署问题
其实真正企业级的 Linux 网络服务项目,都是模块化部署的结构,各个资源文件不会堆在同一个文件夹里。举个例子 : 我们的 HTTP 可执行程序一般放在 /usr/bin 目录、服务配置文件放在 /etc/conf 相关目录、运行日志文件统一存入 /var/log 目录,网页资源文件也会放在专属固定路径下。上线部署的时候,我们会先把整套程序和所有资源打包,在服务器指定位置解压,再按照 Linux 系统规范,把不同类型的文件拷贝到对应标准目录里。 就像我们把 HTTP 服务程序放进 /usr/bin 之后,不管在终端哪个文件夹,都能直接指令启动服务,程序也能按照固定规范,去各个专属目录查找自己需要的配置、日志、网页资源。
注意 /usr/bin 不是根目录,也不是用户主目录,Linux 唯一的根目录只有 /,/usr/bin 只是根目录下专门存放系统指令的目录,我们把我们的 HTTP 服务器放到这个目录里,就可以像系统自带命令一样,在终端任意位置直接敲击指令启动程序。
并且只要子进程成功调用 setsid(),守护进程就立刻创建了属于自己的全新独立会话,和原来用户登录会话分离开来。普通的程序运行启动时,它的路径起点是我们敲命令启动程序的那个用户文件夹,但守护进程没有绑定任何终端、没有固定的用户启动目录,它的工作路径随时可能不受控制地变化,根本没办法用相对路径稳定找到文件。
所以线上正式运行的 HTTP 服务器,所有配置、日志、网页资源的文件访问,全部都要用从根目录 / 开头的绝对路径来写。以永恒不变的系统根目录作为统一路径基准,不管守护进程在哪运行、什么时候启动、有没有用户登录服务器,都能精准找到散落在 /etc、/var/log 各个模块化目录里的所有资源,不会出现路径错乱、文件找不到的致命 bug。相对路径只适合我们本地调试开发,守护进程正式部署上线,就必须全程使用根目录开头的绝对路径。
- 所以下一步就是更改守护进程的工作路径在根目录下,让守护进程工作在根目录下,再把相关的资源文件配置到特定的目录下。
这里最开始小编混淆了部署对象,上线部署根本不需要把源码、hpp 头文件传到服务器拆分存放,头文件和 cc 源文件只在本地编译阶段使用 ,所有源码文件都统一放在同一个项目文件夹里,编译器依靠统一的工程路径查找头文件,顺利编译出独立完整的二进制可执行程序,编译完成之后,程序运行就再也和所有源码头文件没有任何关系了。我们真正需要部署到 Linux 系统规范目录的,只有编译运行好的可执行程序,一般会放到 /usr/bin 路径下 ,让终端任意位置都能直接启动服务,而配置文件、日志文件、网页静态资源,再按照 Linux 模块化规范,分别部署到 /etc、/var/log 等不同专属目录。同时守护进程会通过 chdir 切换工作目录到系统根目录 /,它彻底脱离了原本本地项目文件夹,不再以我们敲命令的用户目录作为路径参照,**所以代码里所有运行时需要读取的配置、日志、网页静态资源,全部都必须写成以根目录 / 开头的绝对路径。**如果依旧使用原来的相对路径查找文件,守护进程就会完全找不到对应资源,轻则日志无法写入、网页无法访问,重则程序直接崩溃退出,服务无法正常稳定运行。
守护进程调用完 setsid 脱离原有会话之后,我们可以不执行 chdir ("/"),让它继续留在原本启动程序的项目文件夹里运行,这种写法程序不会报错,也能正常变成守护进程。只是线上企业级规范都推荐切换到根目录,因为守护进程没有固定用户启动目录,长期后台运行很容易出现工作路径错乱,相对路径频繁失效找不到资源。因此切换到根目录用绝对路径访问所有文件,是 Linux 服务最稳定、最标准、适配线上模块化分散部署的写法,不受启动位置、会话变化任何影响,永久不会出现路径错乱问题,所以只是行业强烈推荐,并不是语法硬性要求必须切换。
7. 最后一步就是标准 IO 文件描述符 0、1、2 的重定向处理,这里我们提供两种可选方案。第一种方案是直接关闭 0 标准输入、1 标准输出、2 标准错误这三个终端文件描述符 ,虽然能彻底断绝进程和终端的关联,但这种写法不推荐,很多系统底层接口、第三方库默认依赖这三个文件描述符运行,强行关闭会导致程序无故报错、异常崩溃退出。所以行业里统一推荐第二种更安全规范的写法,以可读可写模式打开系统黑洞文件 /dev/null ,通过 dup2 函数把 0、1、2 三个标准文件描述符全部重定向到这个文件上,所有原本打印到终端的日志、报错信息都会被系统直接丢弃,既不会影响程序正常逻辑。
这里关于标准 IO 文件描述符 0、1、2 的重定向处理和切换守护进程工作目录到根目录一样,都有两种选择,我们可以选择不切换到根目录,也可以切换到根目录,我们可以选择直接关闭文件描述符0 1 2,也可以选择不关闭并重定向到 /dev/null 中,只不过我们选择更推荐的 而这两个灵活可选的配置,也会和后续我们要讲的 daemon() 系统调用的底层逻辑深度关联。
我们在 main 函数最开头,先开启文件日志策略,紧接着第一时间调用封装好的 Daemon 守护进程函数,这样后续所有 HTTP 服务器业务逻辑,就全部由 fork 之后的子进程来运行。这个 Daemon 函数传入的两个参数,刚好对应我们之前聊的两个可选配置项:第一个参数 isdup,用来控制要不要对 0、1、2 标准文件描述符做 /dev/null 重定向,传 1 就开启重定向、屏蔽所有终端打印,传 0 就不执行 IO 重定向;第二个参数 isredir,用来控制要不要把守护进程工作目录切换到系统根目录/,传 1 就执行 chdir 切换根路径,适配线上绝对路径部署规范,传 0 就保持原本的项目启动目录不变。两个参数都是灵活开关,你可以根据本地调试、线上部署自由选择组合,完全对应守护进程里两个非强制、行业推荐的可选优化步骤。
运行结果
运行后终端没有任何打印输出,是正常现象:一方面我们提前开启了文件日志策略,所有服务日志不再往终端屏幕打印,全部写入本地磁盘文件保存;另一方面我们通过 /dev/null 重定向了 0、1、2 号标准 IO,代码里所有 printf、cout 屏幕打印都会被系统直接丢弃,自然终端里看不到任何内容。
ps 查询出来的结果,刚好完美符合守护进程的全部标志性特征:TTY 那一栏显示?,代表这个进程已经彻底脱离终端会话,不再绑定任何 Xshell 窗口,此时我们就算关闭了 Xsell,我们的程序照样能运行;进程 PID、PGID、SID 全部相同且都不是 - 1,代表进程已经成功调用 setsid 创建了独立全新会话,父进程早已退出,后台子进程正稳定以守护进程状态长期运行,哪怕关闭 Xshell 终端,服务也不会跟着退出。
第二种方法 : daemon() 系统调用

Linux 原生 daemon() 系统函数,是标准的 Linux 守护进程化系统调用,功能就是让进程守护进程化,和我们自己手写封装的 Daemon 函数逻辑、参数选项完全一一对应。第一个参数 nochdir,用来控制是否切换进程工作目录:传入 0 就自动把工作目录切换到根目录/,传入 1 就保持原本启动目录不改动,刚好对应我们之前 isredir路径切换开关;第二个参数 noclose,用来控制是否重定向 0、1、2 标准文件描述符:传入 0 就自动把标准输入输出错误全部重定向到 /dev/null 屏蔽终端打印,传入 1 就不改动原有文件描述符,完美对应我们之前isdup的 IO 重定向开关。
我们自己手写 Daemon 函数,只是为了一步步理解守护进程 fork、setsid、路径切换、IO 重定向的底层原理,方便吃透每一步逻辑;而企业实际开发上线,全都优先直接调用系统原生 daemon 库函数,只需要在 main 函数开头添加一行调用代码,就能一键完成全套守护进程化操作,不用自己手写繁琐底层逻辑,代码极简又标准稳定。
运行结果:

这里我们只需要在代码里添加这一行系统调用就行,打印出的结果也完全正确。
七、守护进程化本质总结
守护进程化最核心、最本质的操作,就是通过 setsid() 系统调用,让进程脱离原来用户终端会话,创建属于自己独立、全新的会话,彻底和登录终端会话解绑,不再受用户登录、注销、关闭窗口的任何影响,就算 Xshell 断开连接,服务依然能一直在后台稳定长期运行。
而普通后台进程只是在终端里加 & 符号放到后台运行,它依旧依附原来的终端会话,只要关闭登录窗口,整个后台进程就会跟着一起被杀掉退出,根本没有独立会话。
八、端口号频繁更换问题
这里我们再补充一个历史遗留问题 : 我们平时启动 HTTP 服务器时,明明之前用的 8080 端口,马上重启却绑定失败、只能被迫换新端口,这是什么原因?
这就是 TCP 协议经典的 TIME_WAIT 逆时序关闭遗留问题 。服务器作为主动关闭连接的一方退出程序后,操作系统不会立刻释放这个端口,**会让端口进入 TIME_WAIT 等待状态,预留一分钟左右的时间来接收网络里残留的延迟数据包、保证双方 TCP 连接正常收尾,这段时间里系统严格禁止任何程序再次绑定这个端口,所以立刻重启服务就会端口占用报错。**而线上服务器的业务端口是固定不能随意更改的,端口一变外部客户端就无法正常访问服务,绝对不能频繁换端口运行,等待系统超时自动释放端口又会严重耽误服务重启恢复,不符合服务器常驻运行的需求。


想要一劳永逸解决这个问题,我们就需要在创建监听套接字之后、绑定端口之前,调用 setsockopt套接字选项系统调用,开启端口地址复用特性 。这个函数依次传入套接字文件描述符、套接字标准层级 SOL_SOCKET、地址复用选项 SO_REUSEADDR,再传入开启开关的数值 ,就能告诉操作系统忽略端口的 TIME_WAIT 等待限制。开启之后服务器不管什么时候退出重启,都能立刻重复绑定刚刚用过的固定端口,不用等待超时、不用更换端口号,完美解决服务重启端口绑定失败、被迫频繁换端口的运维痛点,也是网络服务器开发必加的标准底层配置。
九、总结
本文详细介绍了Linux进程管理体系中的前后台进程组、作业管理及守护进程化原理。主要内容包括:1. 进程组划分规则:前台进程组独占终端控制权,后台进程组可同时存在多个;2. 作业与进程组关系:每个后台进程组对应一个作业号,二者一一绑定;3. 会话管理机制:每个终端登录创建独立会话,包含bash进程和终端设备文件;4. 守护进程化核心:通过fork()+setsid()使进程脱离原会话自立门户,配合工作目录切换和IO重定向实现服务持久化;5. 两种实现方案对比:手动封装Daemon函数与直接调用daemon()系统调用;6. 补充解决端口复用问题。文章通过具体示例和系统调用分析,完整呈现了Linux进程管理体系及网络服务守护化的关键技术要点。
谢谢大家的观看!



比如我们在第一个终端 /dev/pts/0 里,向另一个终端窗口 /dev/pts/1 写入内容,数据就能直接在第二个终端窗口屏幕上显示出来,这就说明所有 pts 终端本质都是共用同一个屏幕显示设备,所有进程的字符输入输出,都会通过对应的 pts 文件流转到同一个显示器界面。


再账号密码经过 sshd 认证成功之后,系统就会为我们启动一个专属的 bash 进程。紧接着这个 bash 会在 Linux 的 /dev 目录里打开一个字符终端设备文件,这个文件就是我们之前说的 pts/... 公用的显示器输入输出文件。
运行后终端没有任何打印输出,是正常现象:一方面我们提前开启了文件日志策略,所有服务日志不再往终端屏幕打印,全部写入本地磁盘文件保存;另一方面我们通过 /dev/null 重定向了 0、1、2 号标准 IO,代码里所有 printf、cout 屏幕打印都会被系统直接丢弃,自然终端里看不到任何内容。