一、 进程组
1.1 进程组的本质
进程组:是一个或多个进程的集合,主要用于作业控制。
进程组具有以下特征:
唯一标识:每个进程组拥有一个进程组 ID(PGID),其数据类型与 PID 完全相同。
成员关系:一个进程组由一个或多个进程组成,组内进程共享同一个 PGID。
用途:主要用于作业控制中的信号分发和状态管理。
1.2 组长进程
组长进程:进程的 PID 等于其所在进程组的 PGID, 即PGID == PID时,该进程为组长。
组长进程的职责:
创建进程组:当某个进程首次被放入一个全新的进程组时,它自动成为该组的组长。
在组内派生子进程:子进程默认继承父进程的 PGID(本质是子进程PCB写实拷贝自父进程)。
进程组的生命周期:
起点:组长进程创建进程组的时刻。
终点:组内最后一个进程终止或转入其他进程组的时刻。
关键点:组长进程的终止并不意味着进程组消亡,只要组内仍有其他存活进程,进程组就继续存在。
1.3 示例解释
通常我们都是使用管道将多个进程编成一个进程组
hamber@VM-0-14-ubuntu:~$ sleep 100 | sleep 200 | sleep 300 & hamber@VM-0-14-ubuntu:~$ ps axj |head -1 && ps ajx | grep sleep | grep -v grep PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 281066 312705 312705 281066 pts/1 313057 S 1003 0:00 sleep 100 281066 312706 312705 281066 pts/1 313057 S 1003 0:00 sleep 200 281066 312707 312705 281066 pts/1 313057 S 1003 0:00 sleep 300
在 Shell 中执行ps -o pid,pgid,ppid,comm | cat时:
ps -o pid,pgid,ppid,comm | cat PID PGID PPID COMMAND 281066 281066 281058 bash 310950 310950 281066 ps 310951 310950 281066 cat bash (PID=281066, PGID=281066) ├── ps (PID=310950, PGID=310950) ← 组长,PGID 等于自身 PID └── cat (PID=310951, PGID=310950) ← 加入 ps 所在的进程组
二、会话
2.1 会话的本质
会话:是比进程组更高一层的抽象,它是一个或多个进程组的集合。
会话的设计目的是将一次完整的用户登录交互(从登录到退出)所涉及的所有进程组织在一起。
2.2 会话的完整生命周期
将一次完整的用户登录交互(从登录到退出)所涉及的所有进程组织在一起。
这期间发生的所有事情构成了"一次完整的交互",它包括:
- Shell 本身(bash/zsh) - 你执行的每一条命令及其子进程(vim file.txt、gcc main.c、make) - 管道链中的所有进程(cat log | grep error | sort | uniq -c) - 后台运行的任务(sleep 3600 &、find / -name "*.tmp" &) - 被 Ctrl+Z 挂起后尚未恢复的作业 - 甚至是你启动的子 Shell(bash 嵌套调用)
它们都有一个共同的身份标签:它们都是"这次登录"产生的,会话就是用来给这批进程贴上同一个标签的容器。
当你在终端上执行ssh user@server登录后,到最终exit退出为止。
用户 ssh 登录 │ ▼ login/sshd 创建新会话 (setsid) │ SID = Shell PID │ 绑定控制终端 /dev/pts/N │ ├── 用户执行各种命令(进程不断创建、终止) ├── 进程组不断创建、消亡 ├── 前台进程组反复切换 │ ... 可能持续数小时、数天 ... │ ▼ 用户 exit / 关闭终端 / 网络断开 │ ▼ 控制终端释放 SIGHUP 发送给会话首进程 会话内所有进程收到清理信号 最后一个进程退出 → 会话消亡
三、控制终端
3.1 控制终端的认识
控制终端是:一个与会话绑定的终端设备
每个会话最多拥有一个控制终端,每个控制终端也最多被一个会话所控制。
终端设备:由内核管理的、带有输入输出处理管线的双向字符流通道。
关键点:
控制终端不是终端设备本身的属性,而是会话的属性
例如:对同一个
/dev/pts/0设备文件,在被某个会话设为控制终端时才是"控制终端";在没有会话绑定它时,它只是一个普通的字符设备。
对于同一个/dev/pts/0设备文件而言:
普通打开终端设备 作为控制终端 ───────────────── ──────────────── open("/dev/pts/0", O_RDWR) 通过特定机制建立控制关系 仅获得一个文件描述符 会话获得信号路由能力 无特殊内核行为 内核自动分发 SIGINT/SIGTSTP/SIGHUP 任何进程都可以打开 仅会话首进程可以建立(或继承) 关闭 fd 即断开 需要显式脱离或会话终结才断开控制终端与会话:
会话 (SID) │ ├── 控制终端(最多一个) │ ├── 控制进程 = 会话首进程 │ ├── 前台进程组(同一时刻最多一个) │ └── 后台进程组(零个或多个) │ └── 信号分发规则: ├── Ctrl+C → SIGINT → 前台进程组所有成员 ├── Ctrl+\ → SIGQUIT → 前台进程组所有成员 ├── Ctrl+Z → SIGTSTP → 前台进程组所有成员 └── 终端断开 → SIGHUP → 控制进程(会话首进程)
3.2 进程组的分类
在一个会话内部,进程组并非平级罗列,而是根据其与控制终端的关系被严格划分为:
前台进程组
后台进程组
3.2.1 前台进程组
定义:当前正在"使用"控制终端的那个进程组,在同一时刻,一个会话中有且仅有一个前台进程组。
前台进程组核心特征:
可以从控制终端读取输入(
read()、fgets()等不会被阻塞或拒绝)。向控制终端写入输出时不受限制。
接收来自终端的信号:Ctrl+C → SIGINT,Ctrl+Z → SIGTSTP,Ctrl+\ → SIGQUIT。
典型场景:
你在 Shell 中输入vim file.txt并回车,Shell 将vim所在的进程组设为前台进程组。此时你的键盘输入直接送达vim,Ctrl+C 也是发给vim。
3.2.2 后台进程组
定义:属于某个会话,且不是该会话当前前台进程组的所有进程组,都是后台进程组。
类比理解:
场景:“老师上课” 1. 会话 = 一个班级。 - 例如:“高三一班”,所有在这个班里的人和事,都属于这个会话。 2. 前台进程组 = 当前正在被老师“点名提问”的那个同学。 - 只有这个同学能直接跟老师对话(接收键盘输入),他的回答全班都能听到(输出到屏幕)。 - 此时,全班同学的目光和注意力都集中在他身上。 3. 后台进程组 = 班里其他所有正在埋头做题的同学。
"属于某个会话" = 他们都是"高三一班”的人(同一班级)。
"且不是该会话当前前台进程组"= 只要这个同学现在没有被老师点名站起来回答问题。
"都是后台进程组"= 那无论他在做数学卷子、在翻书、还是在发呆,他都属于"后台”状态。
后台进程组的限制:
不能从控制终端读取输入。
如果尝试
read(),内核会向该进程组发送 SIGTTIN 信号,默认行为是暂停进程(SIGSTOP 效果)。这是为了防止后台进程偷偷抢走用户的键盘输入。
写入控制终端通常被允许,但可以通过
stty tostop禁止。若禁止后后台进程尝试写终端,内核发送 SIGTTOU 信号,同样导致暂停。
这避免了后台输出意外覆盖前台程序的界面。
不接收终端产生的信号。
Ctrl+C 只影响前台进程组,后台进程组完全不受影响。
四、作业控制
4.1 作业的认识
作业:是针对Shell在用户层面的概念,表示用户为完成某项任务而启动的一个或多个进程的集合,进程间互相协作完成任务。
例如:作业通常是一个进程管道
# 这是一个作业,包含三个进程,前台执行 cat access.log | grep "404" | wc -l # 后台执行作业 cat access.log | grep "404" | wc -l & # 输出示例:[1] 2345 # [1] 是作业号(Job Number),2345 是组长进程的 PID
作业与进程组:作业与进程组相等于一枚硬币的两面。
对于用户层面而言,进程组执行用户指定的任务被视为做作业
对于内核层面而言,进程组是作业的底层实现机制。
4.2 作业号与标识符
作业号:是 Shell 内部的小整数(1、2、3...),由 Shell 分配和管理。
hamber@VM-0-14-ubuntu:~$ jobs ↓ 作业号 [1]- Running sleep 1 | sleep 2 | sleep 10 & [2]+ Running sleep 1 | sleep 2 | sleep 12 &
标识符:%
%符号用于引用作业:
%1、%2:按编号引用。
%+或%%:当前默认作业(最近被放入后台或挂起的作业)。
%-:上一个默认作业(当前默认作业之前的那个)。
4.3 作业状态转换
Start command (启动命令) │ | ▼ 前台运行 暂停运行 ┌────────────┐ Ctrl+Z (SIGTSTP) ┌──────────┐ │ Foreground │ ──────────────────────→ │ Stopped │ │ (Running) │ └──────────┘ └────────────┘ │ │ │ & │ fg / bg + SIGCONT ▼ ▼ 后台启动 前台恢复 ┌────────────┐ ┌──────────────┐ │ Background │ ←────────────────── │ Foreground │ │ (Running) │ fg %N │ (Resumed) │ └────────────┘ └──────────────┘ │ │ 正常退出或被信号终止 ▼ ┌───────────┐ │ Completed │ └───────────┘
4.4 作业相关命令
| 命令 | 功能 |
|---|---|
command & | 在后台启动作业 |
Ctrl+Z | 挂起当前前台作业(发送 SIGTSTP 给前台进程组) |
jobs | 列出当前 Shell 的所有作业及其状态 |
jobs -l | 列出作业及其所有进程的 PID |
jobs -p | 仅列出组长进程的 PID |
fg %N | 将第 N 号作业调回前台继续执行 |
bg %N | 让第 N 号已停止的作业在后台继续执行(发送 SIGCONT) |
kill %N | 向第 N 号作业的所有进程发送 SIGTERM |
五、守护进程
5.1 守护进程的认识
守护进程:存在于独立的会话中,脱离原有的控制终端,是在后台长期运行的特殊进程。
守护进程的典型特征:
没有控制终端,独立于用户登录会话。
不产生终端输出,日志写入文件或系统日志。
生命周期很长,往往伴随整个系统运行周期。
命名惯例:进程名以
d结尾(如sshd、httpd、crond、syslogd)。
需要守护进程化的原因:
以网络服务器为例:
若服务器进程直接由用户终端启动,当用户关闭终端或 SSH 断开时,SIGHUP 信号会终止服务器。
守护进程化使服务器脱离任何终端和会话,不再受用户登录状态影响。
5.2 守护进程化的步骤
5.2.1 第一步:忽略特定信号
signal(SIGCHLD, SIG_IGN); // 忽略子进程退出信号,由内核进行回收子进程,避免僵尸进程 signal(SIGPIPE, SIG_IGN); // 忽略管道断裂信号,避免 write 导致进程终止
忽略特定信号SIGCHLD的原因:
守护进程的核心特征是长期运行且无交互,它通常在主循环中处理业务逻辑(如接受网络连接、处理定时任务),而不是在一个专门的"子进程回收循环"中等待。
SIGCHLD:默认动作是 SIG_DFL,而 SIG_DFL 对 SIGCHLD 的语义是"不做任何事"。
这意味着内核不会自动回收子进程,显式忽略后,内核会自动回收子进程资源,无需父进程调用
wait()。
忽略特定信号SIGPIPE的原因:
在一个网络服务器(守护进程),如果用户关闭客户端,而此时服务端还在向客户端写入时,会触发SIGPIPE信号,导致服务端进程被信号杀死,从而引起服务器进程被关闭。
SIGPIPE:当向已关闭读端的管道或 socket 写入时触发。
忽略后,
write()返回 -1 并设置errno = EPIPE,程序可自行处理错误而非直接崩溃。
5.2.2 第二步:fork 并让父进程退出
pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(EXIT_FAILURE); } if (pid > 0) { // 父进程退出 exit(EXIT_SUCCESS); } // 此后代码仅在子进程中执行核心目的: 让子进程能够创建新会话,创建新会话的前提是当前进程不是进程组长(PID = PGID)
确保子进程不是进程组组长。
子进程 PID 是新的,而 PGID 继承自父进程,PGID 是进程组长的PID,因为PID具有唯一性,所以子进程的PID不可能等于PGID
因为父进程退出,子进程被 init 进程(PID=1)收养,成为孤儿进程,不会导致僵尸进程。
5.2.3 第三步:创建新会话
pid_t sid = setsid(); if (sid < 0) { perror("setsid"); exit(EXIT_FAILURE); }创建新会话后的效果:
子进程成为新会话的首进程(SID = PID)。
子进程成为新进程组的组长(PGID = PID)。
彻底脱离原控制终端。
此时该进程不再属于任何终端会话,Ctrl+C、Ctrl+Z、终端关闭都不会影响它。
5.2.4 更改工作目录
将守护进程的工作路径放置在根目录下
if (chdir("/") < 0) { perror("chdir"); exit(EXIT_FAILURE); }5.2.5 重置文件描述符
守护进程存在于独立的会话中失去了控制终端,但失去控制终端 ≠ 文件描述符自动关闭。
fd 0/1/2 仍然打开着,仍然指向原来的设备节点,这可能导致的问题:
阻止终端/文件系统卸载,只要守护进程持有这些 fd,原有对应的设备或文件系统就无法被释放或卸载
写入已失效的终端导致不可预测行为
第三方库的隐式 I/O
将守护进程的文件描述符重定向到 /dev/null的原因:
写入即消失:所有重定向或写入到该 /dev/null 文件的数据,都会被操作系统内核直接丢弃,不占用任何磁盘空间。
读取即空:如果你尝试从它里面读取内容,它会立刻返回一个" 文件结束符(EOF)",即什么都读不到。
// 方式一:关闭后重定向到 /dev/null int fd = open("/dev/null", O_RDWR); if (fd != -1) { dup2(fd, STDIN_FILENO); // fd 0 → /dev/null dup2(fd, STDOUT_FILENO); // fd 1 → /dev/null dup2(fd, STDERR_FILENO); // fd 2 → /dev/null close(fd); //关闭fd文件描述,此时/dev/null存在标准输入 fd=0、标准输出 fd=1、标准错误fd=2, 进行指向,引用计数为3。 }5.3 完整的守护进程化函数实现
// Daemon.hpp #include <unistd.h> #include <stdlib.h> #include <signal.h> #include <fcntl.h> #include <sys/stat.h> void Daemon() { // 1. 忽略可能干扰的信号 signal(SIGCHLD, SIG_IGN); signal(SIGPIPE, SIG_IGN); // 2. fork,父进程退出 pid_t pid = fork(); if (pid < 0) { exit(EXIT_FAILURE); } if (pid > 0) { exit(EXIT_SUCCESS); } // 3. 创建新会话,脱离控制终端 if (setsid() < 0) { exit(EXIT_FAILURE); } // 4. 更改工作目录 if (chdir("/") < 0) { exit(EXIT_FAILURE); } // 5. 重置文件描述符到 /dev/null int fd = open("/dev/null", O_RDWR); //读写的方式打开 if (fd != -1) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd > STDERR_FILENO) { close(fd); } } }六、总结
用户登录 │ ▼ 创建会话 (Session) │ SID = Shell 的 PID │ 控制终端 = /dev/pts/N │ ├── Shell 进程(会话首进程 / 控制进程) │ PGID = PID = SID │ ├── 前台进程组(同一时刻最多一个) │ 接收 Ctrl+C / Ctrl+Z 信号 │ 可读写终端 │ ├── 后台进程组 1 │ 不接收终端信号 │ └── 后台进程组 2 不接收终端信号 守护进程: 通过 fork + setsid 脱离上述h结构 无会话归属(或自建新会话) 无控制终端 不受任何用户操作影响 独立运行直至系统关闭或手动终止