文章目录
-
- 进程间关系与守护进程详解:从进程组到作业控制到守护进程实现
- 一、进程组
-
- [1.1 什么是进程组](#1.1 什么是进程组)
- [1.2 进程组的组长进程](#1.2 进程组的组长进程)
-
- [1.2.1 观察组长进程](#1.2.1 观察组长进程)
- [1.3 进程组的生命周期](#1.3 进程组的生命周期)
- [1.4 进程组的作用](#1.4 进程组的作用)
- 二、会话
-
- [2.1 什么是会话](#2.1 什么是会话)
- [2.2 进程组的形成](#2.2 进程组的形成)
-
- [2.2.1 实际验证](#2.2.1 实际验证)
- [2.3 创建会话](#2.3 创建会话)
-
- [2.3.1 setsid的效果](#2.3.1 setsid的效果)
- [2.3.2 使用限制](#2.3.2 使用限制)
- [2.4 会话ID(SID)](#2.4 会话ID(SID))
- 三、控制终端
-
- [3.1 什么是控制终端](#3.1 什么是控制终端)
- [3.2 会话与控制终端的关系](#3.2 会话与控制终端的关系)
- [3.3 信号与控制终端](#3.3 信号与控制终端)
- [3.4 进程、进程组、会话、控制终端的关系图](#3.4 进程、进程组、会话、控制终端的关系图)
- 四、作业控制
-
- [4.1 什么是作业](#4.1 什么是作业)
- [4.2 前台作业和后台作业](#4.2 前台作业和后台作业)
- [4.3 作业号](#4.3 作业号)
- [4.4 默认作业](#4.4 默认作业)
- [4.5 作业状态](#4.5 作业状态)
- [4.6 作业的挂起](#4.6 作业的挂起)
- [4.7 作业的切回](#4.7 作业的切回)
- [4.8 查看作业列表](#4.8 查看作业列表)
- [4.9 作业控制的信号](#4.9 作业控制的信号)
- 五、守护进程
-
- [5.1 什么是守护进程](#5.1 什么是守护进程)
- [5.2 守护进程的创建步骤](#5.2 守护进程的创建步骤)
-
- [5.2.1 忽略信号](#5.2.1 忽略信号)
- [5.2.2 fork并让父进程退出](#5.2.2 fork并让父进程退出)
- [5.2.3 创建新会话](#5.2.3 创建新会话)
- [5.2.4 更改工作目录](#5.2.4 更改工作目录)
- [5.2.5 关闭文件描述符](#5.2.5 关闭文件描述符)
- [5.3 守护进程实现(完整代码)](#5.3 守护进程实现(完整代码))
- [5.4 将服务守护进程化](#5.4 将服务守护进程化)
- [5.5 验证守护进程](#5.5 验证守护进程)
- 六、本篇总结
-
- [6.1 核心要点](#6.1 核心要点)
- [6.2 容易混淆的点](#6.2 容易混淆的点)
进程间关系与守护进程详解:从进程组到作业控制到守护进程实现
💬 开篇:之前我们学习了进程的基本概念、进程创建、进程控制等内容。但在实际的Linux系统中,进程之间并不是孤立存在的,它们通过进程组、会话等机制组织在一起,形成了复杂的进程间关系。理解这些关系,对于理解Shell的作业控制、实现守护进程等都至关重要。这一篇从进程组讲起,到会话、控制终端、作业控制,最后到守护进程的实现,系统地讲解进程间的组织关系,并手把手教你实现一个守护进程。掌握了这些知识,你就能理解Linux系统中进程管理的精髓。
👍 点赞、收藏与分享:这篇会把进程组、会话、守护进程等核心概念讲透,并提供完整的守护进程实现代码。如果对你有帮助,请点赞收藏!
🚀 循序渐进:从进程组开始,到会话,到控制终端,到作业控制,到守护进程,一步步理解进程间的组织关系和守护进程的实现原理。
一、进程组
1.1 什么是进程组
进程组(Process Group)是一个或多个进程的集合。
我们知道每个进程都有一个唯一的进程ID(PID),实际上每个进程还属于一个进程组,每个进程组也有一个唯一的进程组ID(PGID)。
PGID的类型 :和PID一样,PGID也是正整数,可以用pid_t类型存储。
查看进程组ID:
bash
ps -eo pid,pgid,ppid,comm | grep test
输出示例:
bash
PID PGID PPID COMMAND
2830 2830 2259 test
解释:
- PID:进程ID(2830)
- PGID:进程组ID(2830)
- PPID:父进程ID(2259)
- COMMAND:命令名称(test)
从这个例子可以看出,进程2830的PID和PGID相同,说明它是进程组的组长进程。
1.2 进程组的组长进程
组长进程(Process Group Leader):进程组ID等于其进程ID的进程。
特点:
- 每个进程组都有一个组长进程
- 组长进程的PID = PGID
- 组长进程可以创建进程组、创建该组中的进程
1.2.1 观察组长进程
bash
ps -o pid,pgid,ppid,comm | cat
输出示例:
bash
PID PGID PPID COMMAND
2806 2806 2805 bash
2880 2880 2806 ps
2881 2880 2806 cat
分析:
- bash进程:PID=2806, PGID=2806,是组长进程
- ps进程:PID=2880, PGID=2880,是组长进程(创建了新的进程组)
- cat进程:PID=2881, PGID=2880,属于ps进程组(不是组长)
为什么ps和cat在同一个进程组?
因为我们用管道连接了它们:ps ... | cat,Shell会把它们放在同一个进程组中。
1.3 进程组的生命周期
创建:当第一个进程加入时创建。
存在条件:只要进程组中有一个进程存在,进程组就存在。
销毁:当进程组中最后一个进程离开(终止或加入其他进程组)时销毁。
重要:进程组的存在与组长进程是否终止无关。
示例:
bash
进程组PGID=100,包含3个进程:
- 进程100(组长)
- 进程101
- 进程102
1. 进程100终止 → 进程组仍然存在(还有101、102)
2. 进程101终止 → 进程组仍然存在(还有102)
3. 进程102终止 → 进程组销毁(没有进程了)
1.4 进程组的作用
统一管理:可以对整个进程组发送信号,组内所有进程都会收到。
例如:
bash
kill -9 -PGID # 向整个进程组发送SIGKILL信号
作业控制:Shell通过进程组实现作业控制(后面详细讲)。
二、会话
2.1 什么是会话

会话(Session)是一个或多个进程组的集合。
bash
会话
├── 进程组1
│ ├── 进程A
│ └── 进程B
├── 进程组2
│ ├── 进程C
│ ├── 进程D
│ └── 进程E
└── 进程组3
└── 进程F
会话ID(SID):每个会话有唯一的会话ID。
会话首进程(Session Leader):创建会话的进程,其PID等于SID。
2.2 进程组的形成
通常通过管道将几个进程组成一个进程组。
示例:
bash
proc2 | proc3 &
proc4 | proc5 | proc6 &
这两个命令分别创建了两个进程组:
- 进程组1:proc2、proc3(后台运行)
- 进程组2:proc4、proc5、proc6(后台运行)
2.2.1 实际验证
bash
# 创建进程组(后台运行)
sleep 100 | sleep 200 | sleep 300 &
# 查看列标题
ps axj | head -n1
# 过滤sleep进程
ps axj | grep sleep | grep -v grep
输出示例:
bash
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
2806 4223 4223 2780 pts/2 4229 S 1000 0:00 sleep 100
2806 4224 4223 2780 pts/2 4229 S 1000 0:00 sleep 200
2806 4225 4223 2780 pts/2 4229 S 1000 0:00 sleep 300
分析:
- 三个sleep进程的PGID都是4223(同一个进程组)
- 第一个sleep进程(PID=4223)是组长进程(PID=PGID)
- 它们的SID都是2780(属于同一个会话)
2.3 创建会话
setsid系统调用:
c
#include <unistd.h>
/*
* 功能:创建会话
* 返回值:成功返回SID,失败返回-1
*/
pid_t setsid(void);
2.3.1 setsid的效果
调用setsid后会发生:
- 创建新会话:调用进程成为新会话的会话首进程(Session Leader)
- 创建新进程组:调用进程成为新进程组的组长进程,新PGID = 调用进程的PID
- 脱离控制终端:如果调用前有控制终端,调用后会脱离控制终端
2.3.2 使用限制
重要:如果调用进程已经是进程组组长,setsid会失败并返回-1。
避免错误的方法:
c
// 1. fork创建子进程
pid_t pid = fork();
if (pid > 0) {
// 2. 父进程退出
exit(0);
}
// 3. 子进程继续,调用setsid
// 子进程继承了父进程的PGID,但有新的PID
// 因此子进程肯定不是组长进程
setsid();
原理:
- 子进程继承父进程的PGID
- 子进程的PID是新分配的
- 所以子进程的PID ≠ PGID,不是组长进程
- setsid调用成功
2.4 会话ID(SID)
会话ID:会话首进程的进程ID。
因为会话首进程总是某个进程组的组长进程,所以:
bash
SID = 会话首进程的PID = 会话首进程的PGID
三、控制终端
3.1 什么是控制终端
控制终端(Controlling Terminal):与会话关联的终端设备。
工作流程:
bash
1. 用户通过终端登录系统
↓
2. 系统为用户创建Shell进程
↓
3. 这个终端成为Shell进程的控制终端
↓
4. Shell启动的所有进程都继承这个控制终端
控制终端的作用:
- 进程的标准输入、标准输出、标准错误默认指向控制终端
- 读标准输入 = 读用户键盘输入
- 写标准输出/标准错误 = 输出到显示器
3.2 会话与控制终端的关系
关系列表:
-
一个会话可以有一个控制终端(也可以没有)
-
会话首进程打开终端后,该终端成为会话的控制终端
-
会话首进程称为控制进程(Controlling Process)
-
一个会话可以有多个进程组:
- 一个前台进程组(Foreground Process Group)
- 零个或多个后台进程组(Background Process Group)
-
终端输入和终端信号只影响前台进程组
3.3 信号与控制终端
终端信号:
| 按键 | 信号 | 效果 |
|---|---|---|
| Ctrl+C | SIGINT | 中断前台进程组的所有进程 |
| Ctrl+\ | SIGQUIT | 终止前台进程组的所有进程(生成core dump) |
| Ctrl+Z | SIGTSTP | 暂停前台进程组的所有进程 |
重要:这些信号只发送给前台进程组,后台进程组不受影响。
调制解调器断开:如果终端检测到调制解调器断开(或网络断开),会向控制进程发送SIGHUP(挂断)信号。
3.4 进程、进程组、会话、控制终端的关系图
bash
终端(控制终端)
↓
会话(Session, SID=1000)
├── 前台进程组(PGID=2000)
│ ├── 进程A(PID=2000, 组长)
│ └── 进程B(PID=2001)
├── 后台进程组1(PGID=3000)
│ ├── 进程C(PID=3000, 组长)
│ └── 进程D(PID=3001)
└── 后台进程组2(PGID=4000)
└── 进程E(PID=4000, 组长)
用户按Ctrl+C → SIGINT发送给进程A和进程B(前台进程组)

四、作业控制
4.1 什么是作业
作业(Job):用户为完成某项任务而启动的一个或多个进程。
作业的组成:
- 单个进程:
sleep 100 - 多个进程(管道):
cat file | grep pattern | sort
作业控制(Job Control):Shell对作业的管理机制,包括前台作业、后台作业的切换和控制。
4.2 前台作业和后台作业
前台作业(Foreground Job):
- Shell等待作业完成
- 接收用户的键盘输入
- 可以接收终端信号(Ctrl+C、Ctrl+Z等)
后台作业(Background Job):
- Shell不等待作业完成,立即返回提示符
- 不接收键盘输入
- 不受Ctrl+C、Ctrl+Z等影响
启动后台作业 :在命令后加&符号。
bash
# 前台作业
cat /etc/filesystems | head -n 5
# 后台作业
cat /etc/filesystems | grep ext &
4.3 作业号
作业号(Job Number):Shell为每个后台作业分配的编号。
示例:
bash
cat /etc/filesystems | grep ext &
输出:
bash
[1] 2202
ext4
ext3
ext2
[1]+ 完成 cat /etc/filesystems | grep --color=auto ext
解释:
[1]:作业号2202:该作业中第一个进程的PIDext4、ext3、ext2:作业的输出结果[1]+ 完成:作业完成的提示
4.4 默认作业
默认作业 :用+标记,是最近被放到后台的作业。
次默认作业 :用-标记,是倒数第二个被放到后台的作业。
规则:
- 一个用户只能有一个默认作业(+)
- 只能有一个次默认作业(-)
- 当默认作业退出后,次默认作业(-)变成默认作业(+)
示例:
bash
sleep 100 & # 作业1,默认作业
sleep 200 & # 作业2,成为新的默认作业,作业1变成次默认作业
sleep 300 & # 作业3,成为新的默认作业,作业2变成次默认作业
jobs
输出:
[1] 运行中 sleep 100 &
[2]- 运行中 sleep 200 &
[3]+ 运行中 sleep 300 &
4.5 作业状态
| 状态 | 含义 |
|---|---|
| 运行中(Running) | 作业正在前台或后台运行 |
| 已停止(Stopped) | 作业被Ctrl+Z暂停 |
| 完成(Done) | 作业正常结束 |
| 终止(Terminated) | 作业被信号杀死 |
4.6 作业的挂起
挂起(Suspend):将前台作业暂停,放到后台。
方法 :按Ctrl+Z键。
示例:
c
// 死循环程序
#include <stdio.h>
int main()
{
while (1) {
printf("hello\n");
}
return 0;
}
运行并挂起:
bash
./test
# 按Ctrl+Z
[1]+ 已停止 ./test
效果:
- 作业被暂停(状态变为Stopped)
- 作业被放到后台
- Shell返回提示符
4.7 作业的切回
fg命令:将后台作业切换到前台。
语法:
bash
fg [作业号]
fg %作业号
fg %命令名
fg %% # 默认作业
fg %- # 次默认作业
示例:
bash
# 切换作业1到前台
fg 1
fg %1
# 切换默认作业到前台
fg
fg %%
# 按命令名切换
fg %test
bg命令 :让后台停bash
bg [作业号]
4.8 查看作业列表
jobs命令:查看当前用户的后台作业和挂起的作业。
语法:
bash
jobs # 显示作业列表
jobs -l # 显示详细信息(包含PID)
jobs -p # 只显示PID
示例:
bash
sleep 300 &
./test
# 按Ctrl+Z
jobs -l
输出:
bash
[1]- 2265 运行中 sleep 300 &
[2]+ 2267 停止 ./test
4.9 作业控制的信号
| 按键 | 信号 | 效果 |
|---|---|---|
| Ctrl+C | SIGINT | 中断前台进程组 |
| Ctrl+\ | SIGQUIT | 退出前台进程组(生成core dump) |
| Ctrl+Z | SIGTSTP | 挂起前台进程组 |
重要:这些信号只影响前台进程组,后台进程组不受影响。
五、守护进程
5.1 什么是守护进程
守护进程(Daemon):在后台运行的特殊进程,不与任何终端关联。
特点:
- 长期运行(通常从系统启动到关闭)
- 没有控制终端
- 不受用户登录/登出影响
- 通常以root权限运行
常见守护进程:
- sshd:SSH服务
- httpd/nginx:Web服务器
- mysqld:MySQL数据库
- crond:定时任务服务
5.2 守护进程的创建步骤
5.2.1 忽略信号
cpp
// 忽略可能导致进程异常退出的信号
signal(SIGCHLD, SIG_IGN); // 忽略子进程结束信号
signal(SIGPIPE, SIG_IGN); // 忽略管道破裂信号
原因:
- SIGCHLD:避免子进程变成僵尸进程
- SIGPIPE:避免写入已关闭的管道时进程被杀死
5.2.2 fork并让父进程退出
cpp
if (fork() > 0) {
exit(0); // 父进程退出
}
// 以下代码由子进程执行
目的:
- 确保调用进程不是进程组组长(为setsid做准备)
- 让Shell认为命令已经执行完成,返回提示符
5.2.3 创建新会话
cpp
setsid();
效果:
- 子进程成为新会话的首进程
- 子进程成为新进程组的组长进程
- 子进程脱离控制终端
5.2.4 更改工作目录
cpp
chdir("/"); // 切换到根目录
原因:
- 守护进程可能长期运行
- 如果工作目录在某个挂载点上,会导致该挂载点无法卸载
- 切换到根目录避免这个问题
5.2.5 关闭文件描述符
cpp
// 关闭标准输入、标准输出、标准错误
close(0);
close(1);
close(2);
// 或者重定向到/dev/null
int fd = open("/dev/null", O_RDWR);
if (fd > 0) {
dup2(fd, 0); // stdin重定向到/dev/null
dup2(fd, 1); // stdout重定向到/dev/null
dup2(fd, 2); // stderr重定向到/dev/null
close(fd);
}
原因:
- 守护进程没有控制终端,不需要stdin/stdout/stderr
- 关闭或重定向到/dev/null避免意外的I/O操作
5.3 守护进程实现(完整代码)
cpp
#pragma once
#include <iostream>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
const char *root = "/";
const char *dev_null = "/dev/null";
void Daemon(bool ischdir, bool isclose)
{
// 1. 忽略可能引起程序异常退出的信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
// 2. fork并让父进程退出,确保子进程不是组长
if (fork() > 0) {
exit(0);
}
// 3. 创建新会话(子进程执行)
setsid();
// 4. 是否更改工作目录到根目录
if (ischdir) {
chdir(root);
}
// 5. 处理标准输入/输出/错误
if (isclose) {
// 直接关闭
close(0);
close(1);
close(2);
} else {
// 重定向到/dev/null(推荐)
int fd = open(dev_null, O_RDWR);
if (fd > 0) {
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
}
5.4 将服务守护进程化
cpp
// ./server port
int main(int argc, char *argv[])
{
if (argc != 2) {
std::cout << "Usage : " << argv[0] << " port" << std::endl;
return 0;
}
uint16_t localport = std::stoi(argv[1]);
// 调用Daemon函数,将进程守护进程化
Daemon(false, false);
// 创建TCP服务器
std::unique_ptr<TcpServer> svr(new TcpServer(localport, HandlerRequest));
svr->Loop();
return 0;
}
参数说明:
-
Daemon(false, false):- 第一个false:不更改工作目录(保持当前目录)
- 第二个false:不直接关闭stdin/stdout/stderr,而是重定向到/dev/null
5.5 验证守护进程
启动服务器:
bash
./server 9090 &
查看进程:
bash
ps axj | grep server | grep -v grep
输出示例:
bash
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1 5678 5678 5678 ? -1 Ss 1000 0:00 ./server 9090
关键特征:
- PPID=1:父进程是init(进程被init收养)
- TTY=?:没有控制终端
- SID=PID=PGID:是会话首进程和进程组组长
测试:
- 退出终端,守护进程继续运行
- 可以通过kill命令停止:
kill PID
六、本篇总结
6.1 核心要点
进程组:
- 一个或多个进程的集合
- 每个进程组有唯一的PGID
- 组长进程:PID = PGID
- 生命周期:从创建到最后一个进程离开
会话:
- 一个或多个进程组的集合
- 每个会话有唯一的SID
- 会话首进程:创建会话的进程
- setsid创建新会话(调用进程不能是组长)
控制终端:
- 与会话关联的终端设备
- 一个会话最多有一个控制终端
- 终端信号(Ctrl+C、Ctrl+Z等)只影响前台进程组
作业控制:
- 作业:用户启动的一个或多个进程
- 前台作业:接收键盘输入,受终端信号影响
- 后台作业:不接收输入,不受Ctrl+C等影响
- jobs查看作业,fg切换到前台,bg让后台作业继续运行
守护进程:
- 后台长期运行,没有控制终端
- 创建步骤:忽略信号→fork→setsid→chdir→关闭fd
- 不受用户登录/登出影响
6.2 容易混淆的点
-
PID、PGID、SID的关系:
- PID:进程ID(唯一标识进程)
- PGID:进程组ID(进程所属的进程组)
- SID:会话ID(进程所属的会话)
-
组长进程和会话首进程:
- 组长进程:PID = PGID
- 会话首进程:PID = SID
- 会话首进程一定是某个进程组的组长
-
为什么setsid前要fork:
- setsid要求调用进程不是组长
- fork后子进程继承父进程的PGID,但有新的PID
- 所以子进程肯定不是组长
-
前台进程组和后台进程组:
- 一个会话只有一个前台进程组
- 终端信号只发送给前台进程组
- 后台进程组不受Ctrl+C、Ctrl+Z影响
-
守护进程为什么要关闭stdin/stdout/stderr:
- 守护进程没有控制终端
- 如果不关闭,读写这些fd可能导致错误
- 重定向到/dev/null是最安全的做法
-
守护进程为什么要chdir("/"):
- 避免守护进程的工作目录所在文件系统无法卸载
- 根目录永远不会被卸载
💬 总结:这一篇系统讲解了进程组、会话、控制终端、作业控制和守护进程的概念与实现。理解了进程间的组织关系,就能理解Shell的工作原理、作业控制机制。掌握了守护进程的创建方法,就能实现稳定的后台服务。这些知识是Linux系统编程的重要基础,对于理解操作系统的进程管理机制非常有帮助。
👍 点赞、收藏与分享:如果这篇帮你理解了进程组、会话和守护进程,请点赞收藏!