一.进程组
1.什么是进程组
之前我们提到了进程的概念, 其实每⼀个进程除了有⼀个进程ID(PID)之外 还属于⼀个进程组。
进程组是⼀个或者多个进程的集合, ⼀个进程组可以包含多个进程。 每⼀个进程组也有⼀个唯⼀的进程组ID(PGID), 并且这个PGID类似于进程ID, 同样是⼀个正整数, 可以存放在pid_t数据类型中。
我们来验证看一下:
sleep 100000 | sleep 200000 | sleep 300000
管道 | 的含义:把前一个命令输出交给下一个命令;管道会创建子进程,每个 sleep 都是独立进程管道 sleep a | sleep b | sleep c:同时启动 3 个 sleep,并行运行,不是挨个等。。

写个代码验证一下:


看指令:
ps -eo pid,pgid,ppid,comm | grep process_task
# -e 选项表⽰every的意思, 表⽰输出每⼀个进程信息
# -o 选项以逗号操作符(,)作为定界符, 可以指定要输出的列
用处:专门看某个进程的:自己 PID、进程组 ID、父进程 ID,快速判断谁是进程组组长(PID==PGID 就是组长)。

2.组⻓进程
每⼀个进程组都有⼀个组⻓进程。 组⻓进程的ID等于其进程ID。我们可以通过ps命令看到组⻓进程的现象:
ps -o pid,pgid,ppid,comm | cat

从结果上看ps进程的PID和PGID相同, 那也就是说明ps进程是该进程组的组⻓进程, 该进程组包括ps和cat两个进程。
- 进程组组⻓的作⽤: 进程组组⻓可以创建⼀个进程组或者创建该组中的进程
- 进程组的⽣命周期: 从进程组创建开始到其中最后⼀个进程离开为⽌。
注意: 主要某个进程组中有⼀个进程存在, 则该进程组就存在, 这与其组⻓进程是否已经终⽌⽆关。
二.会话
1.什么是会话
刚刚我们谈到了进程组的概念, 那么会话⼜是什么呢? 会话其实和进程组息息相关, 会话可以看成是⼀个或多个进程组的集合, ⼀个会话可以包含多个进程组。每⼀个会话也有⼀个会话ID(SID)

通常我们都是使⽤管道将⼏个进程编成⼀个进程组。 如上图的进程组2和进程组3可能是由下列命令形成的:
[node@localhost code]$ proc2 | proc3 &
[node@localhost code]$ proc4 | proc5 | proc6 &
# &表⽰将进程组放在后台执⾏
举个例子看指令:
# ⽤管道和sleep组成⼀个进程组放在后台运⾏
[node@localhost code]$ sleep 100 | sleep 200 | sleep 300 &
# 查看ps命令打出来的列描述信息
[node@localhost code]$ ps axj | head -n1
# 过滤sleep相关的进程信息
[node@localhost code]$ ps axj | grep sleep | grep -v grep
# a选项表⽰不仅列当前⽤⼾的进程,也列出所有其他⽤⼾的进程
# x选项表⽰不仅列有控制终端的进程,也列出所有⽆控制终端的进程
# j选项表⽰列出与作业控制相关的信息, 作业控制后续会讲
# grep的-v选项表⽰反向过滤, 即不过滤带有grep字段相关的进程
sleep 100 | sleep 200 | sleep 300 & 整条管道,sleep 100 是进程组组长,三个 sleep 属于同一个进程组,&放到后台。
ps axj | head -n1 打印 ps axj 的表头,能看到 PID PGID SID这些列。
ps axj | grep sleep | grep -v grep 过滤出 sleep 进程;grep -v grep把 grep 自身进程过滤掉,只看 sleep。
看结果:

三个进程 PGID 全部等于 sleep 100 的 PID (4002066),证明整条管道所有进程属于同一个进程组,组长是管道第一个命令
2. 如何创建会话
可以调⽤setseid函数来创建⼀个会话, 前提是调⽤进程不能是⼀个进程组的组⻓。
#include <unistd.h>
/*
*功能:创建会话
*返回值:创建成功返回SID, 失败返回-1
*/
pid_t setsid(void);
该接⼝调⽤之后会发⽣:
- 调⽤进程会变成新会话的会话⾸进程。 此时, 新会话中只有唯⼀的⼀个进程
- 调⽤进程会变成进程组组⻓。 新进程组ID就是当前调⽤进程ID
- 该进程没有控制终端。 如果在调⽤setsid之前该进程存在控制终端, 则调⽤之后会切断联系
需要注意的是: 这个接⼝如果调⽤进程原来是进程组组⻓, 则会报错, 为了避免这种情况, 我们通常的使⽤⽅法是先调⽤fork创建⼦进程, ⽗进程终⽌, ⼦进程继续执⾏, 因为⼦进程会继承⽗进程的进程组ID, ⽽进程ID则是新分配的, 就不会出现错误的情况。


- PID = PGID = SID = 4004044 子进程调用setsid()之后,自身成为新会话的会话首进程,同时也是新进程组的组长进程。新会话内目前仅有这一个进程。
- TTY 列为 ? 进程已经脱离原来的控制终端,不再和启动它的终端绑定。此时即使关闭原来的终端窗口,该子进程依然可以继续在后台运行。
- PPID 为 1 父进程执行完毕退出,子进程被 systemd (init 进程) 收养。
3.会话ID(SID)
会话ID(Session ID)是一个用于标识进程所属会话的标识符。简单来说:
会话首进程:每个会话都有一个领头进程,称为"会话首进程"(Session Leader)。它是一个普通进程,拥有唯一的进程ID(PID)。
会话ID的定义:会话ID就是会话首进程的进程ID。因为会话首进程的PID在整个系统中是唯一的,所以用它来作为整个会话的标识符非常合适。
与进程组ID的关系:在某些资料中,会话ID也被称为"会话首进程的进程组ID"。这是因为:
- 会话首进程同时也是它所在进程组的组长(Process Group Leader)
- 进程组组长的PID = 该进程组的PGID(进程组ID)
- 因此,会话首进程的PID = 会话首进程的PGID = 会话ID
- 所以两者在数值上是等价的
速记:
会话ID = 会话首进程的PID = 会话首进程的PGID


三.控制终端
1.什么是控制终端
在 Unix/Linux 系统中,控制终端是与一个会话相关联的终端设备。当用户通过终端(物理终端、SSH 连接或伪终端)登录系统后,系统会为该登录会话分配一个终端设备,这个终端就成为该会话的控制终端。
控制终端的信息存储在每个进程的 PCB(进程控制块,即内核中的 task_struct)中,具体位于 signal->tty 字段,指向对应的终端设备结构体。由于 fork() 会复制父进程的 PCB,因此由 Shell 启动的所有子进程都会继承相同的控制终端。
控制终端的作用:
- 标准 I/O 关联:默认情况下(没有重定向),进程的标准输入(stdin)、标准输出(stdout)和标准错误(stderr)都指向控制终端。这意味着程序从键盘读取输入,将输出显示在显示器上。
- 进程组分类:在一个拥有控制终端的会话中,进程组被划分为:
前台进程组(Foreground Process Group):唯一拥有终端控制权,可以读写终端;
后台进程组(Background Process Group):不能读取终端(否则收到 SIGTTIN),写入时可能受影响。
- 信号转发:
按下 Ctrl+C(中断键)或 Ctrl+\\(退出键)时,终端驱动程序会向前台进程组中的所有进程发送 SIGINT 或 SIGQUIT 信号;
当终端连接异常断开(如 SSH 断开、网络中断)时,内核会向控制进程(会话首进程)发送 SIGHUP 信号,该信号默认会终止进程,并进一步传递给整个会话。
这些特性的关系如下图所⽰:

四.作业控制
1. 什么是作业(job)和作业控制(Job Control)?
作业是针对用户来讲,⽤⼾完成某项任务⽽启动的进程,⼀个作业既可以只包含⼀个进程,也可以
包含多个进程,进程之间互相协作完成任务, 通常是⼀个进程管道。
Shell分前后台来控制的不是进程⽽是作业 或者进程组。⼀个前台作业可以由多个进程组成,⼀个
后台作业也可以由多个进程组成,Shell可以同时运⾏⼀个前台作业和任意多个后台作业,这称为
作业控制。
作业控制的实操:

fg/bg 与信号控制(核心考点)
|
fg 作业号 |
— |
后台作业切到前台 |
前台进程组 |
— |
|
Ctrl+C |
SIGINT |
终止整个前台进程组 |
作业被删除 |
不能恢复 |
|
Ctrl+Z |
SIGTSTP |
暂停整个前台进程组 |
保留,状态 Stopped |
✅可 bg/fg 恢复 |
|
bg 作业号 |
SIGCONT |
暂停作业转到后台继续 |
恢复 Running |
— |
|
kill -SIGINT %作业号 |
SIGINT |
复现 Ctrl+C 效果 |
作业销毁 |
不能恢复 |
命令对照速查
| jobs | 查看后台作业列表 |
| jobs -l | 查看作业 + PID |
| fg %2 | 作业 2 切前台 |
| bg %2 | 作业 2 放后台运行 |
| Ctrl+C | 终止整个前台进程组 |
| Ctrl+Z | 暂停整个前台进程组 |
2. 作业号
放在后台执⾏的程序或命令称为后台命令,可以在命令的后⾯加上 & 符号从⽽让Shell识别这是⼀
个后台命令,后台命令不⽤等待该命令执⾏完成,就可⽴即接收新的命令,另外后台进程执⾏完后
会返回⼀个作业号以及⼀个进程号(PID)。

3.作业状态
常⻅的作业状态如下表所⽰:

4.作业的挂起与切回
(1) 作业挂起
我们在执⾏某个作业时,可以通过 Ctrl+Z 键将该作业挂起,然后Shell会显⽰相关的作业号、状
态以及所执⾏的命令信息。
例如我们运⾏⼀个死循环的程序, 通过 Ctrl+Z 将该作业挂起, 观察⼀下对应的作业状态:

下⾯我运⾏这个程序, 通过 Ctrl+Z 将该作业挂起:

可以发现通过 Ctrl+Z 将作业挂起, 该作业状态已经变为了停⽌状态。
(2) 作业切回
如果想将挂起的作业切回,可以通过 fg 命令, fg 后⾯可以跟 作业号 或 作业的命令名称 。如
果参数缺省则会默认将作业号为1的作业切到前台来执⾏,若当前系统只有⼀个作业在后台进⾏,
则可以直接使⽤fg命令不带参数直接切回。 具体的参数参考如下:

如我们把刚刚挂起来的 ./process_task 作业切回到前台:

运⾏结果为开始⽆限循环打印 hello , 可以发现该作业已经切换到前台了。
注意:当通过 fg 命令切回作业时,若没有指定作业参数,此时会将默认作业切到前台执⾏,即带
有“+”的作业号的作业
5.查看后台执⾏或挂起的作业
我们可以直接通过输⼊ jobs 命令查看本⽤⼾当前后台执⾏或挂起的作业
- 参数 -l 则显⽰作业的详细信息
- 参数 -p 则只显⽰作业的PID
例如, 我们先在后台及前台运⾏两个作业, 并将前台作业挂起, 来⽤ jobs 命令查看作业相关的
信息:
# 在后台运⾏⼀个作业sleep
[node@localhost code]$ sleep 300 &
# 运⾏刚才的死循环可执⾏程序
[node@localhost code]$ ./test
# 键⼊Ctrl + Z 挂起作业
# 使⽤jobs命令查看后台及挂起的作业
[node@localhost code]$ jobs -l
运⾏结果如下所⽰:

6. 作业控制相关的信号
⾯我们提到了键⼊ Ctrl + Z 可以将前台作业挂起,实际上是将 STGTSTP 信号发送⾄前台进
程组作业中的所有进程, 后台进程组中的作业不受影响。 在unix系统中, 存在3个特殊字符可以使得终端驱动程序产⽣信号, 并将信号发送⾄前台进程组作业, 它们分别是:
- Ctrl + C : 中断字符, 会产⽣ SIGINT 信号
- Ctrl + \\ : 退出字符, 会产⽣ SIGQUIT 信号
- Ctrl + Z :挂起字符, 会产⽣ STGTSTP 信号
终端的I/O(即标准输⼊和标准输出)和终端产⽣的信号总是从前台进程组作业连接打破实际终端。我
们可以通过下体来看到作业控制的功能:

这是 Linux 终端、会话、前台 / 后台进程组、信号的整体工作流程图。
- 前台进程组:当前正在占用终端的一组进程
- 键盘输入输出直接给到它
- Ctrl+C / Ctrl+Z产生的信号,发给整个前台进程组
- 后台进程组:后台作业(&启动)
- 不能直接读终端输入,读终端会收到SIGTTIN被暂停;写终端会收到SIGTTOU。
- tcsetpgrp():shell 调用这个系统调用,切换哪个进程组是前台(就是fg/bg命令底层做的事)。
- 用户键盘操作交给终端驱动,再把信号转发给前台进程组全体进程。
- 后台进程组想读终端,终端驱动抛出SIGTTIN,进程被暂停。
总结登:录 shell 管理同一个终端下的前台进程组、后台进程组;终端的输入、键盘信号只作用于前台进程组;后台进程组访问终端会被信号暂停;fg/bg本质就是调用tcsetpgrp切换前台进程组
五. 守护进程
通俗讲:Linux 后台长期运行的服务进程,脱离终端,关闭 ssh / 登录窗口也不会死掉,开机可以自启。
核心特点
这里面的重点:setsid () 是核心,调用后进程变成新会话首进程,脱离终端,TTY 变成?。
在前面我们也说过这个函数,话不多说来看到代码:
Daemon.hpp
#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,是否当前进程的CWD更改为/根目录
if(ischdir)
{
chdir(root);
}
//5.已经变成守护进程,不需要用户的输入和输出,错误进行关联了
if(isclose)
{
::close(0);
::close(1);
::close(2);
}
else
{
//这里一般建议两种
int fd = open(dev_null,O_RDWR);
if(fd>0)
{
dup2(fd,0);
dup2(fd,1);
dup2(fd,2);
close2(fd);
}
}
}
process_task.cc
#include <iostream>
#include <string>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <signal.h>
#include <cstdlib>
#include"Daemon.hpp"
int main()
{
Daemon(false, false);
// 打开日志文件
int log_fd = open("/tmp/mydaemon.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (log_fd < 0) {
exit(1);
}
// 主循环:每10秒写一次日志
while (true) {
std::string msg = "进程 " + std::to_string(getpid()) + " 还活着\\n";
write(log_fd, msg.c_str(), msg.size());
sleep(10);
}
close(log_fd);
return 0;
}
Daemon() 函数的目标是:将当前进程变成一个守护进程(Daemon)。
第1步:忽略信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
SIGCHLD 信号
-
产生时机:当子进程终止、暂停或恢复时,内核会向父进程发送 SIGCHLD 信号。
-
忽略的原因:守护进程通常会创建子进程来处理任务(比如 Web 服务器接收新连接后 fork 子进程处理)。如果不处理 SIGCHLD,子进程退出后会变成僵尸进程(Zombie),占用进程表项。
-
SIG_IGN 的作用:告诉内核"我对这个信号不感兴趣,子进程退出时自动回收资源,不需要通知我"。这是一种简化的处理方式,避免写复杂的信号处理函数。
SIGPIPE 信号
-
产生时机:当进程向一个已经关闭的 socket 管道写入数据时,内核会发送 SIGPIPE。
-
忽略的原因:守护进程经常涉及网络通信(比如 HTTP 服务)。如果客户端突然断开连接,服务端继续写入就会触发 SIGPIPE,默认行为是终止整个进程。忽略后,写入操作会返回 -1 并设置 errno 为 EPIPE,由程序自己处理错误,而不是崩溃退出。
为什么要在守护进程里忽略这两个信号?
守护进程设计原则之一就是稳定可靠,不能因为网络异常或子进程退出而意外终止。
第2步:创建子进程,父进程退出
if (fork() > 0) {
exit(0);
}
为什么要 fork 一次?
在 Unix/Linux 中,进程组组长(Process Group Leader)不能调用 setsid() 创建新会话。如果直接让一个组长进程调用 setsid(),会返回 -1 失败。
| fork() | 创建子进程,子进程的 PPID(父进程 ID)是原来的父进程 |
| 父进程 exit(0) | 父进程立即退出,子进程变成孤儿进程,被 init(PID=1)收养 |
| 子进程继续执行 | 子进程的 PGID 和 SID 仍然是父进程的值,但它不再是组长,因为它是新 fork 出来的,PGID 不等于自己的 PID |
此时的状态:
-
子进程 PID ≠ PGID(不是组长)
-
子进程可以安全调用 setsid()
第3步:创建新会话
setsid();
setsid() 做了什么?这是最核心的一步,调用后会发生三件事:
| 创建新会话 | 当前进程成为该会话的会话首进程(Session Leader),会话 ID = 当前进程的 PID |
| 创建新进程组 | 当前进程成为该进程组的组长(Group Leader),进程组 ID = 当前进程的 PID |
| 脱离原控制终端 | 进程不再有任何控制终端,即使它之前有(比如继承自 Shell),也会被切断 |
此时的状态
PID = PGID = SID = 当前进程的PID
当前进程既是进程组组长,也是会话首进程
没有控制终端
第4步:切换工作目录
if (ischdir) {
chdir(root); // root = "/"
}
切换到 / 根目录的好处
根目录永远是稳定的,不会因为挂载/卸载而变化
补充: 这是一个可选操作,不是所有守护进程都必须切到 /。有些守护进程需要特定工作目录,比如日志服务可能需要 /var/log。
第5步:处理标准输入/输出/错误
if (isclose) {
::close(0);
::close(1);
::close(2);
} else {
int fd = open(dev_null, O_RDWR);
if (fd > 0) {
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
:: 前缀 = 我要用全局的那个函数,别用别的同名函数替代我
相当于给编译器一个"强制指定",保证你用的是系统提供的 close 函数
简单记忆:
直接关闭 ,可能被复用 容易出 bug重定向到 /dev/null , 始终安全是标准做法。
为什么要处理这三个文件描述符?
一句话:守护进程在后台运行,没终端可交互。不管的话,万一 printf 或报错写到不存在的终端上,程序会崩。
两种处理方式的核心区别
| 直接关闭 close(0);close(1);close(2); | 释放掉 0、1、2 | 后面 open() 或 socket() 会重复利用这三个号,本来以为是标准I/O的代码可能误操作到真正的文件/网络,造成混乱 |
| 重定向到 /dev/null | 0、1、2 始终有效,但指向"黑洞" | 几乎没有风险,写入被丢弃,读取返回 EOF |
重定向 = 改变数据流向:本来数据要去 A 地方,现在让它去 B 地方。
┌─────────────────────────────────────────────────────────┐
│ 原始进程(可能是 Shell 启动的,有控制终端) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 1. 忽略 SIGCHLD 和 SIGPIPE │
│ 防止子进程变僵尸、防止管道异常终止进程 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2. fork() 创建子进程,父进程退出 │
│ 子进程不再是组长,可以调用 setsid() │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3. setsid() 创建新会话 │
│ 成为会话首进程 │
│ 成为进程组组长 │
│ 脱离原控制终端 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4. chdir("/") 切换工作目录 │
│ 避免文件系统无法卸载、路径失效等问题 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 5. 关闭/重定向 标准输入/输出/错误 │
│ 守护进程不需要终端交互 │
└─────────────────────────────────────────────────────────┘
│
▼
成为合格的守护进程,开始执行主逻辑


Daemon(true, false);
// ↑ ↑
// | └── 第2个参数:isclose = false(不关闭,重定向到 /dev/null)
// └───────── 第1个参数:ischdir = true(切换到根目录 /)
如果把这个改一下改为true呢:

注意这里要重新杀一下进程
六. 如何将服务守护进程化
// ./server port
int main(int argc, char *argv[])
{
// 1. 检查命令行参数
if (argc != 2) {
std::cout << "Usage : " << argv[0] << " port" << std::endl;
return 0;
}
// 2. 解析端口号
uint16_t localport = std::stoi(argv[1]);
// 3. 变成守护进程(不切目录,重定向到 /dev/null)
Daemon(false, false);
// 4. 创建 TCP 服务器
std::unique_ptr<TcpServer> svr(new TcpServer(localport, HandlerRequest));
// 5. 启动服务器主循环(永久运行)
svr->Loop();
return 0;
}
变成守护进程
Daemon(false, false);
作用: 让程序变成后台守护进程。
| ischdir | false | 不切换工作目录(保持在运行目录) |
| isclose | false | 重定向到 /dev/null(标准输出被吞掉) |
为什么要变成守护进程?
-
服务器需要长期运行(7×24小时)
-
不能被终端关闭影响(SSH断开也不怕)
-
不能在终端打印干扰用户





