一、 进程组
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结构
无会话归属(或自建新会话)
无控制终端
不受任何用户操作影响
独立运行直至系统关闭或手动终止