为什么需要理解"进程间关系"?
我们平时写程序,习惯了一个进程"单打独斗"。但在 Linux/Unix 系统中,进程从来不是孤立的:
-
你在终端敲下
ls | grep txt,Shell 会创建两个进程 ,它们属于同一个进程组; -
你按下
Ctrl+C,信号不是发给某一个进程,而是发给整个前台进程组; -
你关闭终端,后台跑着的服务却依然活着------这就是守护进程的功劳。
理解进程组、会话、控制终端、作业控制 这几层关系,是写出健壮后台服务(比如 TCP 服务器)的基础。而**守护进程(Daemon)**正是这些概念的集大成者。
下面我们从最底层的"进程组"开始,逐层向上剖析。
进程组(Process Group)
什么是进程组?
每一个进程除了有 PID(进程 ID) 之外,还属于某个进程组。
-
进程组:一个或多个进程的集合。
-
PGID(进程组 ID) :每个进程组有唯一的 ID,类型为
pid_t,本质是一个正整数。
可以通过 ps 命令查看:
bash
ps -eo pid,pgid,ppid,comm | grep test
输出示例:
bash
PID PGID PPID COMMAND
2830 2830 2259 test
-
-e:every,显示所有进程 -
-o:以逗号分隔指定要输出的列
可以看到 test 进程的 PID 和 PGID 相同,说明它是组长进程。
组长进程(Process Group Leader)
组长进程的 PID 等于其 PGID。
看一个例子:
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
分析:
-
ps进程的 PID = PGID = 2880,所以ps是组长; -
cat的 PGID 也是 2880,所以它和ps属于同一个进程组; -
管道
|会把ps和cat编成一个进程组。
组长的作用:
-
可以创建一个进程组;
-
可以创建该组中的进程。
进程组的生命周期:
-
从进程组创建开始,到其中最后一个进程离开为止;
-
注意:只要组内还有任意一个进程存在,进程组就存在,与组长是否终止无关。组长死了,组还在,只是"群龙无首"。
-
组长进程终止后,进程组并不会消失,但该组不再有组长。此时组内其他进程的 PGID 依然是原来的值。
会话(Session)
什么是会话?
会话是一个或多个进程组的集合 。每个会话有唯一的 SID(会话 ID)。
用一张图表示层级关系:
bash
会话 (Session, SID)
├── 进程组 1 (PGID=100)
│ └── proc1
├── 进程组 2 (PGID=200)
│ ├── proc2
│ └── proc3
└── 进程组 3 (PGID=300)
├── proc4
├── proc5
└── proc6
通常我们用管道 把几个进程编成一个进程组,再用 & 放到后台:
bash
proc2 | proc3 &
proc4 | proc5 | proc6 &
& 表示将进程组放在后台执行。
实验:观察进程组与会话
bash
sleep 100 | sleep 200 | sleep 300 &
ps axj | head -n1
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),说明属于同一个进程组;
-
它们的 SID 相同(2780),说明属于同一个会话;
-
TTY 都是
pts/2,说明共享同一个控制终端。
参数说明:
-
a:显示所有用户进程; -
x:显示没有控制终端的进程; -
j:显示与作业控制相关的信息(PGID、SID、TTY、TPGID 等)。
如何创建会话?------ setsid()
bash
#include <unistd.h>
/*
* 功能:创建会话
* 返回值:成功返回 SID,失败返回 -1
*/
pid_t setsid(void);
调用 setsid() 后会发生三件事:
-
调用进程变成新会话的会话首进程,此时新会话中只有它一个进程;
-
调用进程变成新进程组的组长,新 PGID = 当前进程 PID;
-
该进程失去控制终端。如果之前有控制终端,调用后联系被切断。
前提 :调用进程不能是进程组的组长,否则会报错。
常见用法 :先 fork() 出子进程,父进程退出,子进程继续执行。因为子进程会继承父进程的 PGID,但 PID 是新分配的,所以它不是组长 ,可以安全调用 setsid()。
bash
if (fork() > 0) exit(0); // 父进程退出
setsid(); // 子进程创建新会话
会话 ID(SID)
-
会话首进程:创建会话的那个进程,具有唯一的 PID;
-
SID = 会话首进程的 PID;
-
又因为会话首进程总是它所在进程组的组长,所以 SID 也等于该进程组的 PGID,两者等价。
控制终端(Controlling Terminal)
什么是控制终端?
在 Linux 系统中,用户登录后得到一个 Shell 进程 ,这个终端就成了 Shell 的控制终端。
控制终端信息保存在 PCB(进程控制块) 中。fork() 会复制 PCB,所以 Shell 启动的子进程默认也以同一个终端为控制终端。
默认情况下(无重定向):
-
标准输入 → 控制终端(读键盘)
-
标准输出 / 标准错误 → 控制终端(写屏幕)
会话、进程组与控制终端的关系
-
一个会话最多有一个控制终端;
-
会话首进程打开终端后,该终端成为该会话的控制终端;
-
建立与控制终端连接的会话首进程称为控制进程;
-
一个会话中的进程组分为:
-
前台进程组(1 个)
-
后台进程组(0 个或多个)
-
-
终端产生的中断信号发给前台进程组的所有进程;
-
如果终端检测到调制解调器(或网络)断开,会向控制进程发送挂断信号(SIGHUP)。
结构示意图:
bash
会话 (Session)
┌───────────────────────────────────────────────┐
│ 后台进程组 后台进程组 前台进程组 │
│ [登录 shell] [proc1 proc2] [proc3 proc4]│
│ 会话首进程=控制进程 [proc5] │
└───────────────────────────────────────────────┘
↑ ↑
挂断信号(SIGHUP) 终端输入和终端产生的信号
│ │
└──────────── 控制终端 ────────────┘
↑
键盘输入
作业控制(Job Control)
作业与作业控制
-
作业(Job):从用户角度看,是为完成某项任务而启动的进程集合。一个作业可以是一个进程,也可以是一组协作的进程(通常是管道)。
-
作业控制(Job Control) :Shell 分前后台控制的是作业/进程组,而不是单个进程。
一个前台作业可以由多个进程组成,一个后台作业也可以。Shell 可以同时运行1 个前台作业 + 任意多个后台作业。
例如:
bash
cat /etc/filesystems | head -n 5
这就是一个作业,包含两个进程(cat 和 head),在前台运行。
作业号
在命令后加 & 可让 Shell 把它放到后台:
bash
cat /etc/filesystems | grep ext &
输出:
bash
[1] 2202
ext4
ext3
ext2
[1]+ 完成 cat /etc/filesystems | grep --color=auto ext
-
[1]:作业号 -
2202:进程号(PGID) -
+:默认作业 -
完成后会显示"完成"
默认作业规则:
-
+:默认作业(最近一次提交的) -
-:即将成为默认作业的(倒数第二个) -
无符号:其他作业
-
一个用户同时只能有一个
+和一个-
作业状态
| 状态 | 含义 |
|---|---|
| Running | 后台作业(&),正在执行 |
| Done | 作业完成,返回码为 0 |
| Done(code) | 作业完成并退出,返回码非 0 |
| Stopped | 前台作业被 Ctrl+Z 挂起 |
| Terminated | 作业被终止 |
作业的挂起与切回
(1)挂起:Ctrl+Z
运行一个死循环程序:
cpp
#include <stdio.h>
int main() {
while (1) {
printf("hello\n");
}
return 0;
}
运行 ./test,按 Ctrl+Z:
cpp
[1]+ 已停止 ./test
(2)切回前台:fg
cpp
fg %%
fg 参数表:
| 参数 | 含义 |
|---|---|
%n |
作业号 n |
%string |
以 string 开头的命令对应的作业 |
%?string |
包含 string 的命令对应的作业 |
%+ 或 %% |
最近提交的作业 |
%- |
倒数第二个提交的作业 |
不带参数时,默认把 + 作业切到前台。
查看后台/挂起作业:jobs
cpp
jobs # 查看作业
jobs -l # 显示详细信息(含 PID)
jobs -p # 只显示 PID
示例:
cpp
sleep 300 &
./test
# Ctrl+Z
jobs -l
输出:
cpp
[1]- 2265 运行中 sleep 300 &
[2]+ 2267 停止 ./test
作业控制相关的信号
| 按键 | 信号 | 含义 |
|---|---|---|
Ctrl+C |
SIGINT | 中断字符 |
Ctrl+\ |
SIGQUIT | 退出字符 |
Ctrl+Z |
SIGTSTP | 挂起字符 |
关键点 :这些信号只会发送给前台进程组中的所有进程,后台进程组不受影响。
终端的 I/O(标准输入/输出)和终端产生的信号,总是与前台进程组关联。
守护进程(Daemon)
什么是守护进程?
守护进程是在后台长期运行、脱离控制终端 的进程。典型例子:sshd、httpd、mysqld。
它的特点:
-
没有控制终端;
-
独立于任何会话终端;
-
通常随系统启动而启动,随系统关闭而终止;
-
名字通常以
d结尾(daemon)。
创建守护进程的步骤
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. 让自己不要成为组长
if (fork() > 0) exit(0);
// 3. 设置让自己成为一个新的会话(子进程继续执行)
setsid();
// 4. 是否将当前进程的 CWD 更改为根目录
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);
}
}
}
主程序使用:
cpp
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(false, false);
std::unique_ptr<TcpServer> svr(new TcpServer(localport, HandlerRequest));
svr->Loop();
return 0;
}
每一步为什么这么做?
| 步骤 | 操作 | 原因 |
|---|---|---|
| 1 | 忽略 SIGCHLD、SIGPIPE | 防止子进程退出或写管道出错导致进程异常终止 |
| 2 | fork() 后父进程退出 |
确保子进程不是进程组组长 ,为 setsid() 做准备 |
| 3 | setsid() |
创建新会话,脱离原控制终端 |
| 4 | chdir("/") |
避免占用可卸载的文件系统(如 U 盘),防止 CWD 被删除导致问题 |
| 5 | 重定向 0/1/2 到 /dev/null |
守护进程不需要终端输入输出,避免资源浪费和意外阻塞 |
更完整的守护进程
实际工程中,守护进程通常还会:
-
二次 fork :确保进程不是会话首进程,从而永远不会获得控制终端。
-
设置文件权限掩码 :
umask(0),避免继承的掩码影响创建文件。 -
写 PID 文件 :
/var/run/xxx.pid,方便管理。 -
日志系统 :用
syslog替代 stdout/stderr。
二次 fork 的代码示例:
cpp
if (fork() > 0) exit(0);
setsid();
if (fork() > 0) exit(0); // 二次 fork,确保不是会话首进程
// 继续执行守护进程逻辑
总结与知识地图
cpp
进程
├── PID(进程 ID)
├── 属于某个进程组
│ ├── PGID
│ └── 组长进程:PID == PGID
├── 属于某个会话
│ ├── SID
│ └── 会话首进程:PID == SID
└── 可能拥有控制终端
├── 前台进程组 ← Ctrl+C / Ctrl+\ / Ctrl+Z
└── 后台进程组 ← & 启动
作业控制
├── jobs / fg / bg
├── Ctrl+Z 挂起
└── 信号:SIGINT、SIGQUIT、SIGTSTP
守护进程
├── fork → setsid → chdir → 重定向
├── 脱离控制终端
└── 长期后台运行