Asynchronous Programming with C++ 学习:第二章导读:进程、线程与服务(Processes, Threads, and Services)
一、这一章要讲什么
这一章其实是一个"承上启下"的章节:前面我们已经从概念层面理解了并行编程模型、异步编程、事件驱动编程这些"思路",而这一章开始,要正式进入 Linux 系统底层,看看这些思路具体是靠哪些"操作系统提供的实实在在的机制"来落地实现的。
简单来说,这一章会依次介绍三个互相关联的主题:
- Linux 中的进程(Processes)
- 服务与守护进程(Services and Daemons)
- 线程与并发(Threads and Concurrency)
学完这一章,我们就能对"Linux 下的异步编程到底是怎么一回事"建立起一个扎实的基础认知,为后面章节更深入、更实战的内容打好铺垫。
二、先回顾一下:异步编程到底解决了什么问题
在正式进入进程、线程这些具体机制之前,这一章先用一段话重新强调了异步编程的重要性,我们用自己的话再梳理一遍。
异步编程的核心做法是:发起一个操作之后,不去傻等它完成,而是立刻转身去做下一件事情。这种"不阻塞"的行为方式,能带来两个非常直接的好处:
- 响应更灵敏:程序不会因为某一个操作耗时较长,就把整个程序"卡死",导致用户觉得程序"没反应"了。
- 资源利用率更高:与其让 CPU 傻乎乎地"干等"某个操作完成(比如等待网络数据、等待磁盘读写),不如让它趁这段等待的时间去处理别的任务,一点都不浪费。
正因为有这些好处,异步编程在下面这几类场景中显得格外关键:
| 应用领域 | 异步编程发挥的作用 |
|---|---|
| 网络应用开发 | 需要同时应对大量客户端的连接请求,不能一个请求处理慢了就拖累所有人 |
| 用户界面开发 | 界面必须随时保持可操作、可响应,不能因为后台在算什么东西就卡住不动 |
| 系统编程 | 需要高效地处理输入输出(I/O)操作、以及多个任务的并发执行 |
三、为什么选择 Linux 作为这一章的实践环境
这本书在涉及"没办法做到跨平台通用"的代码时,会统一选择在 Linux 操作系统上进行讲解和实践,原因主要有这几点:
- 进程管理能力强:Linux 提供了一整套成熟、稳定、经过长期打磨的进程管理机制。
- 原生支持多线程:线程作为并发编程的基本单位,在 Linux 内核层面有非常完善、直接的支持。
- I/O 能力先进:Linux 提供了非阻塞 I/O 等一系列高级输入输出特性,这些恰恰是实现高性能异步程序的关键基础设施。
- 丰富的 API 和 IPC 机制:Linux 对外暴露了大量用于"进程管理""线程管理"的强大 API,同时还提供了成熟的**进程间通信(Inter-Process Communication,IPC)**机制,方便不同的进程之间交换数据、协调工作。
正是因为具备这些特性,Linux 才成为开发高性能异步应用程序的理想土壤。
四、这一章内容的整体结构图
我们用一张 Mermaid 图,把这一章接下来要讲的三大主题,以及它们之间大致的递进关系可视化出来:
从这张图可以看出一条清晰的学习脉络:先理解"进程"这个操作系统里最基本的执行实体,再理解"服务/守护进程"这种特殊的、长期在后台运行的进程形态,最后再深入到"线程"——也就是同一个进程内部更轻量级的并发执行方式。三者环环相扣,共同构成了 Linux 下实现异步、并发程序的完整基础知识体系。
五、进程与线程的直观区别(先建立一个初步印象)
虽然这一章后面会分别详细展开进程和线程各自的内容,但这里我们可以先用一张简单的对比表格,建立一个初步的、直观的印象,方便理解后面为什么要分成"进程"和"线程"两条线来讲:
| 对比维度 | 进程(Process) | 线程(Thread) |
|---|---|---|
| 定义 | 一个正在运行的程序实例,拥有独立的内存空间 | 进程内部的一条执行路径,是 CPU 调度的基本单位 |
| 资源隔离性 | 进程之间彼此独立,默认互不共享内存 | 同一进程内的多个线程共享同一份内存空间 |
| 创建/切换开销 | 相对较重,创建和切换的成本较高 | 相对较轻,创建和切换的成本更低 |
| 通信方式 | 需要借助专门的 IPC 机制(如管道、消息队列、共享内存) | 可以直接通过共享变量通信,但需要小心同步问题 |
六、用一段最小化的 C++ 代码,直观感受"进程"这个概念
虽然进程的详细知识会在这一章后续小节里展开,但既然这里是导读部分,我们不妨先用一小段完整可运行的 C++ 代码,直观地"看一眼"进程到底是什么——通过 Linux 提供的 fork() 系统调用,创建一个全新的子进程,观察父进程和子进程分别拥有各自独立的执行流程。
// 文件名:process_intro_demo.cpp
// 说明:使用 Linux 的 fork() 系统调用创建一个子进程,
// 直观感受"进程"是一个拥有独立执行流程和独立内存空间的实体。
// 编译方式(仅适用于 Linux/类 Unix 系统):
// g++ -std=c++17 -O2 process_intro_demo.cpp -o process_intro_demo
#include <unistd.h> // 提供 fork()、getpid()、getppid() 等 Linux 进程相关的系统调用
#include <sys/wait.h> // 提供 waitpid(),让父进程等待子进程结束
#include <iostream> // 提供标准输入输出
int main() {
std::cout << "程序启动,当前进程的 PID(进程编号)为: " << getpid() << std::endl;
// fork() 是 Linux 提供的系统调用,作用是"复制当前进程",创建出一个几乎一模一样的子进程。
// 调用一次 fork(),会返回两次:
// - 在父进程中,fork() 返回子进程的 PID(一个正整数)
// - 在子进程中,fork() 返回 0
// - 如果创建失败,fork() 会返回一个负数
pid_t fork_result = fork();
if (fork_result < 0) {
// fork 失败的情况,通常是系统资源不足导致的
std::cerr << "创建子进程失败!" << std::endl;
return 1;
} else if (fork_result == 0) {
// 走到这个分支,说明当前代码正运行在"子进程"里
// 子进程拥有一份独立的内存空间,虽然刚创建时内容和父进程一模一样,
// 但从这一刻起,父子进程的执行是完全独立、互不干扰的
std::cout << " [子进程] 我是子进程,我的 PID 是: " << getpid()
<< ",我的父进程 PID 是: " << getppid() << std::endl;
std::cout << " [子进程] 子进程即将结束" << std::endl;
return 0; // 子进程执行完毕,退出
} else {
// 走到这个分支,说明当前代码正运行在"父进程"里,
// fork_result 此时保存的就是刚刚创建出来的子进程的 PID
std::cout << "[父进程] 我是父进程,我的 PID 是: " << getpid()
<< ",我刚创建的子进程 PID 是: " << fork_result << std::endl;
// waitpid 让父进程阻塞等待,直到指定 PID 的子进程真正结束为止,
// 这样可以避免子进程变成"僵尸进程"(子进程结束了但没人回收它的退出状态)
int child_status = 0;
waitpid(fork_result, &child_status, 0);
std::cout << "[父进程] 检测到子进程已经结束,父进程也即将退出" << std::endl;
}
return 0;
}
代码关键点解析
fork()这个系统调用最特别的地方:它只被调用了一次,却会"返回两次"——一次是在原来的父进程里返回(返回值是子进程的 PID),另一次是在新创建出来的子进程里返回(返回值固定是 0)。这是刚接触进程编程时最容易迷惑的地方:从fork()这一行代码往后,程序实际上会有两条独立的执行路径同时存在。if (fork_result == 0)这个分支专门给子进程执行:因为只有在子进程里,fork()的返回值才会是 0,所以这个if分支里的代码,只会被子进程执行到,父进程是不会进入这个分支的。else分支专门给父进程执行:此时fork_result是一个正数(子进程的 PID),说明当前代码是在原来那个父进程里继续往下跑。getpid()和getppid():getpid()返回"当前进程自己的 PID(进程编号)“,getppid()返回"当前进程的父进程的 PID”,运行这段代码时你会发现,子进程打印出来的getppid()结果,正好等于父进程打印出来的getpid()结果,这就直观验证了"父子进程"之间的关系。- 为什么父进程要调用
waitpid:如果父进程不管子进程,自己先退出了,子进程结束之后就会变成操作系统里所谓的"僵尸进程(Zombie Process)"——它已经执行完毕,但它的退出状态还残留在系统的进程表里没有被清理掉,长期积累会浪费系统资源。waitpid就是父进程专门用来"等待并回收"子进程的标准做法。 - 这段代码体现了进程的什么核心特性:父进程和子进程虽然一开始的代码内容一模一样(因为子进程是父进程的"复制品"),但从
fork()返回之后,它们拥有各自独立的变量、独立的执行流程,互不干扰——这正是"进程之间默认资源隔离、彼此独立"这一特性最直接的体现,也呼应了前面对比表格里"进程之间彼此独立,默认互不共享内存"这一条。
七、父子进程执行过程的时序图
由于 fork() 之后父子进程是"分叉"运行的,逻辑上比较特殊,我们用时序图梳理一下上面这段代码的完整执行过程:
这张图里用 par ... and ... end 表示的部分,就是想强调:fork() 之后,父进程和子进程是并发、独立执行的,谁先打印、谁先执行完,其实并没有一个绝对固定的先后顺序(取决于操作系统的调度),只不过父进程最后会通过 waitpid 等到子进程结束为止。
八、本章知识地图(ASCII 树形结构)
最后用一棵 ASCII 树,把这一章的知识结构梳理一遍,方便建立整体印象:
第二章:进程、线程与服务
|
+-- 异步编程回顾
| |
| +-- 非阻塞行为带来的两大好处(响应灵敏、资源高效)
| +-- 三大典型应用领域(网络应用、用户界面、系统编程)
|
+-- 为什么选择 Linux 作为实践平台
| |
| +-- 成熟的进程管理
| +-- 原生的多线程支持
| +-- 先进的 I/O 能力
| +-- 丰富的 API 与 IPC 机制
|
+-- 主题一:Linux 中的进程
|
+-- 主题二:服务与守护进程
|
+-- 主题三:线程与并发
九、小结
| 要点 | 说明 |
|---|---|
| 本章定位 | 从"并行编程的抽象模型"过渡到"Linux 操作系统提供的具体实现机制" |
| 异步编程的价值 | 通过非阻塞的方式,同时提升程序的响应速度和资源利用效率 |
| 选择 Linux 的原因 | 成熟的进程管理、原生多线程支持、先进的 I/O 能力、丰富的 API 与 IPC 机制 |
| 本章三大主题 | Linux 中的进程、服务与守护进程、线程与并发 |
| 进程与线程的关系 | 进程是更重、彼此隔离的独立执行实体;线程是进程内部更轻量、彼此共享内存的执行单位 |
这一章接下来的内容,会顺着"进程 → 服务/守护进程 → 线程"这条主线,一步步把 Linux 下实现异步、并发编程所需要的底层知识补全,为后面章节里更复杂的实战案例打下坚实的基础。
进程间通信(IPC)详解
一、先搞清楚问题的根源:为什么进程之间"互相看不见"
在 Linux 操作系统里,每一个进程(process)都运行在一个相互隔离的环境中——这意味着,一个进程无法直接读写另一个进程的内存空间。这种隔离是操作系统故意设计出来的,目的是为了安全性和稳定性:如果进程之间可以随意读写彼此的内存,一个进程崩溃或者出现 bug,很可能就会把其他进程的数据也搞坏,甚至威胁到整个系统的稳定。
但这种隔离性也带来了一个现实问题:当多个进程确实需要互相通信、协调步调的时候,该怎么办? 比如一个进程算出了一部分结果,需要交给另一个进程继续处理;或者多个进程需要商量好谁先做、谁后做,避免互相冲突——这些场景在进程完全隔离的前提下,是无法直接实现的。
为了解决这个问题,Linux 内核提供了一整套进程间通信(IPC,Inter-Process Communication)机制。这些机制各有侧重、适合不同的场景,让开发者可以在"进程互相隔离"这个前提下,依然能够构建出复杂、高性能、支持异步处理的应用程序。
二、为什么要花时间理解 IPC——它到底带来了什么好处
理解并用好这些 IPC 技术,对开发可扩展、高效率的应用程序来说非常关键。总的来说,IPC 让不同进程能够做到三件事:
- 交换数据:一个进程算出来的东西,可以传递给另一个进程使用。
- 共享资源:多个进程可以协调使用同一份资源(比如同一块内存、同一个文件)。
- 协调各自的行动:确保多个进程按照正确的顺序、正确的时机去执行各自的任务,而不是各干各的、互相踩踏。
只要合理选用适合场景的 IPC 机制,开发者就能获得更高的吞吐率、更低的延迟、更强的并发能力,最终带来更好的程序性能和用户体验。
三、两个非常典型的实际应用场景
场景一:Web 服务器处理并发请求
设想一个 Web 服务器需要同时处理来自多个客户端的请求。一种常见的架构是:主进程负责接收连接,然后为每个请求派生(fork)出一个子进程去专门处理这一个请求;主进程和这些子进程之间,就需要依靠 IPC 机制来传递必要的信息(比如把客户端的请求内容传给子进程,或者子进程处理完之后把结果传回主进程)。正是通过这种"主进程 + 多个子进程 + IPC通信"的组合,Web 服务器才能够同时处理多个请求,从而提升整体的性能和可扩展性。
场景二:分布式系统 / 微服务架构
在分布式系统或者微服务架构里,多个彼此独立、甚至运行在不同机器上的进程或服务,需要互相协作才能共同完成一个业务目标。这时候常用的 IPC 手段包括:
- 消息队列(message queue):进程之间通过往队列里"投递消息"、"取出消息"的方式来通信,发送方和接收方不需要同时在线。
- 套接字(socket):基于网络协议(比如 TCP/IP)在不同进程甚至不同机器之间建立连接,双向传递数据流。
- 远程过程调用(RPC,Remote Procedure Call):让一个进程可以像调用本地函数一样,去调用另一个(可能在远程机器上的)进程里的方法,底层自动帮你完成参数的打包、网络传输、结果的返回等一系列繁琐工作。
这些机制共同保证了分布式系统里各个独立服务之间,能够顺畅、可靠地交换消息、调用彼此的功能、协调各自的执行步骤。
四、Linux 提供的常见 IPC 机制一览
下面用一张表格,梳理一下 Linux 内核提供的几种常见 IPC 机制,方便你从整体上把握它们各自的特点和适用场景:
| IPC机制 | 通信方式简述 | 典型适用场景 |
|---|---|---|
| 管道(Pipe) | 数据像水流一样,从一端写入、另一端读出,通常只能用于有亲缘关系的进程(比如父子进程) | 简单的父子进程单向数据传递 |
| 命名管道(Named Pipe / FIFO) | 类似管道,但通过文件系统中的一个特殊文件来标识,没有亲缘关系的进程也能用 | 无关联进程之间的简单通信 |
| 消息队列(Message Queue) | 发送方把一条条独立的消息放入队列,接收方随时取出,支持消息类型区分 | 需要异步、解耦的消息传递场景 |
| 共享内存(Shared Memory) | 多个进程直接映射同一块物理内存,读写速度最快,但需要自己处理同步问题 | 大量数据、要求极高读写效率的场景 |
| 信号量(Semaphore) | 用于协调多个进程访问共享资源时的先后顺序,本身不传递数据,只做同步控制 | 配合共享内存,防止多个进程同时读写冲突 |
| 套接字(Socket) | 基于网络协议,既能用于同一台机器上的进程通信,也能跨越网络连接不同机器 | 分布式系统、微服务、网络应用 |
五、用一张图直观理解"进程隔离"与"IPC搭桥"的关系
从这张图可以直观看到:进程A和进程B各自守着自己的一块私有内存,谁也无法直接闯入对方的地盘;它们之间唯一的沟通渠道,就是由操作系统内核提供的这条"IPC通道",所有的数据交换都必须经过这条通道来完成。
六、用 C++ 代码演示最基础的一种 IPC:管道(Pipe)
下面这段代码演示了 Linux 系统下最经典的一种 IPC 方式:用 fork() 创建一个子进程,父子进程之间通过一个**管道(pipe)**来传递数据。这份代码需要在 Linux 环境下编译运行(因为用到的是 POSIX 系统调用,不是 C++ 标准库本身提供的功能)。
#include <iostream> // 标准输入输出
#include <unistd.h> // fork()、pipe()、read()、write()、close() 等系统调用
#include <cstring> // strlen,用于计算字符串长度
#include <sys/wait.h> // waitpid,父进程用来等待子进程结束
int main() {
int pipeFds[2]; // pipeFds[0] 是"读端",pipeFds[1] 是"写端"
// 创建一个管道,成功时会填好 pipeFds[0] 和 pipeFds[1] 这两个文件描述符,
// 这两个描述符分别对应管道的"入口"和"出口"。
if (pipe(pipeFds) == -1) {
std::cerr << "创建管道失败" << std::endl;
return 1;
}
// fork() 会把当前进程复制一份,产生一个新的"子进程"。
// fork() 的返回值在父进程里是子进程的 PID(一个正数),
// 在子进程里则是 0,据此可以判断当前代码到底运行在父进程还是子进程里。
pid_t pid = fork();
if (pid < 0) {
std::cerr << "创建子进程失败" << std::endl;
return 1;
}
if (pid == 0) {
// ------------------ 这一段代码在"子进程"里执行 ------------------
close(pipeFds[1]); // 子进程只负责"读取",所以把写端关掉,避免浪费文件描述符
char buffer[128] = {0}; // 用来存放从管道里读出来的数据
ssize_t bytesRead = read(pipeFds[0], buffer, sizeof(buffer) - 1);
if (bytesRead > 0) {
std::cout << "[子进程] 收到父进程发来的消息: " << buffer << std::endl;
}
close(pipeFds[0]); // 读取完毕,关闭读端
return 0; // 子进程执行结束
} else {
// ------------------ 这一段代码在"父进程"里执行 ------------------
close(pipeFds[0]); // 父进程只负责"写入",所以把读端关掉
const char *message = "你好,这是父进程通过管道发来的数据";
write(pipeFds[1], message, strlen(message)); // 把消息写入管道
std::cout << "[父进程] 已经把消息写入管道" << std::endl;
close(pipeFds[1]); // 写完之后关闭写端
// 等待子进程执行结束,避免产生"僵尸进程"
int status;
waitpid(pid, &status, 0);
std::cout << "[父进程] 子进程已结束,父进程退出" << std::endl;
}
return 0;
}
代码关键点解析
pipe(pipeFds):这一步调用了 Linux 内核提供的系统调用,创建了一条"单向数据通道"。pipeFds[0]是这条通道的读取端,pipeFds[1]是写入端——数据只能从写入端流进去、从读取端流出来,方向是固定的。fork():这是整个 IPC 演示的关键一步。调用一次fork(),操作系统会把当前进程的内存状态几乎原样复制一份,形成两个几乎一模一样、但彼此独立的进程(父进程和子进程)。有意思的是,这两个管道的文件描述符(pipeFds[0]和pipeFds[1])会同时存在于父进程和子进程里——这正是父子进程之间能够通过这条管道通信的前提:他们各自持有同一条管道的"两端"。if (pid == 0)分支(子进程):子进程只需要"读",所以先把自己这一份写端pipeFds[1]关掉(这是一个好习惯,避免占用不需要的资源,也能让读端在写端全部关闭后正确判断"数据已经读完");然后调用read()从管道里读取父进程写入的数据。else分支(父进程):父进程反过来,只需要"写",所以先把自己这一份读端pipeFds[0]关掉;然后调用write()把一段字符串写入管道;写完后关闭写端,并调用waitpid()等待子进程执行完毕再退出,避免子进程变成没人回收的"僵尸进程"。- 为什么父子进程都要关闭自己不需要的那一端:因为
fork()之后,管道的读端和写端在父子进程里都各自留了一份拷贝,如果不主动关闭不需要的那一端,可能会导致管道一直"认为还有人可能会写入/读取",从而影响某些判断逻辑(比如读端在判断"是否已经读到数据末尾"时,需要所有写端都关闭才能准确判断)。
编译运行方式(注意:这段代码依赖 POSIX 系统调用,只能在 Linux / macOS 等类 Unix 系统上编译运行):
g++ -std=c++17 ipc_pipe_demo.cpp -o demo
./demo
运行输出:
[父进程] 已经把消息写入管道
[子进程] 收到父进程发来的消息: 你好,这是父进程通过管道发来的数据
[父进程] 子进程已结束,父进程退出
这份代码虽然简单,但完整体现了 IPC 最核心的意义:父进程和子进程本来是两个完全独立、互相看不到对方内存的进程,但通过操作系统提供的"管道"这一 IPC 机制,它们依然能够可靠地交换数据。
七、用时序图展示"Web服务器 + 子进程 + IPC"的协作过程
结合前面提到的 Web 服务器场景,下面用一张时序图,展示主进程如何通过 IPC 与处理具体请求的子进程协作:
从这张时序图可以看到:主进程本身并不直接处理具体的业务逻辑,而是把这部分工作委托给专门派生出来的子进程去做;主进程和子进程虽然是两个完全独立的进程,但借助 IPC 通道,它们依然能够默契地协作,共同完成一次完整的请求处理流程。这种"主进程负责调度、子进程负责干活、IPC负责传话"的模式,正是很多高并发服务器架构的基础思路。
八、小结
- 在 Linux 系统中,进程之间天生是互相隔离的,无法直接访问彼此的内存空间,这是出于安全和稳定性的考虑。
- 为了让相互隔离的进程之间也能协作,Linux 内核提供了一整套 IPC(进程间通信)机制,包括管道、消息队列、共享内存、信号量、套接字等,每一种都有各自适合的场景。
- 合理运用 IPC,可以让程序在多任务环境下获得更高的吞吐率、更低的延迟、更强的并发能力,典型应用场景包括 Web 服务器的多请求并发处理,以及分布式系统/微服务架构中各个独立服务之间的协作。
- 上面用
pipe()+fork()实现的父子进程通信示例,是理解 IPC 最基础也最直观的一个切入点:两个原本互相看不见对方内存的进程,借助操作系统提供的这条"通道",实现了可靠的数据交换。
Linux 中的进程间通信机制(IPC Mechanisms)详解
一、什么是进程间通信(IPC)
我们已经知道,进程和进程之间默认是"互相隔离"的——每个进程都有自己独立的一份内存空间,一个进程完全看不到、也访问不了另一个进程内部的数据。但现实中的程序往往需要"合作"完成任务,比如一个进程负责采集数据,另一个进程负责处理数据,这就必然需要一种方式,让原本互相隔离的进程之间能够传递信息、协调步调——这就是**进程间通信(Inter-Process Communication,简称 IPC)**要解决的问题。
Linux 系统提供了好几种不同的 IPC 机制,各自有自己的特点和适用场景。这一节我们就把这些机制逐一梳理清楚。
二、Linux 常见 IPC 机制总览
在深入每一种机制之前,先用一张表格建立整体印象:
| 机制名称 | 通信方向 | 典型特点 | 适用场景 |
|---|---|---|---|
| 管道 / 命名管道 | 单向 | 结构简单,命名管道可让互不相关的进程通信 | 简单的父子进程或本地进程间数据传递 |
| 信号(Signal) | 单向通知 | 不传输实际数据,只是一种"软件中断"式的通知 | 通知进程发生了某个事件、控制进程行为 |
| 消息队列 | 双向皆可 | 消息按先进先出方式存放,支持异步收发 | 生产者和消费者不需要同时在线的异步通信 |
| 信号量(Semaphore) | 不传输数据,用于同步 | 限制同一时刻能访问某资源的进程数量 | 保护共享资源、避免竞态条件 |
| 共享内存 | 双向 | 多个进程直接共享同一块物理内存,速度极快 | 单机内需要高速交换大量数据的场景 |
| 套接字(Socket) | 双向 | 既能用于同一台机器,也能跨网络通信 | 跨服务器通信、网络应用开发 |
题目里特别强调,这么多机制里,共享内存和套接字是最常用的两种:共享内存主要用在"同一台服务器内部"的进程通信,因为速度最快;套接字则主要用在"跨服务器之间"的通信,是网络应用的基石。接下来我们按照由简单到复杂的顺序,把每一种机制都讲清楚。
三、管道与命名管道(Pipes and Named Pipes)
管道(Pipe)是最简单的一种 IPC 方式,它只能实现单向通信——数据只能从一端流向另一端,如果想要双向通信,通常需要建立两个管道。
普通管道有一个很大的限制:只能在有"亲缘关系"的进程之间使用(比如父进程和它 fork() 出来的子进程),因为管道本质上是通过文件描述符传递的,而这种文件描述符的继承关系只存在于父子进程之间。
**命名管道(Named Pipe,也叫 FIFO)**在这个基础上做了扩展:它会在文件系统里创建一个"看得见"的文件路径(比如 /tmp/my_fifo),任何知道这个路径的进程,哪怕彼此毫无亲缘关系,都可以打开这个文件来进行通信。
我们用一张图说明普通管道和命名管道的区别:
四、完整可运行的 C++ 代码示例一:用管道实现父子进程通信
下面写一段完整的代码,父进程创建一个管道,fork() 出一个子进程,子进程往管道里写入一句话,父进程从管道里把这句话读出来打印。
// 文件名:pipe_demo.cpp
// 说明:演示 Linux 管道(pipe)在父子进程之间传递数据
// 编译方式(仅适用于 Linux/类 Unix 系统):
// g++ -std=c++17 -O2 pipe_demo.cpp -o pipe_demo
#include <unistd.h> // 提供 pipe()、fork()、read()、write()、close()
#include <sys/wait.h> // 提供 waitpid()
#include <cstring> // 提供 strlen()
#include <iostream> // 提供标准输入输出
int main() {
// pipe_fds[0] 是管道的"读端",pipe_fds[1] 是管道的"写端"
// pipe() 调用成功后,这两个文件描述符就会被填充好
int pipe_fds[2];
// 创建管道,如果创建失败会返回 -1
if (pipe(pipe_fds) == -1) {
std::cerr << "创建管道失败!" << std::endl;
return 1;
}
// fork 出一个子进程,父子进程都会各自持有 pipe_fds[0] 和 pipe_fds[1] 这两个文件描述符
// (因为文件描述符会被子进程"继承"一份)
pid_t pid = fork();
if (pid < 0) {
std::cerr << "创建子进程失败!" << std::endl;
return 1;
} else if (pid == 0) {
// ---------- 子进程:负责写数据 ----------
// 子进程不需要读数据,把管道的读端关闭,养成良好习惯,避免浪费文件描述符资源
close(pipe_fds[0]);
const char* message = "你好,我是子进程,这是我发送的数据!";
// 往管道的写端写入数据,write 的第三个参数是要写入的字节数
write(pipe_fds[1], message, strlen(message));
// 数据写完之后,关闭写端,这一步很关键:
// 只有写端被关闭,父进程用 read() 读取时才能收到"读到末尾"的信号(返回 0)
close(pipe_fds[1]);
std::cout << " [子进程] 数据发送完毕" << std::endl;
return 0;
} else {
// ---------- 父进程:负责读数据 ----------
// 父进程不需要写数据,把管道的写端关闭
close(pipe_fds[1]);
char buffer[256] = {0}; // 用来接收数据的缓冲区,初始化为全 0
// 从管道的读端读取数据,最多读取 buffer 大小减 1 个字节(留一个位置给字符串结尾的 '\0')
// read 会阻塞,直到有数据可读,或者写端被关闭且没有更多数据时返回 0
ssize_t bytes_read = read(pipe_fds[0], buffer, sizeof(buffer) - 1);
std::cout << "[父进程] 收到子进程发来的数据: " << buffer
<< "(共 " << bytes_read << " 字节)" << std::endl;
close(pipe_fds[0]); // 读取完毕,关闭读端
// 等待子进程结束,避免产生僵尸进程
waitpid(pid, nullptr, 0);
}
return 0;
}
代码关键点解析
pipe(pipe_fds):这一个系统调用会创建出一对相互关联的文件描述符,pipe_fds[0]专门用来读,pipe_fds[1]专门用来写,数据只能沿着"写端 → 读端"这一个方向流动。- 为什么父子进程都要关闭自己不用的那一端:
fork()之后,父子进程各自都持有读端和写端两个文件描述符的拷贝。如果子进程不关闭读端、父进程不关闭写端,会导致文件描述符资源没有被正确释放,甚至会影响read()判断"数据是否已经发送完毕"的逻辑(因为只要还有任何一个进程持有写端没关闭,read()就会一直傻等,以为后面还可能有新数据)。 write和read的配合:子进程调用write把字符串内容写进管道的写端,父进程调用read从管道的读端把这些数据取出来,这个过程完全是"单向"的——子进程没办法反过来从这个管道里读到父进程写的数据。
五、信号(Signals)
信号是一种比较特殊的 IPC 方式,它本质上是一种"软件中断",用来通知某个进程"发生了某件事情"。它有一个很重要的特点需要记住:信号本身不传输实际的数据内容,它只是像"拍一下肩膀"一样告诉对方"出事了,你该处理一下",具体是什么事、要怎么处理,取决于进程内部为这个信号注册的处理逻辑。
常见的信号包括:SIGINT(通常由 Ctrl+C 触发,请求进程终止)、SIGKILL(强制杀死进程)、SIGTERM(请求进程正常退出)等。
六、消息队列(Message Queues)
消息队列在"传输数据"这一点上比管道更进一步:它允许进程把一条条消息按照先进先出(FIFO)的顺序放进一个队列里,接收方可以在自己方便的时候再去取,不需要发送方和接收方同时在线、同步配合。
这正是异步通信的一个典型体现:发送方把消息扔进队列就可以继续做别的事情,不用等接收方立刻来取。
七、信号量(Semaphores)
信号量本身并不用来传输数据,它的作用是同步——用来控制"同一时刻,最多允许多少个进程访问某一份共享资源"。
举个例子,如果我们规定某个共享资源同一时刻最多只能被 1 个进程访问,那信号量就相当于一把"钥匙":进程想要访问资源,必须先拿到这把钥匙(术语叫"获取信号量"),用完之后要把钥匙还回去(术语叫"释放信号量"),如果钥匙已经被别人拿走了,后来者就必须排队等待。这种机制正是用来防止多个进程同时读写同一份数据而引发竞态条件的关键手段。
八、共享内存(Shared Memory)
共享内存是 IPC 机制里速度最快的一种,也是在单机环境下、对性能要求较高时的首选方案。
8.1 共享内存的基本原理
共享内存的核心思路是:让多个进程直接访问同一块物理内存区域。既然大家操作的是同一块内存,数据自然就不需要像其他 IPC 方式那样,经过"从一个进程的内存拷贝到内核缓冲区,再从内核缓冲区拷贝到另一个进程内存"这种绕远路的过程,因此速度极快,非常适合处理大数据量或者需要高速通信的场景。
具体的实现思路是:
- 先创建出一段"共享内存段",这是物理内存里专门划分出来、可以被多个进程共同访问的一块区域;
- 各个进程把这段共享内存"挂载"到自己的进程地址空间中,之后就可以像操作普通内存一样,直接读写这块区域;
- 由于多个进程可能同时读写同一块内存,为了保证数据不会被搞乱,必须配合信号量或者互斥锁这类同步机制,来控制"什么时候谁可以写、什么时候谁可以读",避免出现数据不一致甚至数据损坏的问题。
8.2 共享内存的优点和需要注意的地方
| 方面 | 具体说明 |
|---|---|
| 优点 | 速度极快,因为数据不需要在进程之间被反复拷贝,几乎没有额外的通信开销 |
| 需要注意 | 必须仔细管理同步逻辑,防止竞态条件和内存泄漏;进程之间需要遵循统一的访问约定,否则容易造成死锁;依赖具体操作系统的支持,可能存在平台相关性 |
用一个简单的公式,可以直观说明"共享内存为什么快":假设一份数据大小为 DDD,如果通过管道或者消息队列这类需要"拷贝"的方式传递,数据实际上要经历两次拷贝——从发送方内存拷贝到内核缓冲区,再从内核缓冲区拷贝到接收方内存:
总拷贝量管道方式=2D
\text{总拷贝量}_{\text{管道方式}} = 2D
总拷贝量管道方式=2D
而共享内存的方式下,数据本身根本不需要被拷贝,多个进程直接读写的就是同一块物理内存:
总拷贝量共享内存方式≈0
\text{总拷贝量}_{\text{共享内存方式}} \approx 0
总拷贝量共享内存方式≈0
这也是为什么在需要交换的数据量很大、或者对通信延迟极其敏感的场景下,共享内存往往是效率最高的选择。
九、完整可运行的 C++ 代码示例二:用共享内存 + 信号量实现进程间通信
下面用 POSIX 提供的共享内存 API(shm_open、mmap)配合一个具名信号量(sem_open),实现一个真实可运行的例子:一个"写入进程"往共享内存里写一句话并通知,另一个"读取进程"等待通知后把内容读出来。为了让示例可以在一个可执行文件里演示完整流程,我们依然用 fork() 来创建两个角色。
// 文件名:shared_memory_demo.cpp
// 说明:使用 POSIX 共享内存(shm_open + mmap)和具名信号量(sem_open)
// 实现父子进程之间的高速数据交换,并用信号量做同步。
// 编译方式(仅适用于 Linux/类 Unix 系统,需要链接 rt 和 pthread 库):
// g++ -std=c++17 -O2 shared_memory_demo.cpp -o shared_memory_demo -lrt -lpthread
#include <fcntl.h> // 提供 O_CREAT 等文件打开标志
#include <sys/mman.h> // 提供 shm_open、mmap、munmap、shm_unlink
#include <sys/wait.h> // 提供 waitpid
#include <semaphore.h> // 提供 sem_open、sem_wait、sem_post、sem_close、sem_unlink
#include <unistd.h> // 提供 fork、ftruncate
#include <cstring> // 提供 strcpy
#include <iostream> // 提供标准输入输出
// 共享内存区域的大小,这里只演示存放一小段文字
constexpr size_t SHM_SIZE = 256;
// 共享内存和信号量都需要一个"名字",多个进程通过这个名字找到同一份资源
const char* SHM_NAME = "/demo_shared_memory";
const char* SEM_NAME = "/demo_semaphore";
int main() {
// 第一步:创建(或打开)一段具名共享内存对象
// O_CREAT 表示如果不存在就创建,O_RDWR 表示以读写方式打开
// 0666 是权限位,表示所有用户都可读写(仅用于演示,实际生产环境要更严格控制权限)
int shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
if (shm_fd == -1) {
std::cerr << "创建共享内存失败!" << std::endl;
return 1;
}
// 设置这段共享内存的实际大小,默认新建出来的大小是 0,必须手动调整
if (ftruncate(shm_fd, SHM_SIZE) == -1) {
std::cerr << "设置共享内存大小失败!" << std::endl;
return 1;
}
// 把这段共享内存"映射"到当前进程的地址空间,之后就可以像操作普通指针一样操作它
// PROT_READ | PROT_WRITE 表示这块内存允许读也允许写
// MAP_SHARED 表示这是一段可以被多个进程共享的映射
char* shared_data = static_cast<char*>(
mmap(nullptr, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0)
);
if (shared_data == MAP_FAILED) {
std::cerr << "映射共享内存失败!" << std::endl;
return 1;
}
// 第二步:创建一个具名信号量,用来做"写完了通知读端"的同步动作
// 初始值设为 0,表示"一开始还没有数据可读",读端一开始应该处于等待状态
sem_t* sem_ready = sem_open(SEM_NAME, O_CREAT, 0666, 0);
if (sem_ready == SEM_FAILED) {
std::cerr << "创建信号量失败!" << std::endl;
return 1;
}
// 用 fork 创建子进程,父进程扮演"写入者",子进程扮演"读取者"
pid_t pid = fork();
if (pid < 0) {
std::cerr << "创建子进程失败!" << std::endl;
return 1;
} else if (pid == 0) {
// ---------- 子进程:扮演"读取者"角色 ----------
std::cout << " [读取进程] 正在等待数据准备好……" << std::endl;
// sem_wait 会阻塞,直到信号量的值大于 0,然后把信号量的值减一再继续往下执行
// 这里的效果就是:只有等父进程调用 sem_post 之后,子进程才会被唤醒继续执行
sem_wait(sem_ready);
std::cout << " [读取进程] 检测到数据已准备好,读取内容: " << shared_data << std::endl;
// 子进程用完共享内存后,解除映射(不销毁底层对象,父进程可能还要用)
munmap(shared_data, SHM_SIZE);
return 0;
} else {
// ---------- 父进程:扮演"写入者"角色 ----------
const char* message = "这是通过共享内存高速传递的数据!";
// 直接把字符串拷贝进共享内存区域,这个拷贝动作发生在用户态的内存操作,
// 和管道那种"经过内核中转"的方式相比,少了很多额外的开销
strcpy(shared_data, message);
std::cout << "[写入进程] 数据已写入共享内存,准备通知读取进程" << std::endl;
// sem_post 把信号量的值加一,这会唤醒正在 sem_wait 处等待的子进程
sem_post(sem_ready);
// 等待子进程读取完毕、退出
waitpid(pid, nullptr, 0);
// 父进程负责最后的清理工作:
// munmap 解除映射,shm_unlink 真正从系统里删除这个共享内存对象,
// sem_close 关闭信号量句柄,sem_unlink 删除信号量对象本身
munmap(shared_data, SHM_SIZE);
shm_unlink(SHM_NAME);
sem_close(sem_ready);
sem_unlink(SEM_NAME);
std::cout << "[写入进程] 清理完毕,程序结束" << std::endl;
}
return 0;
}
代码关键点解析
shm_open创建具名共享内存对象:这一步类似于"打开一个特殊的文件",但它实际对应的是一段物理内存,而不是磁盘上的文件;SHM_NAME这个名字就是多个进程用来"找到同一份共享内存"的钥匙。ftruncate设置共享内存的大小:新创建出来的共享内存对象默认大小是 0,必须显式调用ftruncate把它扩展到我们需要的字节数(这里是SHM_SIZE),否则后续mmap映射时会出问题。mmap把共享内存映射进本进程的地址空间:调用之后返回的shared_data就是一个普通的char*指针,之后我们完全可以像操作普通内存一样,直接对它进行读写,底层由操作系统保证多个进程看到的都是同一块物理内存。- 为什么要额外引入信号量
sem_ready:如果没有信号量,子进程可能在父进程还没来得及把数据写进共享内存之前,就已经跑去读取共享内存的内容了,读到的就会是一堆没有意义的"垃圾数据"。信号量在这里的作用,就是让子进程老老实实地"等着",直到父进程明确发出"数据已经准备好了"的信号(sem_post)之后,才允许继续往下执行,这正是共享内存必须配合同步机制使用的原因。 sem_wait和sem_post的配合:sem_wait会一直阻塞,直到信号量的值大于 0;sem_post则会把信号量的值加一,从而唤醒正在等待的一方。可以把信号量的初始值 0 理解成"目前没有可读的数据",父进程写完数据之后调用sem_post,相当于告诉子进程"现在有 1 份数据可以读了"。- 清理资源的必要性(
shm_unlink和sem_unlink):共享内存和信号量都是"系统级"的资源,它们并不会随着进程的结束而自动被销毁(这一点和普通的进程内存不一样),如果不显式调用shm_unlink和sem_unlink清理,这些资源会一直残留在系统里,长期下来会造成资源泄漏,所以父进程在最后一定要负责把它们清理干净。
十、共享内存整体交互结构图
我们用 Mermaid 图把上面这段代码里"共享内存 + 信号量"的协作关系画出来:
十一、套接字(Sockets)
套接字是功能最强大、应用最广泛的一种 IPC 方式,它既可以用于同一台机器内部的进程通信,也可以用于跨网络的进程通信,几乎所有的网络应用(浏览器、邮件客户端、文件共享工具)以及很多操作系统级的服务(比如 DNS、NFS)都是建立在套接字之上的。
11.1 面向连接 vs 无连接
套接字支持两种截然不同的通信方式:
| 通信方式 | 特点 | 典型应用场景 |
|---|---|---|
| 面向连接(Connection-oriented) | 通信双方在传输数据前,先建立一条可靠的连接,保证数据不丢失、按顺序到达 | 文件传输、远程登录,这类场景必须保证数据完整、顺序正确 |
| 无连接(Connectionless) | 不需要事先建立连接,数据直接发送,不保证一定送达或送达顺序 | 流媒体、实时游戏,这类场景更看重延迟低,可以容忍偶尔丢一点数据 |
11.2 套接字的几个关键优势
- 可靠性:即便通信双方位于不同的机器上,套接字也能提供可靠的数据传输保证(在面向连接的模式下)。
- 可扩展性:可以同时支持大量并发连接,非常适合需要处理高流量的应用。
- 灵活性:可以在套接字的基础上实现各种各样的通信协议,适应不同类型的应用需求。
十二、完整可运行的 C++ 代码示例三:用 TCP 套接字实现本机进程间通信
下面用最经典的"面向连接"套接字(TCP)实现一个服务端和客户端的通信示例:服务端监听本地的一个端口,客户端连接上去发送一条消息,服务端收到后打印出来并回复一句确认信息。
// 文件名:socket_demo.cpp
// 说明:演示使用 TCP 套接字,在同一台机器上实现两个进程(服务端和客户端)之间的通信。
// 通过 fork() 让父进程当服务端、子进程当客户端,模拟真实场景中两个独立进程的交互。
// 编译方式(仅适用于 Linux/类 Unix 系统):
// g++ -std=c++17 -O2 socket_demo.cpp -o socket_demo
#include <sys/socket.h> // 提供 socket、bind、listen、accept、connect 等套接字相关函数
#include <netinet/in.h> // 提供 sockaddr_in 结构体和地址族相关的宏
#include <arpa/inet.h> // 提供地址转换相关的函数
#include <unistd.h> // 提供 fork、close、read、write、sleep
#include <sys/wait.h> // 提供 waitpid
#include <cstring> // 提供 memset、strlen
#include <iostream> // 提供标准输入输出
constexpr int PORT = 8848; // 服务端监听的端口号,选一个不常用的端口避免冲突
constexpr int BACKLOG = 5; // 允许排队等待处理的连接请求数量上限
// 服务端逻辑:监听端口,接受一个客户端连接,收发一条消息
void run_server() {
// 创建一个 TCP 套接字:
// AF_INET 表示使用 IPv4,SOCK_STREAM 表示面向连接的字节流协议(也就是 TCP)
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
std::cerr << "[服务端] 创建套接字失败" << std::endl;
return;
}
// 设置端口复用选项,避免程序重启时出现"端口仍被占用"的报错,方便反复测试
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 填写服务端要绑定的地址信息
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET; // 使用 IPv4
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网络接口
server_addr.sin_port = htons(PORT); // htons 把端口号转换成网络字节序
// 把套接字和上面填写好的地址、端口绑定在一起
if (bind(server_fd, reinterpret_cast<sockaddr*>(&server_addr), sizeof(server_addr)) < 0) {
std::cerr << "[服务端] 绑定端口失败" << std::endl;
close(server_fd);
return;
}
// 开始监听,BACKLOG 表示最多允许多少个连接请求在"排队"等待被 accept
if (listen(server_fd, BACKLOG) < 0) {
std::cerr << "[服务端] 监听失败" << std::endl;
close(server_fd);
return;
}
std::cout << "[服务端] 正在监听端口 " << PORT << ",等待客户端连接……" << std::endl;
// accept 会阻塞,直到有一个客户端真正连接进来,
// 返回值 client_fd 是一个全新的、专门用于和这个客户端通信的文件描述符
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, reinterpret_cast<sockaddr*>(&client_addr), &client_len);
if (client_fd < 0) {
std::cerr << "[服务端] 接受连接失败" << std::endl;
close(server_fd);
return;
}
std::cout << "[服务端] 客户端已连接,开始接收数据" << std::endl;
// 从客户端读取数据
char buffer[256] = {0};
ssize_t bytes_read = read(client_fd, buffer, sizeof(buffer) - 1);
std::cout << "[服务端] 收到客户端消息: " << buffer
<< "(共 " << bytes_read << " 字节)" << std::endl;
// 给客户端回复一条确认信息
const char* reply = "服务端已收到你的消息!";
write(client_fd, reply, strlen(reply));
// 通信结束,关闭这次连接专属的文件描述符,以及服务端自身的监听套接字
close(client_fd);
close(server_fd);
}
// 客户端逻辑:连接到服务端,发送一条消息,接收回复
void run_client() {
// 客户端稍微睡眠一下,确保服务端已经进入监听状态,避免连接时服务端还没准备好
sleep(1);
// 创建客户端自己的 TCP 套接字
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd < 0) {
std::cerr << "[客户端] 创建套接字失败" << std::endl;
return;
}
// 填写要连接的服务端地址信息
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
// 把点分十进制的地址字符串"127.0.0.1"(表示本机)转换成套接字要求的二进制格式
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// 发起连接请求,这一步对应"面向连接"通信中的"建立连接"阶段
if (connect(client_fd, reinterpret_cast<sockaddr*>(&server_addr), sizeof(server_addr)) < 0) {
std::cerr << "[客户端] 连接服务端失败" << std::endl;
close(client_fd);
return;
}
std::cout << " [客户端] 已连接到服务端,发送消息" << std::endl;
// 向服务端发送一条消息
const char* message = "你好,服务端,我是客户端!";
write(client_fd, message, strlen(message));
// 等待并读取服务端的回复
char buffer[256] = {0};
read(client_fd, buffer, sizeof(buffer) - 1);
std::cout << " [客户端] 收到服务端回复: " << buffer << std::endl;
close(client_fd); // 通信结束,关闭套接字
}
int main() {
// 用 fork 模拟两个独立的进程:父进程扮演服务端,子进程扮演客户端
pid_t pid = fork();
if (pid < 0) {
std::cerr << "创建子进程失败" << std::endl;
return 1;
} else if (pid == 0) {
// 子进程:扮演客户端
run_client();
return 0;
} else {
// 父进程:扮演服务端
run_server();
// 等待子进程(客户端)结束
waitpid(pid, nullptr, 0);
}
return 0;
}
代码关键点解析
socket(AF_INET, SOCK_STREAM, 0):创建一个套接字,AF_INET表示使用 IPv4 地址族,SOCK_STREAM表示这是一个面向连接、保证顺序和可靠性的字节流套接字(也就是我们常说的 TCP);如果换成SOCK_DGRAM,就会变成无连接的 UDP 套接字。- 服务端的标准四步:
bind→listen→accept→ 读写数据:bind把套接字绑定到一个具体的端口上;listen让内核开始为这个套接字维护一个"待处理连接"的队列;accept阻塞等待,直到有客户端真正连接进来,返回一个专属于这次连接的新文件描述符,后续所有的读写都通过这个新的文件描述符进行,而不是最初那个监听用的server_fd。 - 客户端的标准两步:
connect→ 读写数据:客户端不需要bind和listen,只需要用connect主动向服务端发起连接请求,连接建立成功之后就可以直接读写数据了。 htons和inet_pton的作用:网络传输中,端口号和 IP 地址都需要转换成统一的"网络字节序"格式,htons(host to network short)负责端口号的转换,inet_pton负责把人类可读的点分十进制 IP 地址字符串(如"127.0.0.1")转换成套接字底层需要的二进制格式。- 为什么客户端要先
sleep(1):这只是为了保证服务端有足够的时间完成bind和listen,真正进入监听状态之后,客户端再发起连接,避免出现"客户端过早尝试连接,但服务端还没准备好"导致连接失败的情况(在真实的生产级代码中,通常会用重试机制来代替这种简单的睡眠等待)。 - 和前面共享内存示例的本质区别:这里父子进程之间交换数据,走的是完整的"网络协议栈"(哪怕通信双方其实就在同一台机器上),数据需要经过内核的缓冲区中转,相比共享内存那种"直接读写同一块物理内存"的方式,会有更多的开销,但套接字的好处是——只需要稍微修改一下 IP 地址,这段代码几乎不用改动,就能变成跨越两台不同机器的网络通信程序,这正是套接字被称为"网络应用基石"的原因。
十三、微服务场景下的异步 IPC:以日志处理为例
题目里提到了一个很贴近实际工程的例子:基于微服务架构的应用程序,本质上就是"不同的进程之间用异步的方式互相通信"的一个典型代表。
举个具体例子——日志处理系统:多个不同的进程各自在运行过程中产生日志条目,它们会把这些日志发送给专门的另一个进程去做进一步处理,比如格式化、去重、统计分析等。这里的关键点在于:日志的生产者进程,只管把日志内容发出去,完全不需要等待处理进程给出任何回复,发送完就可以继续做自己手头的工作。
我们用一张时序图来展示这个过程:
从图中能看出,生产者进程(P1、P2)和日志处理进程之间的交互,完全是"发了就跑"的模式,这正是异步 IPC 在真实工程场景中最典型的应用方式之一,通常这类场景背后会采用消息队列这类支持异步收发的 IPC 机制来实现。
十四、各 IPC 机制的知识地图(ASCII 树)
Linux IPC 机制
|
+-- 管道 / 命名管道
| |
| +-- 普通管道:只能用于父子进程,单向传输
| +-- 命名管道(FIFO):任意进程都可通过文件路径通信
|
+-- 信号(Signal)
| |
| +-- 不传输数据,只是一种通知机制
|
+-- 消息队列
| |
| +-- 按先进先出顺序存放消息,支持异步收发
|
+-- 信号量
| |
| +-- 不传输数据,专门用于同步、控制资源访问数量
|
+-- 共享内存
| |
| +-- 多进程共享同一块物理内存,速度最快
| +-- 必须配合信号量或互斥锁使用
|
+-- 套接字
|
+-- 面向连接(如TCP):可靠、有序,适合文件传输
+-- 无连接(如UDP):低延迟,适合流媒体、实时游戏
十五、小结
| 要点 | 说明 |
|---|---|
| IPC 的意义 | 让默认互相隔离的进程之间能够交换数据、协调工作 |
| 最常用的两种机制 | 共享内存(单机内高性能通信)、套接字(跨服务器网络通信) |
| 管道与命名管道 | 结构简单、单向传输,命名管道解决了"必须有亲缘关系"的限制 |
| 信号 | 不传输数据,只做事件通知 |
| 消息队列 | 支持异步的先进先出式消息收发 |
| 信号量 | 不传输数据,专门用来做同步、防止竞态条件 |
| 共享内存 | 速度最快,因为省去了数据在进程间反复拷贝的开销,但必须搭配同步机制使用 |
| 套接字 | 既能用于本机也能用于跨网络,是绝大多数网络应用的通信基础 |
| 工程实践中的体现 | 微服务架构下的异步通信(如日志处理系统),生产者发送数据后无需等待回复 |
理解了这些 IPC 机制的原理和适用场景之后,我们就能在实际设计一个多进程协作的系统时,根据数据量大小、是否需要跨机器通信、是否需要保证可靠性等因素,选出最合适的通信方式,而不是不管什么场景都用同一种"万能方案"。
Linux中的服务与守护进程(Daemons)从零理解
1. 先建立直觉:守护进程是什么
打开任务管理器或者用 ps 命令看一眼系统里正在运行的进程,会发现有一大批进程根本没有界面,也没人在直接操作它们,它们就那么安安静静地待在后台,一直在干活。这一类进程就叫守护进程(daemon)。
它们通常有一个很好认的命名习惯:名字末尾带一个字母d,比如负责SSH远程登录的 sshd,负责网页服务的 httpd。这个 d 就是 daemon 的缩写,看到某个进程名字后面跟着个 d,基本可以猜到它是一个在后台默默干活的守护进程。
守护进程要做的事情五花八门:提供文件服务、提供网页服务、处理网络通信、记录日志、监控系统状态……几乎所有"系统级别、必须一直运行、不需要用户天天盯着"的任务,背后都是靠守护进程来撑着的。它们的共同特点是:从系统开机就自己启动起来,一直运行到系统关机为止,中间完全不需要人去手动干预。
2. 守护进程和普通进程比,特别在哪
普通进程通常是用户主动敲命令启动的,跑在某个终端窗口下面,用户能直接看到它的输出、也能直接跟它交互。守护进程完全不是这么回事,它有三个很鲜明的特点:
第一,在后台运行:守护进程没有"控制终端",也就是说它压根就不挂在某个终端窗口下面,不需要用户界面,也不需要人工干预就能把自己的活干完。
第二,独立于用户会话:不管当前有没有用户登录、有没有人开着终端,守护进程该怎么跑还怎么跑,完全不依赖某个具体的用户会话,它是靠"系统事件"或者"外部请求"来触发自己该做什么,而不是靠人在旁边敲命令告诉它下一步干什么。
第三,面向特定任务:每一个守护进程通常只专注做好一件事(或者一小类事),比如专门处理网页请求、专门处理数据库读写、专门收集日志,各司其职,这样才能保证每一类任务都能被高效、稳定地处理。
用一张表总结一下这三点:
| 特点 | 具体表现 |
|---|---|
| 后台运行 | 没有控制终端,不需要用户界面,不需要人工干预 |
| 独立于用户会话 | 不依赖具体用户登录状态,靠系统事件或外部请求驱动 |
| 面向特定任务 | 每个守护进程专注处理特定的功能或监听特定的事件 |
3. 怎么把一个普通进程变成守护进程
光是"把一个程序丢到后台跑",还算不上真正意义上的守护进程。要让一个进程真正符合守护进程的规范,通常需要按顺序做好下面这五件事:
下面把这五步分别讲清楚。
3.1 第1步:脱离终端(fork)
用 fork() 复制出一个子进程,然后让父进程直接退出,只留子进程继续在后台跑。从用户或者shell的角度看,父进程一退出,这条命令就算是"执行完毕"了,不会再占据当前这个终端窗口,子进程则悄悄地留在了后台继续运行。
3.2 第2步:创建新会话(setsid)
setsid() 这个系统调用会做一件很关键的事:让调用它的进程变成一个全新会话的"会话首领",同时也是一个全新进程组的组长,并且跟原来的控制终端彻底断开关系。这一步是真正让进程"脱离终端"的关键——单靠上一步的fork,子进程理论上依然可能被终端信号影响到,只有调用了 setsid(),才算是跟终端彻底划清了界限。
这里有一个容易被忽略的技术细节:只有非会话首领的进程才能成功调用setsid(),而刚fork出来的子进程,天然就不是任何会话的首领,所以这一步必须放在fork之后执行,顺序不能反。
3.3 第3步:切换工作目录
如果守护进程的当前工作目录停留在某个普通的文件系统上,那么只要这个守护进程还活着,那个文件系统就会一直处于"正被占用"的状态,管理员没法把它正常卸载(umount)。为了避免出现这种"进程赖着不走,文件系统卸不掉"的尴尬情况,守护进程通常会把自己的工作目录切换到根目录,因为根目录基本不会被卸载。
3.4 第4步:处理文件描述符
守护进程从父进程那里继承来的标准输入、标准输出、标准错误这几个文件描述符,因为已经没有终端了,留着也没什么意义,通常的做法是关掉它们,然后把这三个描述符重新指向 /dev/null——这样即使代码里某个地方不小心还在往标准输出写东西,也只是被 /dev/null 安静地"吃掉",不会因为写入一个已经关闭的文件描述符而报错崩溃。
3.5 第5步:处理信号
守护进程要长期稳定运行,离不开对信号的妥善处理,最常见的两个是:
- SIGHUP:约定俗成的用法是"收到这个信号就重新加载配置文件",而不是退出——这样管理员改完配置,不需要真的把服务停掉重启,发一个SIGHUP就能让它读取最新配置。
- SIGTERM:请求进程"优雅地"停下来,进程收到之后应该先做完必要的收尾工作(比如保存状态、关闭连接),再真正退出,而不是被粗暴地立刻杀死。
做完这五步之后,进程就会进入一个长期运行的主循环,持续地等待、处理各种事件,这个循环通常会一直跑到进程收到终止信号为止。
4. 用C++代码完整实现一个守护进程
下面这份代码,把上面讲的五个步骤全部实现了一遍,写成一个真正能运行起来的守护进程程序:
#include <iostream>
#include <fstream>
#include <cstdlib>
#include <csignal>
#include <unistd.h> // fork(), setsid(), chdir(), close(), sleep()
#include <sys/stat.h> // umask()
#include <fcntl.h> // open()
// 用来控制主循环是否要继续跑下去;收到SIGTERM后会被信号处理函数改成0
// 用volatile sig_atomic_t是因为这个变量会被信号处理函数直接修改,
// 这个类型能保证在"正常代码"和"信号处理代码"之间读写这个变量是安全的
volatile sig_atomic_t keepRunning = 1;
// 标记"是否需要重新加载配置";收到SIGHUP后会被置为1
volatile sig_atomic_t reloadConfig = 0;
// SIGTERM的处理函数:不在这里直接调用exit,而是只改一个标志位,
// 让主循环在下一次检查的时候自己跳出去,从容地做清理工作再结束,
// 这就是"优雅关闭"的做法
void handleSigterm(int) {
keepRunning = 0;
}
// SIGHUP的处理函数:同样只是打一个标记,
// 真正"重新读取配置文件"的具体逻辑放在主循环里去做,
// 这是为了避免在信号处理函数里做太复杂、不安全的操作
void handleSighup(int) {
reloadConfig = 1;
}
int main() {
// ---------- 第1步:fork(),让父进程退出,子进程留在后台 ----------
pid_t pid = fork();
if (pid < 0) {
// fork失败,直接以失败状态退出
exit(EXIT_FAILURE);
}
if (pid > 0) {
// 父进程:任务已经完成(子进程已经创建出来了),可以立刻退出,
// shell看到父进程退出,就会认为这条命令已经执行完毕,把终端交还给用户
exit(EXIT_SUCCESS);
}
// 能执行到这里的,只有子进程(此时pid == 0)
// ---------- 第2步:setsid(),创建新会话,彻底摆脱终端控制 ----------
// setsid()会让当前进程:
// 1. 成为一个新会话的会话首领
// 2. 成为一个新进程组的组长
// 3. 不再关联任何控制终端
// 只有"非会话首领"的进程才能调用成功,所以必须放在fork之后
if (setsid() < 0) {
exit(EXIT_FAILURE);
}
// ---------- 第3步:切换工作目录到根目录 ----------
// 避免守护进程占用某个普通目录所在的文件系统,导致那个文件系统无法被卸载
if (chdir("/") < 0) {
exit(EXIT_FAILURE);
}
// 顺手把权限掩码清零,避免继承自父进程的umask限制了
// 这个守护进程以后创建文件、目录时的权限设置
umask(0);
// ---------- 第4步:处理文件描述符 ----------
// 关掉从父进程继承来的标准输入/输出/错误,它们已经没有意义了
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
// 把标准输入/输出/错误重新指向/dev/null,
// 这样代码里万一还有地方用了std::cout或者printf,
// 也只是被/dev/null安静地丢弃,不会因为写入了一个已关闭的描述符而出错
int devNull = open("/dev/null", O_RDWR);
if (devNull >= 0) {
dup2(devNull, STDIN_FILENO);
dup2(devNull, STDOUT_FILENO);
dup2(devNull, STDERR_FILENO);
if (devNull > STDERR_FILENO) {
close(devNull);
}
}
// ---------- 第5步:注册信号处理 ----------
signal(SIGTERM, handleSigterm); // 请求优雅关闭
signal(SIGHUP, handleSighup); // 请求重新加载配置
// ---------- 进入主循环,这才是守护进程真正干活的地方 ----------
// 因为标准输出已经被重定向到/dev/null,这里改用写日志文件的方式,
// 用来证明这个守护进程确实还在后台持续运行着,
// 可以在另一个终端里用 tail -f /tmp/my_daemon.log 实时观察它的输出
std::ofstream logFile("/tmp/my_daemon.log", std::ios::app);
int counter = 0;
while (keepRunning) {
if (reloadConfig) {
// 收到SIGHUP之后,在这里执行"重新读取配置文件"的具体逻辑
logFile << "[守护进程] 收到SIGHUP,正在重新加载配置..." << std::endl;
logFile.flush();
reloadConfig = 0;
}
logFile << "[守护进程] 第 " << counter++ << " 次心跳,仍在后台正常运行" << std::endl;
logFile.flush();
sleep(5); // 模拟守护进程"每隔一段时间检查一次事件"这种常见工作方式
}
// 跳出主循环之后,说明是收到了SIGTERM,在这里做真正的收尾清理工作
logFile << "[守护进程] 收到SIGTERM,正在完成清理工作后退出" << std::endl;
logFile.close();
return 0;
}
这段代码的关键点:
- 代码结构完全对照前面讲的五个步骤展开:先
fork()让父进程退出,再setsid()创建新会话,接着chdir("/")切换工作目录,然后关闭并重定向标准输入输出错误,最后注册好SIGTERM和SIGHUP的处理函数,才真正进入主循环。 keepRunning和reloadConfig这两个标志位是信号处理函数和主循环之间"沟通"的桥梁:信号处理函数本身应该尽量简单、只做最必要的事(这里只是改一个标志位),真正复杂的逻辑(重新读配置、清理资源)都放到主循环里安全地执行,这是编写信号处理函数时一个很重要的习惯。- 因为守护进程已经没有终端可以打印信息了,代码里用写日志文件的方式来观察它的运行状态,这也是真实系统里绝大多数守护进程的通用做法——运行状态、错误信息都写进日志文件,而不是指望有人盯着某个终端窗口看。
- 编译运行这个程序后,可以用
ps -ef | grep my_daemon之类的命令确认它确实脱离了终端在后台运行,用kill -HUP <PID>触发一次"重新加载配置",用kill -TERM <PID>触发它优雅退出。
5. 完整流程的时序图
把"守护进程启动"和"管理员后续通过信号跟它交互"这两件事放到一张时序图里,会更容易看清楚整个生命周期:
6. 守护进程之间怎么打交道
守护进程虽然各自独立运行、互不干扰,但很多时候还是需要跟其他进程或者别的守护进程交换信息,具体用什么方式,取决于场景需要,常见的包括前面已经讲过的信号、共享内存、消息队列这些进程间通信(IPC)手段,选哪一种主要看"要传递的数据量大小"和"需不需要保证顺序"这类实际需求。
7. 实际生活里常见的守护进程举例
日常使用的很多系统功能,背后都有对应的守护进程在默默支撑着:
| 用途 | 典型守护进程 | 做的事情 |
|---|---|---|
| 网页服务 | httpd、nginx | 响应客户端的网页请求,同时处理很多个并发请求,保证网页访问流畅 |
| 数据库服务 | mysqld、postgresql | 管理数据库,允许不同应用异步地读写数据库 |
| 文件服务 | smbd、nfsd | 提供网络文件共享服务,让不同系统之间可以异步地共享和访问文件 |
| 日志与监控 | syslogd、snmpd | 收集并记录系统事件,对系统的健康状况和运行性能做异步监控 |
8. 小结
守护进程是Linux系统里非常重要的一类特殊进程,它们不需要用户直接操作,从系统启动那一刻就自己跑起来,一直安静地在后台干活,直到系统关机为止。要把一个普通程序真正变成规范的守护进程,需要按顺序完成脱离终端、创建新会话、切换工作目录、处理文件描述符、注册信号处理这五个步骤,其中 setsid() 是让进程真正跟终端划清界限的关键一步,而妥善处理 SIGHUP(重新加载配置)和 SIGTERM(优雅关闭)这两个信号,则是守护进程能够长期稳定运行、又能被管理员灵活控制的重要保障。前面讲过,一个进程可以拥有一个或多个执行线程,守护进程本质上也还是一种进程,只是在启动方式和运行习惯上有着这些独特的规范,接下来就要正式进入"线程"这个话题了。
线程(Threads)详解
一、先把"进程"和"线程"这两个概念重新对比一遍
进程(process)和线程(thread)是实现"代码并发执行"的两种基本方式,但它们在具体运作方式和资源管理上有着本质区别。
一个进程是"一个正在运行的程序的实例",它拥有一整套属于自己私有的资源,包括内存空间、文件描述符、执行上下文等等。进程之间是彼此隔离的,这种隔离性带来了很强的系统稳定性——一个进程崩溃了,通常不会连累到其他进程。
线程则是计算机科学里一个非常基础的概念,可以理解成"在同一个进程内部,轻量级地同时执行多个任务"的一种方式。和进程不同,线程并不是独立的实体,而是和它所属的那个进程紧密绑定在一起——线程会和同一进程内的其他线程共享同一份内存空间,以及这个进程拥有的所有资源,包括文件描述符、堆内存,以及进程分配的任何全局数据结构。
二、线程最大的优势:数据交换又快又简单
线程最核心的优势之一,就是通信和共享数据的效率非常高。既然同一个进程内的所有线程本来就共享着同一份内存空间,那么它们之间要交换数据,直接读写同一个变量就行了,完全不需要像不同进程之间那样,依赖复杂的 IPC(进程间通信)机制。这种"天然共享"的环境,让线程之间的数据交换非常迅速,也让实现各种并发算法和并发数据结构变得更加方便。
三、硬币的另一面:共享内存带来的同步难题
不过,"共享同一份内存"这件事也是一把双刃剑:它同时带来了一个新的挑战——如何管理多个线程对共享资源的访问。如果放任多个线程同时随意读写同一份数据,很容易造成数据损坏,破坏数据的完整性。
为了避免这种情况,线程之间必须使用同步机制(synchronization mechanisms),比如锁(lock)、信号量(semaphore)或者互斥锁(mutex)。这些机制的作用,就是给"访问共享资源"这件事制定规则和协议,确保在任意一个时刻,只有一个线程能够访问某一份特定的资源。
在多线程编程中,做好同步是极其关键的一环,它直接决定了程序能否避免竞态条件、死锁以及其他与并发相关的各种问题。
四、常见的同步原语(Synchronization Primitives)
为了解决上述这些同步难题,业界发展出了多种同步原语和技术,下面用一张表格梳理一下最常见的三种:
| 同步原语 | 作用 | 一句话理解 |
|---|---|---|
| 互斥锁(Mutex) | 保证某一时刻只有一个线程能访问共享资源,实现独占访问 | “这个资源同一时间只能有一个人用,用完了再让下一个人用” |
| 信号量(Semaphore) | 允许同时有若干个(数量可控)线程访问某种有限的资源 | “这个资源池最多容纳N个人同时使用,超过数量就得排队” |
| 条件变量(Condition Variable) | 让线程可以"等待"某个特定条件被满足之后,再继续往下执行 | “我先在这儿等着,等条件成立了你再喊我起来干活” |
通过谨慎地管理同步、并采用恰当的并发设计模式,开发者就能充分发挥线程的威力,让应用程序获得高性能和良好的可扩展性。线程尤其适合那些可以被拆分成多个独立部分并行处理的任务,比如图像处理、科学计算模拟,以及 Web 服务器中多个独立请求的并发处理。
五、用 Mermaid 图梳理"进程"与"线程"的关系
从这张图能清楚看出:线程1、线程2、线程3 全都挤在进程A内部,共享同一份内存,可以直接读写;而进程A和进程B作为两个完全独立的实体,彼此的内存空间是完全隔离、互相看不见的。
六、用 C++ 代码演示"互斥锁 + 条件变量"协作的经典场景:生产者-消费者模型
下面这份代码,用一个非常经典的"生产者-消费者"场景,把互斥锁和条件变量结合起来演示:生产者线程不断往一个共享队列里放数据,消费者线程则在队列为空时"耐心等待",一旦队列里有数据了立刻被唤醒去处理。
#include <iostream> // 标准输入输出
#include <thread> // std::thread
#include <queue> // std::queue,用作共享队列
#include <mutex> // std::mutex,互斥锁
#include <condition_variable> // std::condition_variable,条件变量
#include <chrono> // 用于模拟耗时操作
std::queue<int> sharedQueue; // 生产者和消费者共享的队列
std::mutex queueMutex; // 保护 sharedQueue 的互斥锁
std::condition_variable queueCV; // 用来通知"队列里有新数据了"的条件变量
bool productionFinished = false; // 标记生产者是否已经生产完毕
// ------------------------------------------------------------
// producer:生产者线程要执行的函数。
// 每生产一个数据,就把它放进共享队列,然后通知消费者"有新数据了"。
// ------------------------------------------------------------
void producer(int itemCount) {
for (int i = 1; i <= itemCount; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 模拟生产耗时
{
std::lock_guard<std::mutex> lock(queueMutex); // 加锁,保护共享队列
sharedQueue.push(i);
std::cout << "[生产者] 生产了数据: " << i << std::endl;
} // 离开这个作用域,lock_guard 自动解锁
queueCV.notify_one(); // 唤醒一个正在等待的消费者线程
}
{
std::lock_guard<std::mutex> lock(queueMutex);
productionFinished = true; // 通知消费者:不会再有新数据了
}
queueCV.notify_all(); // 把所有可能还在等待的消费者都叫醒,让它们检查退出条件
}
// ------------------------------------------------------------
// consumer:消费者线程要执行的函数。
// 不断尝试从共享队列里取数据;如果队列是空的,就调用 wait() 进入等待状态,
// 直到被 producer 通过 notify_one()/notify_all() 唤醒为止。
// ------------------------------------------------------------
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(queueMutex); // 注意这里用 unique_lock,因为 wait() 需要能临时解锁
// wait() 的第二个参数是一个"判断条件"的函数:
// 只要队列非空,或者生产已经结束,就不再等待,继续往下走;
// 否则就释放锁并进入休眠,直到被 notify 唤醒后再重新检查这个条件。
queueCV.wait(lock, [] { return !sharedQueue.empty() || productionFinished; });
if (sharedQueue.empty() && productionFinished) {
// 队列空了,并且生产者也已经结束生产,说明没活干了,退出循环
break;
}
int value = sharedQueue.front();
sharedQueue.pop();
lock.unlock(); // 处理数据之前,先把锁释放掉,不要在处理数据时还占着锁
std::cout << " [消费者] 取出并处理数据: " << value << std::endl;
}
}
int main() {
const int totalItems = 5;
std::thread producerThread(producer, totalItems);
std::thread consumerThread(consumer);
producerThread.join();
consumerThread.join();
std::cout << "生产者和消费者都已完成工作。" << std::endl;
return 0;
}
代码关键点解析
sharedQueue:这就是生产者和消费者两个线程共享的那份内存——一个std::queue<int>,完全对应"线程共享同一进程内存空间"这句话。queueMutex(互斥锁):每次读写sharedQueue之前都必须先拿到这把锁,确保同一时刻只有一个线程在操作这个队列,避免出现数据错乱。queueCV(条件变量):这是这段代码的精髓所在。如果消费者只是一直傻乎乎地"轮询"检查队列是否为空,会白白浪费大量 CPU;而条件变量的作用是让消费者在队列为空时真正进入休眠,什么都不做,直到生产者往队列里放了新数据、调用notify_one()之后,消费者才会被重新唤醒、去检查条件是否满足。wait(lock, 条件函数):这一行代码做了两件事——如果条件函数返回false(说明还不满足继续执行的条件),就自动释放lock并进入休眠;一旦被唤醒,又会自动重新加锁,再检查一遍条件函数,只有条件为true才会真正往下继续执行。这个"自动释放锁再重新加锁"的能力,正是为什么这里必须用std::unique_lock而不能用std::lock_guard——因为lock_guard不支持中途解锁再加锁。productionFinished标志位:用来告诉消费者"生产者已经不会再生产新数据了",配合"队列为空"这个条件,消费者就能判断出"确实没活干了,可以放心退出",而不会永远傻等下去。
编译运行方式:
g++ -std=c++17 -pthread producer_consumer.cpp -o demo
./demo
七、系统线程不够用时怎么办——引出"用户线程"和"协程"
前面讲到的这些线程,严格来说都是系统线程(system thread):它们由操作系统内核负责创建和管理。但在某些场景下(后续章节会深入讨论),程序可能需要非常多数量的线程,而系统资源却不一定够用来创建这么多真正的系统线程——毕竟每一个系统线程都需要占用一定的内核资源和调度开销。
这个问题的解决方案,就是使用用户线程(user thread)。实现用户线程的一种途径,就是协程(coroutine)——协程从 C++20 开始被正式纳入 C++ 标准。
八、协程(Coroutine)到底是什么
协程是 C++ 里一个相对较新的特性。可以把协程定义为:一种可以在特定位置暂停、并在之后从暂停的地方恢复执行的函数,这使得在同一个线程内部实现"协作式多任务"成为可能。
和普通函数"一旦开始执行就必须从头跑到尾、中途不能被打断"不同,协程可以在执行到一半时主动挂起(suspend)、把控制权交还给调用者;调用者之后可以再次**恢复(resume)**这个协程,让它从上次暂停的地方继续往下执行。
协程相比系统线程的几个特点
- 更加轻量:协程的创建和销毁比系统线程快得多,所需要的额外开销也小得多。
- 是"协作式"的:协程必须主动把控制权让出去,才能实现执行上下文的切换。这在某些场景下可能是缺点(如果协程写得不好,忘了让出控制权,可能会一直占着不放),但换个角度看,这也是优点——它把"什么时候切换执行上下文"这个决定权,交还给了程序本身,而不是像抢占式调度那样完全由操作系统说了算。
协程可以用来实现多种不同的并发模式,比如: - 任务(task):一种轻量级的工作单元,可以被调度并发运行。
- 通道(channel):一种在协程之间传递数据的通信渠道。
协程还可以分为有栈协程(stackful)和无栈协程(stackless)两大类,C++20 引入的协程属于无栈这一类(这部分的深入原理留待后续章节详细讨论)。
协程和线程的本质区别(一定要分清楚)
需要特别强调一点:协程本身并不能独立实现真正的并行,因为协程终究还是需要一个"CPU 执行上下文"才能运行,而这个执行上下文只能由线程来提供。也就是说,协程只是在某一个线程内部实现了"更细粒度的任务切换",它解决的是"轻量级并发调度"的问题,而不是"利用多核硬件同时执行"这个并行问题——这两者不要混为一谈。
用一张简化的图来区分"有栈协程"和"无栈协程"的直观差别:
有栈协程(stackful)示意:
每个协程都拥有一份完整独立的"调用栈"
[协程1的独立栈] [协程2的独立栈] [协程3的独立栈]
可以在栈上任意深的函数调用层级里挂起和恢复
无栈协程(stackless,C++20采用)示意:
协程的状态被打包保存进一个专门的"协程帧"对象里,
不需要一份独立、完整的调用栈
[协程1的协程帧] [协程2的协程帧] [协程3的协程帧]
只能在协程函数自身内部挂起,不能在它调用的普通函数深处挂起
九、用 C++20 代码演示一个最基础的协程
下面这份代码实现了一个最简化的"生成器(generator)"协程:协程每执行一步就 co_yield 出一个数值,调用方每次想要下一个数值时,就"恢复"这个协程继续往下跑。这是理解"挂起与恢复"最直观的一个例子。
#include <iostream> // 标准输入输出
#include <coroutine> // C++20 协程支持
// ------------------------------------------------------------
// Generator:一个简化的协程返回类型,用来包装"能够逐步产出数值"的协程。
// 里面的 promise_type 是 C++ 协程机制规定必须提供的一个内嵌类型,
// 编译器会根据 promise_type 里定义的这些函数,
// 自动生成协程在"创建、挂起、恢复、结束"各个阶段该做的事情。
// ------------------------------------------------------------
struct Generator {
struct promise_type {
int currentValue{}; // 保存协程当前 co_yield 出来的数值
// 协程创建时,第一次执行到 co_yield 或结束之前,是否要先挂起。
// 这里返回 suspend_always,表示协程一创建出来就先暂停,
// 等调用方主动第一次 resume() 才真正开始执行协程内容。
std::suspend_always initial_suspend() { return {}; }
// 协程执行完毕(走到函数末尾)之后,是否要挂起。
// 返回 suspend_always,方便我们在协程真正销毁之前,
// 还能安全地访问它的一些状态。
std::suspend_always final_suspend() noexcept { return {}; }
// 每次协程代码里执行 co_yield value; 时,会调用这个函数,
// 把 value 保存下来,并挂起协程,把控制权交还给调用者。
std::suspend_always yield_value(int value) {
currentValue = value;
return {};
}
// 协程函数本身必须返回一个 Generator 对象,
// get_return_object() 就是用来构造这个返回值的。
Generator get_return_object() {
return Generator{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
// 协程正常执行到函数末尾(没有显式返回值)时会调用这个函数
void return_void() {}
// 协程内部如果抛出了未捕获的异常,会调用这个函数;
// 这里简单地直接重新抛出异常。
void unhandled_exception() { std::rethrow_exception(std::current_exception()); }
};
// 协程句柄,用来控制这个协程的挂起、恢复、销毁
std::coroutine_handle<promise_type> handle;
explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
// 析构时要负责销毁协程帧,避免内存泄漏
~Generator() {
if (handle) {
handle.destroy();
}
}
// 恢复协程执行,直到下一次 co_yield 或者协程结束为止。
// 返回值表示"协程是否还没结束"(true = 还能继续取值)。
bool moveNext() {
if (handle.done()) {
return false;
}
handle.resume(); // 真正恢复协程执行
return !handle.done();
}
// 取出协程最近一次 co_yield 出来的数值
int currentValue() const {
return handle.promise().currentValue;
}
};
// ------------------------------------------------------------
// countUpTo:这是一个真正的协程函数。
// 只要函数体里出现了 co_yield,编译器就会把这个函数当作协程来特殊处理。
// 它会从 1 数到 n,每数一个数字就暂停一次,把这个数字交给调用者。
// ------------------------------------------------------------
Generator countUpTo(int n) {
for (int i = 1; i <= n; ++i) {
co_yield i; // 挂起协程,把 i 交给调用者,等待下一次被恢复
}
// 循环结束后,函数自然走到末尾,等价于隐式的 co_return;
}
int main() {
Generator gen = countUpTo(5); // 创建协程对象,此时协程代码还没有真正开始执行
std::cout << "开始逐个获取协程产出的数值:" << std::endl;
while (gen.moveNext()) { // 每调用一次,协程就往前推进一步,产出下一个数值
std::cout << "取得数值: " << gen.currentValue() << std::endl;
}
std::cout << "协程已经执行完毕。" << std::endl;
return 0;
}
代码关键点解析
promise_type:这是 C++ 协程机制规定必须提供的一个"约定接口"。编译器在把一个普通函数改写成协程时,会根据这个类型里定义的几个特定函数(initial_suspend、final_suspend、yield_value、get_return_object等),自动生成协程在各个关键节点(创建、挂起、恢复、结束)时应该执行的逻辑,你可以把它理解成"协程行为的说明书"。std::suspend_always:一种编译器内置的"总是挂起"标记,用在initial_suspend里表示协程一创建就先暂停、不立即执行;用在final_suspend里表示协程执行完最后一行代码之后也先暂停,方便外部还能访问它的最终状态。yield_value:每当协程函数内部执行到co_yield i;这一行时,就会调用这个函数,把数值i保存进promise_type里,同时协程本身进入挂起状态,把控制权交还给调用它的那一方。handle.resume():这就是"恢复协程执行"的具体动作,调用之后,协程会从上一次挂起的地方继续往下跑,直到再次遇到co_yield(挂起)或者执行完毕(结束)为止。countUpTo函数:这是真正的协程函数本体。只要函数体内出现了co_yield关键字,编译器就会把它识别成一个协程函数,自动帮你完成状态保存、挂起、恢复等一系列复杂的底层工作,程序员只需要专注在业务逻辑本身(这里就是"从1数到n")。main函数里的调用方式:countUpTo(5)这一句调用并不会立刻执行循环体,而是马上返回一个Generator对象(因为initial_suspend返回的是suspend_always);之后每调用一次gen.moveNext(),协程才会真正往前推进一步、产出下一个数值——这正是"挂起与恢复"这套机制最直观的体现。
编译运行方式(注意:协程是 C++20 的特性,编译器版本要够新,比如 GCC 10 及以上):
g++ -std=c++20 coroutine_demo.cpp -o demo
./demo
运行输出:
开始逐个获取协程产出的数值:
取得数值: 1
取得数值: 2
取得数值: 3
取得数值: 4
取得数值: 5
协程已经执行完毕。
十、用时序图梳理协程"挂起—恢复"的完整过程
十一、小结对比表
| 对比维度 | 系统线程(System Thread) | 协程(Coroutine, C++20) |
|---|---|---|
| 创建管理者 | 操作系统内核 | 程序自身(编译器生成的状态机) |
| 切换控制权方式 | 抢占式,由调度器强制切换 | 协作式,必须主动co_yield/co_await让出 |
| 资源开销 | 较大(需要内核资源支持) | 较小,创建销毁都更轻量 |
| 能否独享CPU执行上下文 | 本身就是一种执行上下文 | 不能独立提供,必须依附在某个线程上运行 |
| 适用场景 | 需要真正并行、抢占式调度的场景 | 需要大量轻量级任务、且能接受协作式切换的场景 |
一句话总结:线程通过共享内存实现了同一进程内多个任务之间的高效协作,但也因此必须依赖互斥锁、信号量、条件变量等同步机制来避免数据竞争;而协程作为一种更轻量的"用户线程"实现方式,用"主动挂起与恢复"取代了线程的抢占式切换,让大量轻量级任务的并发调度变得更加高效,但协程本身始终离不开线程所提供的执行上下文,无法单独实现真正的并行。
线程生命周期(Thread Life Cycle)详解
一、什么是线程的生命周期
线程有时候也被称为轻量级进程(Lightweight Process),之所以这么叫,是因为线程和进程一样,都是"一段正在被执行的代码",但线程比进程要"轻"很多——它不需要像进程那样拥有一整套独立的内存空间,而是寄居在某个进程内部,和同一进程里的其他线程共享大部分资源。
线程的生命周期,指的就是一个线程从"被创建出来"到"最终消亡"这整个过程中所经历的各个阶段。理解这些阶段,就好比理解一个人从出生到成长、工作、退休的整个过程一样——只有搞清楚每个阶段具体在做什么、需要注意什么,才能真正掌握如何写出正确、高效的并发程序。
整个生命周期一共可以划分成四个阶段:
- 创建(Creation)
- 执行(Execution)
- 同步(Synchronization)
- 终止(Termination)
我们先用一张 Mermaid 图,把这四个阶段之间的流转关系画出来,建立一个整体印象:
从图中可以看到,"执行"和"同步"这两个阶段之间是可以来回切换的——线程在执行过程中,一旦遇到需要访问共享资源的地方,就会进入"同步"阶段,等同步完成之后又会回到"执行"阶段继续往下跑,如此反复,直到最终进入"终止"阶段,生命周期才算真正结束。
接下来我们逐一详细展开每一个阶段。
二、第一阶段:创建(Creation)
线程生命周期的起点,是在系统中创建出一个新的线程。这个创建过程通常需要传入几个关键的参数:
- 线程属性(Attributes):比如这个线程的调度策略(决定操作系统怎么给它分配 CPU 时间)、栈的大小、优先级等等。
- 启动例程(Start Routine):也就是这个线程一旦被成功创建出来之后,真正要去执行的那个函数。
一旦创建成功,操作系统就会为这个新线程分配好属于它自己的栈空间以及其他必要的资源,之后这个线程就具备了"独立运行"的能力(虽然它依然和同一进程内的其他线程共享着堆内存等资源)。
三、第二阶段:执行(Execution)
线程被创建出来之后,就会开始执行它被指定的那个启动例程。在执行过程中,线程可以:
- 独立完成自己的任务,不需要依赖别的线程;
- 也可以在必要的时候,和进程内的其他线程互相协作、互相交流。
值得一提的是,每个线程都可以创建和管理属于自己的局部变量和数据结构,这些局部变量存放在线程自己独立的栈空间里,不会和其他线程发生冲突,这也是线程能够"各自独立地并发执行特定任务"的一个重要基础。
四、第三阶段:同步(Synchronization)
当多个线程需要共同访问同一份共享资源(比如一个全局变量、一块共享内存)时,如果不加以约束,就很容易出现多个线程同时读写同一份数据、把数据搞乱的情况。为了避免这种问题,线程之间需要用一些**同步机制(Synchronization Primitives)**来协调彼此的行为,常见的同步机制包括:
| 同步机制 | 作用 |
|---|---|
| 锁(Lock,如互斥锁 Mutex) | 保证同一时刻只有一个线程能够访问被保护的资源 |
| 信号量(Semaphore) | 控制同一时刻最多允许多少个线程访问某项资源 |
| 屏障(Barrier) | 让一组线程互相等待,直到全部到达某个点后才能一起继续往下走 |
正确地使用这些同步机制,可以让多个线程有条不紊地协调工作,从而避免竞态条件(Race Condition)、**死锁(Deadlock)**等并发编程中经常遇到的棘手问题。
我们可以用一个简单的数学表达来说明"竞态条件"到底是怎么产生的:假设两个线程 T1T_1T1 和 T2T_2T2 都要对同一个共享变量 xxx 执行"读取旧值、加一、写回"这三步操作,如果这三步操作没有被当成一个不可分割的整体(也就是没有加锁保护),两个线程的操作步骤就可能交叉执行,最终结果就不再是我们期望的"加了两次":
x期望结果=x初始值+2
x_{\text{期望结果}} = x_{\text{初始值}} + 2
x期望结果=x初始值+2
x实际可能结果=x初始值+1(因为其中一次加法被另一个线程的操作"覆盖"掉了)
x_{\text{实际可能结果}} = x_{\text{初始值}} + 1 \quad (\text{因为其中一次加法被另一个线程的操作"覆盖"掉了})
x实际可能结果=x初始值+1(因为其中一次加法被另一个线程的操作"覆盖"掉了)
这正是为什么同步机制在多线程编程中不是"可选项",而是"必需品"。
五、第四阶段:终止(Termination)
一个线程的生命周期最终会走向终止,终止的方式主要有以下几种:
- 显式调用终止函数:线程自己主动调用某个专门的函数来结束自己(比如 pthread 里的
pthread_exit)。 - 启动例程正常返回:线程要执行的那个函数正常执行完毕、
return了,线程也就随之自然结束,这是最常见、也是最推荐的终止方式。 - 被其他线程取消:另一个线程可以调用特定的函数(比如 pthread 里的
pthread_cancel),强行要求某个线程提前终止。
无论是通过哪种方式终止,线程结束之后,系统都会负责回收这个线程原本占用的资源(比如它的栈空间),同时,如果这个线程当时还持有着某些锁或者有尚未完成的操作,系统也会负责把这些锁释放掉,避免影响其他线程继续正常运行。
六、完整可运行的 C++ 代码示例:完整演示线程的四个生命周期阶段
下面用一段完整的 C++ 代码,把"创建、执行、同步、终止"这四个阶段依次展示出来。这里我们同时使用标准库的 std::thread(更现代、更简洁的方式)以及底层的 POSIX 线程接口 pthread(更贴近题目描述中"设置线程属性""调用取消函数"这些细节),让整个例子既贴近标准 C++ 的常见写法,也能对应上题目里提到的底层概念。
// 文件名:thread_lifecycle_demo.cpp
// 说明:完整演示线程生命周期的四个阶段——创建、执行、同步、终止
// 编译方式(仅适用于 Linux/类 Unix 系统,需要链接 pthread 库):
// g++ -std=c++17 -O2 thread_lifecycle_demo.cpp -o thread_lifecycle_demo -lpthread
#include <pthread.h> // 提供底层的 POSIX 线程接口:pthread_create、pthread_attr_t、pthread_cancel 等
#include <mutex> // 提供 std::mutex,用于"同步"阶段保护共享资源
#include <chrono> // 提供时间相关工具
#include <thread> // 提供 std::this_thread::sleep_for
#include <iostream> // 提供标准输入输出
// 全局共享变量,模拟多个线程都要访问的"共享资源"
long long shared_counter = 0;
// 保护 shared_counter 的互斥锁,这正对应"同步阶段"要用到的同步机制
std::mutex counter_mutex;
// ---------- 第一部分:使用底层 pthread 接口,展示"创建阶段"如何设置线程属性 ----------
// 线程的启动例程(start routine):pthread 要求这个函数的签名必须是 void* (void*)
// 参数 arg:创建线程时传进来的自定义参数,这里我们传入线程编号
void* pthread_start_routine(void* arg) {
// 把 void* 参数转换回我们实际需要的类型(这里是 int*)
int thread_id = *static_cast<int*>(arg);
std::cout << "[线程 " << thread_id << "] 进入执行阶段,开始工作" << std::endl;
// ---------- 执行阶段:线程独立完成自己的任务 ----------
// 每个线程都有自己独立的局部变量 local_sum,互不干扰
long long local_sum = 0;
for (int i = 0; i < 1000; ++i) {
local_sum += i; // 这是一个完全不需要和其他线程协调的、纯本地的计算
}
std::cout << "[线程 " << thread_id << "] 本地计算完成,local_sum = " << local_sum << std::endl;
// ---------- 同步阶段:多个线程需要共同修改同一个共享变量 ----------
{
// lock_guard 在构造时自动加锁,析构时(离开这个花括号作用域时)自动解锁
// 这保证了同一时刻只有一个线程能够进入这段"临界区"去修改 shared_counter
std::lock_guard<std::mutex> lock(counter_mutex);
shared_counter += local_sum; // 安全地把本地计算结果累加到共享变量上
std::cout << "[线程 " << thread_id << "] 已经把结果累加到共享计数器,当前共享计数器值为: "
<< shared_counter << std::endl;
} // 离开这个作用域,锁被自动释放
std::cout << "[线程 " << thread_id << "] 即将通过启动例程正常返回的方式终止" << std::endl;
// ---------- 终止阶段:通过正常返回来结束线程 ----------
// pthread_exit 显式终止当前线程,效果上和直接 return 类似,
// 这里用它来明确演示题目中提到的"显式调用终止函数"这种终止方式
pthread_exit(nullptr);
}
int main() {
constexpr int THREAD_COUNT = 3;
// ---------- 创建阶段:设置线程属性 ----------
// pthread_attr_t 是用来描述"线程属性"的结构体,对应题目中提到的调度策略、栈大小、优先级等
pthread_attr_t attr;
pthread_attr_init(&attr); // 先用默认值初始化这个属性对象
// 显式设置这个线程的栈大小为 1MB(1024 * 1024 字节)
// 这正是题目中提到的"线程属性"里的一项具体设置
pthread_attr_setstacksize(&attr, 1024 * 1024);
// 用来存放每个线程句柄的数组
pthread_t threads[THREAD_COUNT];
// 用来给每个线程传递独立编号的数组(必须在线程真正结束前一直有效,所以不能用局部临时变量)
int thread_ids[THREAD_COUNT];
std::cout << "===== 开始创建线程 =====" << std::endl;
for (int i = 0; i < THREAD_COUNT; ++i) {
thread_ids[i] = i + 1; // 线程编号从 1 开始,方便打印时阅读
// pthread_create 的四个参数依次是:
// 1. 用来接收新线程句柄的变量地址
// 2. 线程属性(这里传入我们刚才配置好的 attr)
// 3. 线程要执行的启动例程
// 4. 传给启动例程的参数
int create_result = pthread_create(&threads[i], &attr, pthread_start_routine, &thread_ids[i]);
if (create_result != 0) {
std::cerr << "创建线程 " << i + 1 << " 失败!" << std::endl;
} else {
std::cout << "线程 " << i + 1 << " 创建成功,已进入创建阶段完成、即将开始执行" << std::endl;
}
}
// 属性对象在用完之后应该被销毁,释放它占用的资源
pthread_attr_destroy(&attr);
std::cout << "===== 主线程等待所有子线程结束 =====" << std::endl;
// pthread_join 会阻塞,直到对应的线程真正终止为止,
// 这一步保证主线程会等所有子线程都完整走完"执行—同步—终止"这几个阶段之后,才会继续往下执行
for (int i = 0; i < THREAD_COUNT; ++i) {
pthread_join(threads[i], nullptr);
}
std::cout << "===== 所有线程均已终止 =====" << std::endl;
std::cout << "最终共享计数器的值为: " << shared_counter << std::endl;
return 0;
}
代码关键点解析
pthread_attr_t和pthread_attr_init/pthread_attr_setstacksize:这一组函数对应"创建阶段"里提到的"线程属性"这个概念。pthread_attr_init先给属性对象填上默认值,然后我们用pthread_attr_setstacksize显式修改了栈大小这一项属性,题目中提到的调度策略、优先级也是通过类似pthread_attr_setschedpolicy这样的函数来设置的,原理是一样的。pthread_create的四个参数:这是真正"创建线程"的关键调用,第三个参数pthread_start_routine就是题目里说的"启动例程(start routine)"——线程一旦创建成功,就会去执行这个函数;第四个参数是传给这个函数的自定义数据,本例中传的是线程编号。- 启动例程内部的"执行阶段"(
local_sum的计算):这部分代码完全不涉及任何共享数据,每个线程各自维护自己的local_sum变量,互不干扰,这正对应题目里说的"线程可以创建和管理自己的局部变量,独立完成任务"。 std::lock_guard<std::mutex> lock(counter_mutex)对应"同步阶段":当多个线程都要把自己的local_sum累加到全局共享的shared_counter上时,如果不加锁,多个线程的读—改—写操作就可能交叉执行,导致最终结果不正确(也就是前面提到的竞态条件)。lock_guard通过"构造时加锁、析构时自动解锁"的方式,保证了这一小段代码在任意时刻只有一个线程能够进入,其余线程会在这里排队等待。pthread_exit(nullptr)对应"终止阶段"里的"显式调用终止函数":题目提到线程可以"显式调用函数来终止自己",pthread_exit正是 POSIX 线程接口里专门用来做这件事的函数,调用之后,当前线程会立即结束,不会再执行后面的任何代码(不过本例中它已经是函数的最后一行了,效果上和直接让函数自然结束差别不大,这里主要是为了明确演示这个 API 的存在)。pthread_join对应"等待线程终止、系统回收资源":pthread_join会阻塞调用它的线程(本例中是主线程),直到目标线程真正终止为止;这也隐含着题目提到的"线程终止后,系统会回收它占用的资源"这一过程——pthread_join正是确保这些资源被正确、及时回收的标准做法,如果不调用join(或者对应的detach),线程结束后占用的一些资源可能不会被立刻释放。pthread_attr_destroy(&attr):属性对象本身也是需要显式销毁的资源,用完之后要记得调用这个函数进行清理,这是使用 pthread 底层接口时容易被忽略、但很重要的一个细节。
七、线程终止的三种方式对比
题目里提到线程终止一共有三种典型方式,我们用一张表格总结一下它们的区别:
| 终止方式 | 发起者 | 特点 |
|---|---|---|
| 启动例程正常返回 | 线程自己 | 最推荐、最安全的方式,函数走完自然结束,资源能被顺利回收 |
| 显式调用终止函数(如 pthread_exit) | 线程自己 | 可以在函数还没走到结尾的地方,提前主动结束自己 |
| 被其他线程取消(如 pthread_cancel) | 其他线程 | 由外部强制要求某个线程终止,需要格外小心处理"取消点",否则可能导致资源没有被正确释放 |
这里要特别提醒一下:被其他线程取消这种终止方式,是三种方式里最需要小心的一种。因为如果一个线程正好在持有某把锁、或者正在操作某个共享资源的时候突然被外部取消,很可能会导致这把锁永远不会被释放(其他等待这把锁的线程就会永远卡住),或者共享资源被留在一个"中间状态"、数据不完整。所以在实际工程中,通常会优先考虑用"设置一个标志位,线程自己检查这个标志位、主动决定何时结束"这种更温和、更可控的方式,而不是简单粗暴地调用取消函数。
八、线程生命周期完整时序图
我们用一张时序图,把创建线程、多个线程各自经历"执行—同步"、最终终止、主线程等待回收的完整过程串联起来:
从图中能看出一个细节:T1 和 T2 在"执行阶段"是完全并发、互不干扰的(用 par...and...end 表示),但到了要修改共享计数器这一步时,它们必须排队通过同一把锁,一个接一个地完成对共享资源的修改,这正是"执行阶段"和"同步阶段"之间关系的直观体现。
九、用 ASCII 图梳理线程生命周期的整体结构
线程生命周期
|
+-- 阶段一:创建(Creation)
| |
| +-- 设置线程属性(调度策略、栈大小、优先级)
| +-- 指定启动例程(Start Routine)
| +-- 系统分配独立的栈空间和相关资源
|
+-- 阶段二:执行(Execution)
| |
| +-- 执行启动例程中的代码
| +-- 维护自己独立的局部变量和数据结构
|
+-- 阶段三:同步(Synchronization)
| |
| +-- 使用锁、信号量、屏障等机制
| +-- 协调对共享资源的访问,避免竞态条件和死锁
|
+-- 阶段四:终止(Termination)
|
+-- 方式一:启动例程正常返回
+-- 方式二:显式调用终止函数
+-- 方式三:被其他线程取消
+-- 系统回收资源,释放遗留的锁
十、小结
| 要点 | 说明 |
|---|---|
| 线程的本质 | 轻量级进程,寄居在某个进程内部,与同一进程中的其他线程共享大部分资源 |
| 创建阶段核心 | 设置线程属性(调度策略、栈大小、优先级),并指定启动例程 |
| 执行阶段核心 | 独立运行,维护自己的局部变量,可以按需和其他线程交互 |
| 同步阶段核心 | 用锁、信号量、屏障等机制协调对共享资源的访问,避免竞态条件与死锁 |
| 终止阶段核心 | 可以正常返回、主动调用终止函数,或被其他线程取消;终止后系统会回收资源 |
| 工程启示 | 谨慎管理这四个阶段,才能写出高效、可扩展、真正发挥并发优势的程序 |
线程生命周期的这四个阶段,其实和我们之前学过的进程生命周期在思路上是相通的,只不过线程的"创建"和"终止"成本更低、更轻量,这也是为什么在需要频繁创建、销毁执行单元的场景下,线程往往比进程更受欢迎的原因。理解清楚这四个阶段各自的关注点,是后续学习更复杂的并发编程技术(比如线程池、协程)的重要基础。
线程调度(Thread Scheduling)与协程从零理解
1. 先建立直觉:谁来决定"现在轮到哪个线程跑"
一台电脑的CPU核心数是有限的,但系统里同时存在的线程数量往往远远超过核心数。既然没法让所有线程真的同时都在跑,就必须有个"裁判"来决定:这一瞬间,究竟该让哪个线程占用CPU。这个裁判就是操作系统内核里的调度器(scheduler)。
系统线程由内核的调度器统一管理,采用的是抢占式调度(preemptive scheduling)——意思是说,线程自己说了不算,调度器可以在它觉得合适的时候,强行把CPU从当前正在运行的线程手里收回来,转交给另一个线程,线程本身没有拒绝的权利。调度器做这个决定时,通常会综合考虑几个因素:
- 线程优先级:优先级更高的线程更容易被优先安排上CPU。
- 已经分配到的时间片用完了没有:每个线程通常只能连续运行一小段时间(时间片),时间一到,不管这个线程愿不愿意,都得先让出CPU。
- 是不是卡在互斥锁上:如果一个线程正在等待某个被别的线程占用的锁,它自己也没办法继续往下跑,调度器自然会先把CPU让给别的能跑的线程。
2. 上下文切换:看起来简单,其实开销不小
调度器把CPU从一个线程换给另一个线程的这个动作,叫做上下文切换(context switch)。别看只是"换个线程接着跑"这么简单一句话,实际要做的事情并不轻松:调度器得把当前线程运行到哪一步、寄存器里存的什么值、栈用到哪里了等等这些"现场信息"都保存起来,然后再把即将要运行的那个线程的现场信息重新加载进CPU,才能真正切换过去继续跑。
这整个过程是由内核主导完成的,也就是说,每次上下文切换都要经过一次"陷入内核态、内核处理、再返回用户态"的往返,这个往返本身就有不小的固定成本。再加上每个线程通常都会占用一块专属的、不小的栈空间,这些资源开销叠加起来,如果一个程序需要非常频繁地在很多个执行单元之间来回切换,系统线程的这套机制就会显得比较"重",成为性能上的一个明显瓶颈。这也是为什么在某些场景下,**协程(coroutine)**会是一个更高效的替代方案——因为一个线程里可以同时跑好几个协程,不需要为了"多干几件事"就非得开好几个系统线程。
3. 协程为什么更高效
协程带来的好处,可以归纳成几条:
第一,大幅降低上下文切换的开销。协程之间的切换(通常发生在协程主动让出执行权,或者等待某个操作完成的时候)是由用户态的代码(也就是协程库或者语言运行时自己)来处理的,完全不需要经过内核,自然也就省掉了"陷入内核态、内核介入处理"这些额外的固定成本,整个切换过程更轻量、更高效,在需要频繁切换的场景下,这种效率优势会体现得特别明显。
第二,能更精细地掌控调度策略。因为切换逻辑掌握在用户态代码手里,开发者完全可以根据自己应用的具体需求,自己定义调度策略——比如按什么顺序推进各个协程、什么条件下该暂停某个协程,这种灵活性能让开发者对资源利用和性能表现做出更精细的调整,而不是像线程调度那样,具体怎么调度完全是内核说了算,开发者插不上手。
第三,通常比系统线程更轻量。很多协程实现并不需要像线程那样,专门维护一整块独立的、较大的调用栈,这在资源上是一个明显的优势,尤其适合那些内存比较紧张、需要同时维持大量并发执行单元的场景。
用一张表把线程和协程的区别整理一下:
| 对比项 | 系统线程 | 协程 |
|---|---|---|
| 调度方式 | 抢占式,由内核调度器决定何时切换 | 协作式,切换时机通常由代码自己主动让出 |
| 上下文切换开销 | 较高,需要经过内核处理 | 较低,切换逻辑在用户态就能完成 |
| 栈空间占用 | 每个线程通常都有自己独立的、较大的栈 | 很多实现不需要维护一整块独立的完整栈,更省内存 |
| 调度策略的可定制性 | 一般由内核决定,开发者难以直接介入 | 开发者可以自己定义调度顺序和切换时机 |
| 一个线程内能容纳多少 | 本质上只有一条执行流 | 可以在同一个线程内同时运行多个协程 |
4. 两种调度方式,画成图看更直观
先看系统线程的抢占式调度,是怎么在内核的主导下来回切换的:
再看协程的协作式调度,切换完全发生在用户态、同一个线程里:
对比这两张图能看得很清楚:系统线程的切换是"调度器说了算,随时可能被打断",而协程的切换是"我(协程自己)决定什么时候让出去",整个协调过程完全发生在用户态代码内部,没有内核参与。
5. 用C++20协程实际写一个例子
C++20开始,语言本身原生支持协程语法(co_yield、co_await、co_return)。下面用一个最简化的"任务(Task)"类型,演示怎么在同一个线程里,用自己写的调度逻辑,轮流推进多个协程执行——这正好对应前面说的"开发者可以自己定义调度策略"这条优势。
编译这段代码需要支持C++20的编译器,比如:
g++ -std=c++20 coroutine_demo.cpp -o coroutine_demo
#include <iostream>
#include <coroutine>
#include <vector>
// Task是一个非常简化的"协程句柄封装",用来让协程函数的返回类型是我们自己定义的类型,
// 而不是裸的std::coroutine_handle
struct Task {
// promise_type是C++协程机制规定必须提供的一个"内部状态"类型,
// 编译器会根据这个类型里定义的各个函数,来决定协程在各个关键节点该怎么表现
struct promise_type {
int currentValue = 0; // 记录协程最近一次co_yield出来的值
// 协程被调用时,怎么构造出对外返回的Task对象
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
// 协程刚被创建时,先原地暂停,不要自己立刻跑到底,
// 等外部调度代码主动调用resume()才开始真正执行
std::suspend_always initial_suspend() { return {}; }
// 协程执行完最后一行代码之后,也先暂停在这里,
// 方便外部代码判断"协程是不是已经跑完了",再决定要不要销毁它
std::suspend_always final_suspend() noexcept { return {}; }
// 协程正常执行完(没有返回值)时会调用这个函数,这里不需要做什么特殊处理
void return_void() {}
// 每次协程里写了一句co_yield value,就会走到这个函数:
// 把yield出来的值记下来,然后暂停协程的执行,把控制权交还给外部代码,
// 这个"暂停/恢复"的动作全程都在用户态完成,不需要经过内核,
// 这正是协程比线程轻量的关键所在
std::suspend_always yield_value(int value) {
currentValue = value;
return {};
}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
explicit Task(std::coroutine_handle<promise_type> h) : handle(h) {}
~Task() {
if (handle) {
handle.destroy();
}
}
// 协程句柄不允许被拷贝(拷贝了也没有意义,两份句柄会指向同一个协程状态),
// 只允许移动,转移所有权
Task(const Task&) = delete;
Task(Task&& other) noexcept : handle(other.handle) {
other.handle = nullptr;
}
// 推进协程往下执行,直到遇到下一个co_yield,或者协程整个跑完为止
// 返回true表示协程还没跑完,返回false表示已经彻底结束
bool resume() {
if (handle && !handle.done()) {
handle.resume();
}
return handle && !handle.done();
}
};
// 一个真正的协程函数:函数体里出现了co_yield,
// 编译器看到这个关键字,就会自动把这个函数改造成一个协程,
// 每次执行到co_yield,函数会"冻结"在当前位置,把控制权交还给调用者,
// 下次被resume时,会从冻结的地方继续往下跑,局部变量i的值也会被完整保留
Task worker(const char* name, int times) {
for (int i = 1; i <= times; ++i) {
std::cout << " [" << name << "] 正在执行第 " << i << " 步" << std::endl;
co_yield i; // 主动让出执行权
}
}
int main() {
// 创建两个协程任务:注意这一步创建协程本身的开销很小,
// 也不会像创建一个系统线程那样,需要额外分配一整块独立的调用栈
Task taskA = worker("协程A", 3);
Task taskB = worker("协程B", 3);
std::vector<Task*> tasks{&taskA, &taskB};
std::cout << "在同一个线程里,用自定义的轮询调度逻辑推进两个协程:" << std::endl;
// 这里手写了一个最简单的"轮询调度器":
// 依次去推进列表里的每一个协程一步,只要还有协程没跑完,就继续这个循环,
// 整个调度顺序完全由这段代码自己决定,这就是协程"调度策略可自定义"的直接体现
bool anyRunning = true;
while (anyRunning) {
anyRunning = false;
for (auto* task : tasks) {
if (task->resume()) {
anyRunning = true;
}
}
}
std::cout << "所有协程都已经执行完毕" << std::endl;
return 0;
}
这段代码的关键点:
initial_suspend()返回std::suspend_always,意味着协程一创建出来就先原地待命,不会自己抢跑,必须靠外部代码主动调用resume()才会真正开始执行,这就为"自己写调度器、自己决定谁先跑"这件事打下了基础。co_yield i这一行,本质上是在做两件事:把i这个值记录下来,然后把执行权交还给调用者,等下一次resume()被调用时,程序会精确地从这一行的下一步继续往下走,中间经过的所有局部变量都完好无损地保留着,这跟函数普通的"调用一次、跑到底、返回"完全不同。main函数里的while循环就是一个最朴素的"用户态调度器":轮流推进taskA和taskB,谁跑完了就不再推进它,直到两个协程都跑完为止。整个过程只用了一个线程,两个协程却能"看起来像是交替运行",而这份"怎么交替、以什么顺序交替"的决定权,完完全全掌握在开发者自己写的这几行代码手里。
6. 用一段基准测试直观感受开销差异
光说"线程上下文切换开销大"可能不够直观,下面用一段简单的基准测试,实际测一下频繁创建/销毁系统线程要花多少时间:
#include <iostream>
#include <thread>
#include <chrono>
// 一个几乎不做任何事情的空函数,用来让线程的"创建->调度->销毁"这套流程
// 尽量只体现线程本身的开销,而不是被内部计算逻辑的耗时干扰
void trivialWork() {
// 故意留空
}
int main() {
const int numThreads = 20000;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < numThreads; ++i) {
// 每次循环都创建一个全新的系统线程,让它跑完(几乎不耗时的空函数),
// 再等它结束,模拟"频繁创建、销毁执行单元"这种使用方式
std::thread t(trivialWork);
t.join();
}
auto end = std::chrono::steady_clock::now();
double totalMs = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "创建并等待 " << numThreads << " 个系统线程,总耗时: "
<< totalMs << " 毫秒" << std::endl;
std::cout << "平均每个线程花费: " << (totalMs / numThreads) << " 毫秒" << std::endl;
return 0;
}
这段代码的关键点:
trivialWork内部几乎什么都不做,这样测出来的总耗时,基本上就能代表"创建一个系统线程、让内核介入调度、线程结束后再回收资源"这一整套流程本身固有的开销,而不是被"线程里到底算了多少东西"干扰。- 实际跑一下会发现,就算线程里什么正经事都没干,创建和回收两万个线程依然要花上不短的时间。如果把这两万次操作换成前面例子里"创建两万个协程,在一个线程里推进它们",因为完全不涉及内核介入、也不需要为每一个执行单元单独分配一整块线程栈,实际耗时会比这个数字小得多——这也正是文中说"协程更适合频繁切换场景"的直接原因。
7. 别忘了:线程之间共享的是同一份内存
不管调度方式是抢占式还是协作式,有一件事是不变的:同一个进程里的所有线程,访问的都是同一份进程内存,彼此之间没有天然的隔离。这意味着一个线程往某块内存里写数据,另一个线程立刻就能看到,效率是很高,但也正因为这种"直接共享",才特别容易出现前面提过的竞态条件——多个线程同时读写同一块数据,互相踩踏,结果出错。
所以只要涉及多线程共享数据,就必须小心地去控制内存访问的顺序和方式,而实现这种控制的具体手段,就是各种各样的同步原语(synchronization primitives),比如互斥锁、条件变量这些工具,接下来的内容会围绕这些具体的同步机制继续展开。
8. 小结
系统线程由内核统一管理,采用抢占式调度,调度器会根据优先级、时间片、是否卡在锁上这些因素,随时决定该切换到哪个线程执行,而每一次切换都要经过内核介入,加上每个线程自身占用的资源,使得线程在需要频繁切换的场景下显得比较"重"。协程则把切换的决定权交还给用户态代码,切换本身更轻量,开发者也能自己定义调度策略,很多实现还不需要维护独立的完整栈,因此更适合那种需要同时维持大量并发执行单元、又追求高效切换的场景。不过不管是线程还是协程,只要牵涉到共享同一份内存,就都绕不开"怎么安全地控制并发访问"这个核心问题,这也是接下来要继续深入的同步机制部分。
同步原语(Synchronization Primitives)详解
一、为什么需要"同步原语"——先建立一个整体印象
在多线程编程里,多个线程共享同一份内存,这意味着它们随时可能同时去读写同一块数据。如果没有任何规则去约束"谁在什么时候能访问什么资源",程序很容易出现数据错乱、结果不可预测的问题。
同步原语(Synchronization Primitives),就是一整套用来管理"多个线程如何有序地访问共享资源"的基础工具。它们各自的关注点和适用场景都不太一样,接下来我们逐一从零开始理解每一种。
二、互斥锁(Mutex)——最基础的"独占访问"工具
互斥锁的作用非常直白:保证同一时刻只有一个线程能够执行某一段"关键代码区域"(critical section)。当一个线程把互斥锁锁上之后,其他任何线程想要进入这段被保护的代码,都必须先在外面排队等待,直到这把锁被解开为止。
正是通过这种"一次只放一个人进去"的机制,互斥锁保证了数据的完整性,避免了多个线程同时修改同一份数据所导致的竞态条件。
用 C++ 代码演示互斥锁
#include <iostream> // 标准输入输出
#include <thread> // std::thread
#include <mutex> // std::mutex, std::lock_guard
#include <vector> // std::vector,存放多个线程
int sharedCounter = 0; // 多个线程共同修改的共享数据
std::mutex counterMutex; // 用来保护 sharedCounter 的互斥锁
// ------------------------------------------------------------
// increaseCounter:每个线程都会执行这个函数,反复给共享计数器加1。
// ------------------------------------------------------------
void increaseCounter(int times) {
for (int i = 0; i < times; ++i) {
std::lock_guard<std::mutex> lock(counterMutex); // 加锁,进入关键代码区
sharedCounter++; // 这一行是被保护的"关键操作"
// 离开这个作用域时,lock_guard 会自动解锁,不需要手动调用 unlock()
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(increaseCounter, 100000); // 开4个线程,每个各加10万次
}
for (auto &t : threads) {
t.join();
}
std::cout << "最终计数结果: " << sharedCounter << "(期望值应为400000)" << std::endl;
return 0;
}
代码关键点:std::lock_guard<std::mutex> 在构造时立刻尝试加锁,如果锁已经被别的线程占用,当前线程就会在这里排队等待;一旦对象离开自己所在的作用域(这里是每次循环结束),锁会被自动释放,不用担心忘记解锁导致死锁。
编译运行方式:
g++ -std=c++20 -pthread mutex_demo.cpp -o demo
./demo
三、信号量(Semaphore)——比互斥锁更灵活的"数量控制"工具
信号量比互斥锁更加通用,除了可以做"独占访问"之外,还能用于线程之间的信号通知,适用范围更广。
信号量内部维护着一个整数计数器:
- 线程可以对这个计数器做**递增(相当于"释放/发信号")**操作;
- 线程也可以对这个计数器做**递减(相当于"等待/申请资源")**操作,如果计数器已经是 0,尝试递减的线程就会被阻塞,直到有人把计数器加回来为止。
信号量支持更复杂的协调模式,常见的有两种: - 计数信号量(counting semaphore):计数器可以取任意非负整数,常用于"资源池"场景——比如一个池子里有 N 个可用资源,最多允许 N 个线程同时申请到资源。
- 二元信号量(binary semaphore):计数器只能取 0 或 1 两个值,行为上和互斥锁非常相似。
用 C++ 代码演示计数信号量
C++20 标准库正式提供了 std::counting_semaphore,下面用它来模拟"资源池最多同时允许3个线程访问"这个场景:
#include <iostream> // 标准输入输出
#include <thread> // std::thread
#include <vector> // std::vector
#include <semaphore> // C++20 标准库提供的信号量
#include <chrono> // 模拟耗时操作
// 计数信号量,模板参数3表示这个信号量最多允许的计数值上限,
// 初始化参数3表示一开始就有3份可用资源。
std::counting_semaphore<3> resourcePool(3);
// ------------------------------------------------------------
// useResource:模拟一个线程去申请、使用、归还共享资源池中的一份资源。
// ------------------------------------------------------------
void useResource(int threadId) {
std::cout << "[线程" << threadId << "] 正在申请资源..." << std::endl;
resourcePool.acquire(); // 相当于"计数器减1";如果计数已经是0,这里会阻塞等待
std::cout << "[线程" << threadId << "] 已获得资源,开始使用" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟使用资源花费的时间
std::cout << "[线程" << threadId << "] 使用完毕,归还资源" << std::endl;
resourcePool.release(); // 相当于"计数器加1",把资源还回池子里,可能会唤醒其他等待的线程
}
int main() {
std::vector<std::thread> threads;
for (int i = 1; i <= 6; ++i) {
threads.emplace_back(useResource, i); // 开6个线程,去竞争只有3份的资源
}
for (auto &t : threads) {
t.join();
}
std::cout << "所有线程都已完成资源使用。" << std::endl;
return 0;
}
代码关键点:一共开了 6 个线程,但资源池里只有 3 份资源(std::counting_semaphore<3> resourcePool(3))。运行后你会观察到:同一时刻最多只有 3 个线程能打印出"已获得资源",其余线程会一直卡在 acquire() 那一行,直到有线程调用 release() 归还资源之后,才能轮到它们获得资源——这正是信号量"数量受限的资源协调"这一特性的具体体现。
编译运行方式:
g++ -std=c++20 -pthread semaphore_demo.cpp -o demo
./demo
四、条件变量(Condition Variable)——基于"特定条件"的同步
条件变量用于实现这样一种同步场景:线程需要一直等到某个特定条件变成真,才能继续往下执行。线程可以"阻塞在(wait on)"一个条件变量上,进入休眠状态;而另一个线程一旦让条件满足了,就可以通过"通知(signal)"这个条件变量,把正在等待的线程唤醒,让它们继续往下执行。
条件变量通常需要和互斥锁搭配使用,这样才能实现更精细的同步控制,同时避免"忙等(busy waiting,也就是傻乎乎地不停轮询检查条件)"这种浪费 CPU 的做法。
用 C++ 代码演示条件变量
#include <iostream> // 标准输入输出
#include <thread> // std::thread
#include <mutex> // std::mutex
#include <condition_variable> // std::condition_variable
std::mutex mtx;
std::condition_variable cv;
bool dataReady = false; // 这就是条件变量所依赖的那个"特定条件"
// ------------------------------------------------------------
// waitForData:这个线程会一直等待,直到 dataReady 变成 true 才继续执行。
// ------------------------------------------------------------
void waitForData() {
std::unique_lock<std::mutex> lock(mtx); // 必须用 unique_lock,因为 wait() 需要能临时解锁
std::cout << "[等待线程] 开始等待数据准备好..." << std::endl;
// wait() 的第二个参数是判断条件的函数:如果条件不满足,就释放锁并休眠;
// 一旦被唤醒,会自动重新加锁,再检查一次条件,只有真正满足才继续往下走。
cv.wait(lock, [] { return dataReady; });
std::cout << "[等待线程] 检测到数据已经准备好,继续执行后续逻辑" << std::endl;
}
// ------------------------------------------------------------
// prepareData:这个线程负责准备数据,准备好之后修改条件并通知等待方。
// ------------------------------------------------------------
void prepareData() {
std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟准备数据需要花费一些时间
{
std::lock_guard<std::mutex> lock(mtx);
dataReady = true; // 修改共享状态,让条件变为真
std::cout << "[准备线程] 数据已经准备完毕" << std::endl;
}
cv.notify_one(); // 通知一个正在等待的线程,让它检查条件并继续执行
}
int main() {
std::thread t1(waitForData);
std::thread t2(prepareData);
t1.join();
t2.join();
return 0;
}
代码关键点:waitForData 线程一开始就会卡在 cv.wait(...) 这一行,处于休眠状态,完全不占用 CPU;直到 prepareData 线程把 dataReady 改成 true 并调用 notify_one() 之后,waitForData 才会被唤醒、重新检查条件、发现条件已满足,于是继续往下执行。这正好避免了"每隔几毫秒就去检查一次 dataReady 是否为真"这种低效的忙等方式。
编译运行方式:
g++ -std=c++20 -pthread condition_variable_demo.cpp -o demo
./demo
五、其他几种常见的同步原语
除了上面这三种最核心的同步原语,还有几种也很常用的同步机制:
1. 屏障(Barrier)
屏障用来让一组线程互相等待、统一步调:它要求所有参与的线程都必须先到达某一个"检查点",然后才允许所有线程一起继续往下执行——就好比一群人约好在某个地点集合,谁先到都要等其他人到齐了才一起出发。
C++20 提供了 std::barrier,用法示例:
#include <iostream>
#include <thread>
#include <vector>
#include <barrier> // C++20 提供的屏障
int main() {
const int threadCount = 3;
// 创建一个屏障,参数是需要参与同步的线程数量;
// 当所有线程都到达屏障后,会自动执行一次这里的回调(打印一行提示)。
std::barrier syncPoint(threadCount, [] {
std::cout << "----- 所有线程都已到达屏障,一起进入下一阶段 -----" << std::endl;
});
auto worker = [&](int id) {
std::cout << "[线程" << id << "] 完成第一阶段工作,到达屏障等待" << std::endl;
syncPoint.arrive_and_wait(); // 到达屏障并等待其他线程,全部到齐后才会往下走
std::cout << "[线程" << id << "] 开始执行第二阶段工作" << std::endl;
};
std::vector<std::thread> threads;
for (int i = 1; i <= threadCount; ++i) {
threads.emplace_back(worker, i);
}
for (auto &t : threads) {
t.join();
}
return 0;
}
代码关键点:syncPoint.arrive_and_wait() 这一行的含义是"我已经完成了第一阶段的工作,在这里等着,直到其他所有线程也都完成第一阶段"。只有全部 3 个线程都调用过这一句之后,大家才会同时被放行,进入"第二阶段工作",任何一个线程都不能提前偷跑。
2. 读写锁(Read-Write Lock)
读写锁解决的是这样一个场景:多个线程同时"读"共享数据是完全安全的(反正大家都只看不改),但只要有一个线程要"写",就必须独占访问(不能一边写一边被别人读到中间状态,也不能多个线程同时写)。读写锁允许多个读者同时进入,但写者必须独占、且写者和任何读者都不能同时存在。
C++17 提供了 std::shared_mutex 来实现读写锁:
#include <iostream>
#include <thread>
#include <vector>
#include <shared_mutex> // C++17 提供的读写锁
int sharedData = 0;
std::shared_mutex rwLock;
// 读操作:多个线程可以同时进行
void readData(int id) {
std::shared_lock<std::shared_mutex> lock(rwLock); // 共享锁,允许多个读者同时持有
std::cout << "[读线程" << id << "] 读取到的数据是: " << sharedData << std::endl;
}
// 写操作:必须独占访问
void writeData(int newValue) {
std::unique_lock<std::shared_mutex> lock(rwLock); // 独占锁,写的时候不允许任何读者或其他写者
sharedData = newValue;
std::cout << "[写线程] 已经把数据更新为: " << newValue << std::endl;
}
int main() {
std::vector<std::thread> threads;
threads.emplace_back(writeData, 100);
for (int i = 1; i <= 4; ++i) {
threads.emplace_back(readData, i);
}
for (auto &t : threads) {
t.join();
}
return 0;
}
代码关键点:std::shared_lock<std::shared_mutex> 是"共享模式"加锁,多个读线程可以同时持有;std::unique_lock<std::shared_mutex> 是"独占模式"加锁,一旦某个写线程拿到了这把锁,其他任何读者或写者都必须等待它释放锁之后才能继续。
3. 自旋锁(Spinlock)
自旋锁本质上也是一种互斥锁,但它实现"等待"的方式和普通互斥锁不一样:普通互斥锁在等不到锁的时候,会让线程真正"休眠",把 CPU 让给别的线程;而自旋锁则是让线程不停地在原地循环检查某个内存位置是否已经变得"可用"了(也就是所谓的"忙等")。
自旋锁的好处是:避免了线程休眠、唤醒所带来的上下文切换开销,如果预期等待时间非常短,这种"原地空转"反而可能比"真正休眠再被唤醒"更划算;但如果等待时间比较长,自旋锁就会白白浪费大量 CPU 资源去做无意义的空转,这时候用普通互斥锁反而更合适。
用 C++ 代码手写一个简单的自旋锁
#include <iostream>
#include <thread>
#include <vector>
#include <atomic> // std::atomic_flag,用于实现自旋锁的核心
// ------------------------------------------------------------
// SpinLock:一个最简化的自旋锁实现。
// 核心是一个原子标志位 flag_,true 表示"已被占用",false 表示"空闲"。
// ------------------------------------------------------------
class SpinLock {
public:
void lock() {
// test_and_set 会把 flag_ 设置为 true,并返回它"设置之前"的旧值。
// 如果旧值是 true,说明锁已经被别人占用了,就在这里不停循环重试(忙等);
// 直到某次 test_and_set 返回 false(说明抢锁成功),才跳出循环。
while (flag_.test_and_set(std::memory_order_acquire)) {
// 这里什么都不做,就是在原地空转,不停地重新尝试抢锁
}
}
void unlock() {
flag_.clear(std::memory_order_release); // 把标志位清空,表示锁已经被释放
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT; // 初始状态为"空闲"
};
SpinLock spinLock;
int sharedValue = 0;
void increment(int times) {
for (int i = 0; i < times; ++i) {
spinLock.lock();
sharedValue++; // 被自旋锁保护的关键代码区
spinLock.unlock();
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(increment, 100000);
}
for (auto &t : threads) {
t.join();
}
std::cout << "最终结果: " << sharedValue << "(期望值应为400000)" << std::endl;
return 0;
}
代码关键点:std::atomic_flag 是 C++ 里最基础、能保证原子操作的一个类型。test_and_set() 这一个操作会原子地完成"读取旧值、设置为true、返回旧值"这三步,中间不会被其他线程打断——这正是自旋锁能够正确工作的根本保证。lock() 函数里的 while 循环就是所谓的"忙等":只要还没抢到锁,就一直在原地反复尝试,不会让出 CPU 去休眠。
编译运行方式:
g++ -std=c++20 -pthread spinlock_demo.cpp -o demo
./demo
六、六种同步原语一览对比表
| 同步原语 | 核心作用 | 是否会让线程真正休眠 | 典型使用场景 |
|---|---|---|---|
| 互斥锁(Mutex) | 保证关键代码区同一时刻只有一个线程执行 | 是 | 保护一份共享数据,防止同时读写 |
| 信号量(Semaphore) | 控制同时能访问某资源的线程数量,也可用于线程间发信号 | 是 | 限流、资源池管理(如最多N个连接) |
| 条件变量(Condition Variable) | 让线程等待"某个特定条件"成立后再继续 | 是 | 生产者-消费者模型、任务队列 |
| 屏障(Barrier) | 让一组线程互相等待、统一到达某个点后再一起继续 | 是 | 分阶段的并行计算,要求每阶段步调一致 |
| 读写锁(Read-Write Lock) | 允许多个读者并发,但写者必须独占 | 是 | 读多写少的共享数据(如缓存) |
| 自旋锁(Spinlock) | 通过忙等(不停轮询)等待锁被释放 | 否(原地空转,不休眠) | 预期等待时间极短的高频临界区 |
七、小结
- 同步原语是多线程编程里用来管理"共享资源访问顺序"的核心工具,不同的原语解决的是不同性质的问题:互斥锁解决"独占访问",信号量解决"数量受限的资源协调与信号通知",条件变量解决"等待特定条件成立",屏障解决"多线程步调一致",读写锁解决"读多写少场景下的并发优化",自旋锁则是用"忙等"换取更低的切换开销,适合极短时间的等待场景。
- 选择哪种同步原语,本质上是在"正确性、性能、实现复杂度"这几者之间做权衡:没有一种原语是万能的,需要根据具体场景的读写模式、等待时间长短、参与线程数量等因素来综合判断。
如何选择合适的同步原语:从零开始理解互斥量、信号量与条件变量
在多线程编程里,多个线程会同时读写同一块内存(我们称之为"共享资源")。如果不加以控制,两个线程可能同时修改同一个变量,导致结果不可预测,这种现象叫做竞态条件(Race Condition)。为了避免这种情况,我们需要用"同步原语"来协调线程之间的执行顺序。
这篇讲解会按照下面的思路展开:先讲清楚"为什么需要同步",再依次讲互斥量、信号量、条件变量三种最常用的工具,每一种都配上完整可运行的 C++ 代码,最后给出一张对比表和一个"该用哪个"的决策图。
1. 为什么需要同步:竞态条件是什么
假设有一个全局变量 counter = 0,两个线程都执行 counter = counter + 1。表面上看这是一步操作,但在 CPU 层面它其实分成三步:
- 把
counter的值读到寄存器里 - 寄存器的值加 1
- 把寄存器的值写回
counter
如果线程 A 刚读完值(比如读到 0),还没来得及写回,线程 B 也读到了 0,那么两个线程各自加 1 后写回,最终counter只会变成 1,而不是期望中的 2。这就是竞态条件。用数学的方式描述,如果两次自增操作本应是:
counterfinal=counterinitial+1+1 counter_{final} = counter_{initial} + 1 + 1 counterfinal=counterinitial+1+1
但实际因为交叉执行,得到的却是:
counterfinal=counterinitial+1 counter_{final} = counter_{initial} + 1 counterfinal=counterinitial+1
同步原语要解决的核心问题,就是让"读-改-写"这类操作变成不可被打断的整体,或者让线程按照某种约定好的顺序执行。
2. 互斥量 Mutex:保证临界区只有一个线程能进入
互斥量(Mutex,全称 Mutual Exclusion) 是最基础的同步工具。可以把它想象成一把厕所门锁:谁先拿到锁,谁就能进入"临界区"(Critical Section,也就是需要保护的那段代码),其他人必须在门口等着,直到锁被释放。
互斥量只有两种状态,可以理解为:
mutex_state∈{0, 1}(0=未锁定, 1=已锁定)
\text{mutex\_state} \in \{0,\ 1\} \quad (0 = 未锁定,\ 1 = 已锁定)
mutex_state∈{0, 1}(0=未锁定, 1=已锁定)
它不像信号量那样有计数,同一时刻永远只允许一个线程持有锁。
2.1 什么时候用互斥量
当你只是想保护一段"只能被一个线程执行"的代码(比如修改共享变量、写文件、更新链表),互斥量就是最直接的选择。
2.2 完整代码示例:用互斥量修复计数器的竞态条件
#include <iostream> // 用于标准输入输出,比如 std::cout
#include <thread> // 用于创建和管理线程 std::thread
#include <mutex> // 用于互斥量 std::mutex 和 std::lock_guard
#include <vector> // 用于存放多个线程对象
// 全局共享变量,多个线程会同时对它进行自增操作
long long g_counter = 0;
// 全局互斥量,用来保护 g_counter 这个临界资源
// 注意:互斥量本身不和某个变量绑定,是靠"程序员的约定"来保护对应的数据
std::mutex g_counter_mutex;
// 每个线程要执行的函数:把 g_counter 自增 times 次
void increment_task(int times) {
for (int i = 0; i < times; ++i) {
// std::lock_guard 是一个"RAII 风格"的加锁工具:
// 构造时自动调用 g_counter_mutex.lock() 加锁,
// 离开这个大括号作用域时自动调用 unlock() 解锁,
// 即使中间发生异常也能保证锁一定会被释放,避免死锁
std::lock_guard<std::mutex> lock(g_counter_mutex);
// 这里就是"临界区":同一时刻只有一个线程能执行这一行
++g_counter;
} // lock 在这里被销毁,自动解锁
}
int main() {
const int thread_count = 4; // 开 4 个线程
const int per_thread = 100000; // 每个线程自增 10 万次
std::vector<std::thread> workers; // 用来存放线程对象的容器
// 创建并启动所有线程,每个线程都会立刻开始执行 increment_task
for (int i = 0; i < thread_count; ++i) {
// emplace_back 直接在 vector 内部构造 std::thread 对象,
// 构造 std::thread 的同时线程就已经开始运行了
workers.emplace_back(increment_task, per_thread);
}
// 等待所有线程执行完毕,主线程才能继续往下走
// 如果不 join,主线程可能在子线程还没跑完时就退出,导致未定义行为
for (auto& t : workers) {
t.join();
}
// 因为有互斥量保护,这里的结果一定精确等于 4 * 100000
std::cout << "最终计数结果: " << g_counter << std::endl;
std::cout << "期望结果: " << thread_count * per_thread << std::endl;
return 0;
}
关键点说明:
std::mutex提供lock()和unlock()两个基本操作,但我们几乎不直接手写这两个调用,而是用std::lock_guard或std::unique_lock这种"包装器",靠对象的生命周期自动管理加锁解锁,这样可以避免忘记解锁导致的死锁。- 临界区应该尽量短:只把真正需要保护的那一两行代码放进
lock_guard的作用域里,锁住的范围越大,线程等待的时间越长,程序的并发性能就越差。
编译运行方式(Linux/g++ 环境):
g++ -std=c++17 -pthread mutex_demo.cpp -o mutex_demo
./mutex_demo
3. 信号量 Semaphore:控制"同时能有几个线程"进入
如果说互斥量是"只能进一个人的门",那**信号量(Semaphore)**就是"最多能进 N 个人的房间"。信号量内部维护一个计数器 SSS,线程要进入房间前先做一次 PPP 操作(也叫 acquire),计数器减一;离开房间时做一次 VVV 操作(也叫 release),计数器加一。如果计数器已经是 0,再想进入的线程就必须阻塞等待。
用公式描述这两个操作:
P(S):S=S−1; 若 S<0 则当前线程阻塞
P(S):\quad S = S - 1;\ \text{若 } S < 0 \text{ 则当前线程阻塞}
P(S):S=S−1; 若 S<0 则当前线程阻塞
V(S):S=S+1; 若存在等待线程,则唤醒其中一个
V(S):\quad S = S + 1;\ \text{若存在等待线程,则唤醒其中一个}
V(S):S=S+1; 若存在等待线程,则唤醒其中一个
当计数器的最大值被限制为 1 时,信号量的行为就退化成了互斥量;但信号量的计数器可以大于 1,这也是它比互斥量更"灵活"的地方——它天生适合"资源池"场景,比如数据库连接池最多允许 3 个连接同时使用,或者生产者-消费者模型里"还有多少个空位/多少个产品"这种计数信息。
3.1 什么时候用信号量
- 限制同时访问某种有限资源的线程数量(比如线程池、连接池)。
- 线程之间的"信号通知",比如一个线程完成某件事后,通知另一个线程可以继续。
3.2 完整代码示例:用信号量限制同时访问的线程数
C++20 标准库自带 std::counting_semaphore,不需要自己用互斥量和条件变量去模拟。
#include <iostream> // 标准输入输出
#include <thread> // 线程
#include <semaphore> // C++20 引入的 std::counting_semaphore
#include <vector> // 存放线程对象
#include <chrono> // 用于模拟耗时操作 std::this_thread::sleep_for
// 定义一个计数信号量,模板参数 3 表示计数器的"理论最大值",
// 构造函数里的参数 2 表示初始可用资源数量为 2
// 含义:最多允许 2 个线程同时进入下面的"资源区"
std::counting_semaphore<3> g_resource_semaphore(2);
// 每个线程要执行的任务:模拟"使用一个有限资源"
void worker_task(int id) {
std::cout << "线程 " << id << " 正在排队等待资源" << std::endl;
// acquire() 相当于 P 操作:
// 如果当前可用资源数量大于 0,就把计数减一并立即返回;
// 如果已经是 0,当前线程就会阻塞,直到有人调用 release()
g_resource_semaphore.acquire();
std::cout << "线程 " << id << " 获得资源,开始工作" << std::endl;
// 模拟这个线程占用资源工作了 500 毫秒
std::this_thread::sleep_for(std::chrono::milliseconds(500));
std::cout << "线程 " << id << " 工作完成,释放资源" << std::endl;
// release() 相当于 V 操作:把可用资源数量加一,
// 并且如果有其他线程在 acquire() 处等待,会唤醒其中一个
g_resource_semaphore.release();
}
int main() {
const int thread_count = 6; // 开 6 个线程,但资源只有 2 份
std::vector<std::thread> workers;
for (int i = 1; i <= thread_count; ++i) {
workers.emplace_back(worker_task, i);
}
for (auto& t : workers) {
t.join(); // 等待所有线程结束
}
std::cout << "所有线程都已完成工作" << std::endl;
return 0;
}
关键点说明:
std::counting_semaphore<LeastMaxValue>是一个模板类,LeastMaxValue只是告诉编译器计数器至少要能表示到多大,真正的初始计数值由构造函数参数决定。- 和互斥量不同,信号量没有"谁拥有锁"的概念:一个线程
acquire(),完全可以由另一个线程去release(),这在"任务完成通知"场景里很有用,但也意味着信号量比互斥量更容易被误用(比如忘记 release 导致资源永久泄漏)。 - 运行这段代码会看到:同一时刻最多只有 2 个线程打印"开始工作",其余线程会一直排队,直到有资源被释放。
编译运行方式(需要支持 C++20 的编译器,比如 g++ 10 及以上):
g++ -std=c++20 -pthread semaphore_demo.cpp -o semaphore_demo
./semaphore_demo
4. 条件变量 Condition Variable:等待某个条件成立
互斥量解决的是"谁能进临界区",信号量解决的是"同时能进几个",而**条件变量(Condition Variable)**解决的是另一个问题:线程需要"等到某件事发生之后"才能继续往下执行,比如"队列不为空了"“某个标志位变成 true 了”。
条件变量必须搭配一把互斥量一起使用,工作流程可以理解成:
- 线程先加锁,检查条件是否满足;
- 如果不满足,调用
wait(),这一步会自动释放锁并进入休眠,把 CPU 让给别的线程; - 当别的线程改变了条件,并调用
notify_one()或notify_all()时,休眠的线程会被唤醒,并自动重新加锁; - 唤醒后一定要重新检查条件(用
while而不是if),因为可能发生"虚假唤醒"(Spurious Wakeup,也就是没人通知也可能被系统偶然唤醒)。
4.1 什么时候用条件变量
典型场景是生产者-消费者模型:消费者线程要等"队列非空"才能取数据,生产者线程要等"队列未满"才能放数据。
4.2 完整代码示例:生产者-消费者的有界队列
#include <iostream> // 标准输入输出
#include <thread> // 线程
#include <mutex> // 互斥量,条件变量必须配合它使用
#include <condition_variable> // 条件变量 std::condition_variable
#include <queue> // 用队列存放"产品"
#include <chrono> // 模拟耗时
std::mutex g_queue_mutex; // 保护下面这个队列的互斥量
std::condition_variable g_cv; // 条件变量,用来通知"队列状态发生了变化"
std::queue<int> g_data_queue; // 共享的数据队列
const size_t MAX_QUEUE_SIZE = 5; // 队列最大容量
bool g_finished = false; // 标记生产是否已经全部结束
// 生产者线程:不断往队列里放数据
void producer_task() {
for (int i = 1; i <= 10; ++i) {
// unique_lock 和 lock_guard 类似,也是 RAII 加锁,
// 但它支持手动 unlock/lock,条件变量的 wait 需要这种灵活性
std::unique_lock<std::mutex> lock(g_queue_mutex);
// wait 的第二个参数是一个"谓词"(返回 bool 的可调用对象):
// 只要谓词返回 false,wait 就会持续阻塞并释放锁;
// 谓词返回 true 才会真正往下继续执行(此时锁已重新被持有)
g_cv.wait(lock, [] {
return g_data_queue.size() < MAX_QUEUE_SIZE; // 队列没满才能生产
});
g_data_queue.push(i); // 生产一个数据放入队列
std::cout << "生产者放入数据: " << i
<< " 当前队列长度: " << g_data_queue.size() << std::endl;
// 通知所有等待中的线程(这里主要是消费者):队列状态变了,可以再检查一下条件
g_cv.notify_all();
lock.unlock(); // 提前手动解锁,让消费者尽快有机会拿到锁
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产耗时
}
// 全部生产完成后,加锁修改结束标志,并再次通知所有线程
{
std::lock_guard<std::mutex> lock(g_queue_mutex);
g_finished = true;
}
g_cv.notify_all();
}
// 消费者线程:不断从队列里取数据
void consumer_task() {
while (true) {
std::unique_lock<std::mutex> lock(g_queue_mutex);
// 等待条件:要么队列里有数据可取,要么生产已经结束(此时该退出循环了)
g_cv.wait(lock, [] {
return !g_data_queue.empty() || g_finished;
});
// 如果队列空了并且生产者已经结束,说明所有数据都被消费完了,退出线程
if (g_data_queue.empty() && g_finished) {
break;
}
int value = g_data_queue.front(); // 取出队首数据
g_data_queue.pop();
std::cout << "消费者取出数据: " << value
<< " 当前队列长度: " << g_data_queue.size() << std::endl;
g_cv.notify_all(); // 通知生产者:队列腾出空间了
lock.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(150)); // 模拟消费耗时
}
}
int main() {
std::thread producer(producer_task); // 启动生产者线程
std::thread consumer(consumer_task); // 启动消费者线程
producer.join(); // 等待生产者结束
consumer.join(); // 等待消费者结束
std::cout << "生产消费全部完成" << std::endl;
return 0;
}
关键点说明:
g_cv.wait(lock, predicate)是"带谓词版本"的 wait,它内部本质上等价于:
while (!predicate()) {
g_cv.wait(lock); // 不带谓词的版本,只是单纯地睡眠并释放锁
}
强烈建议使用带谓词的版本,因为它自动帮你处理了"虚假唤醒需要重新检查条件"这件事,不容易写错。
notify_one()只唤醒一个等待中的线程,notify_all()唤醒所有等待中的线程。当不确定该唤醒谁、或者有多种不同条件在共用同一个条件变量时,用notify_all()更安全,代价是会有一些线程被唤醒后发现条件不满足又重新睡回去,稍微浪费一点性能。
编译运行方式:
g++ -std=c++17 -pthread condvar_demo.cpp -o condvar_demo
./condvar_demo
5. 三种同步原语的整体关系
用一棵分类树来梳理一下它们的位置:
同步原语
├── 互斥类:解决"同一时刻只能一个线程进入"
│ ├── std::mutex 最基础的互斥锁
│ ├── std::recursive_mutex 允许同一线程重复加锁
│ └── std::timed_mutex 支持加锁超时
├── 计数类:解决"同一时刻最多 N 个线程进入"
│ └── std::counting_semaphore 内部维护一个非负计数器
└── 等待类:解决"等到某个条件成立才能继续"
└── std::condition_variable 必须搭配互斥量一起使用
6. 三者对比表
| 特性 | 互斥量 Mutex | 信号量 Semaphore | 条件变量 Condition Variable |
|---|---|---|---|
| 核心作用 | 保证临界区只有一个线程能访问 | 限制同时访问资源的线程数量 | 让线程等待某个条件成立后再继续 |
| 内部状态 | 只有"锁定/未锁定"两种状态 | 一个可为任意非负整数的计数器 | 没有独立状态,依赖外部的谓词判断 |
| 是否有"归属者" | 有,谁加锁谁负责解锁 | 没有,任何线程都能 release | 没有,靠条件变量+外部标志位配合 |
| 典型场景 | 保护共享变量、链表、文件句柄 | 连接池、限流、生产者消费者的容量控制 | 队列为空/为满时的等待与唤醒、任务完成通知 |
| 是否需要配合谓词循环 | 不需要 | 通常不需要 | 必须要,用 while 或者带谓词的 wait |
7. 该选哪一个:决策流程图
8. 生产者-消费者的时序图
上面第 4 节的代码逻辑涉及两个线程通过条件变量互相配合,用时序图能更直观地看清楚"谁在等谁、谁在通知谁":
9. 小结
三种同步原语分别回答了三个不同的问题:互斥量回答"能不能同时进",信号量回答"最多能同时进几个",条件变量回答"什么时候才能进"。实际项目里它们经常组合使用,比如条件变量本身就离不开互斥量。选择的关键不在于记住哪个"更高级",而在于看清楚你要解决的到底是"互斥问题"“计数问题"还是"等待条件问题”,对号入座即可。
多线程常见问题详解
多线程编程能够让程序同时做很多件事,从而提升性能,但是"同时做事"这件事本身就是麻烦的根源。因为多个线程是并发执行的,谁先执行、谁后执行、执行到哪一步被打断,这些都是不确定的(也就是"非确定性")。正是这种不确定性,带来了下面要讲的四个经典问题:竞态条件、死锁、饥饿、活锁。
下面我们从零开始,一个一个把它们理解透彻,每个问题都配上可以直接编译运行的 C++ 代码。
1. 竞态条件(Race Condition)
1.1 什么是竞态条件
竞态条件指的是:多个线程同时读取和修改同一份共享数据,而最终的结果会因为线程执行顺序的不同而发生变化。也就是说,程序的结果变得"看运气"了——这次跑出来是对的,下次跑可能就错了。
最经典的例子就是"多个线程一起给同一个计数器加一"。我们直觉上认为,10 个线程每个都执行 1000 次加一,最后计数器应该是 10000。但实际上很可能达不到这个数字。
原因在于,"计数器加一"这个看似简单的操作,在 CPU 层面其实要拆成三步:
读取旧值→计算新值→写回新值
\text{读取旧值} \rightarrow \text{计算新值} \rightarrow \text{写回新值}
读取旧值→计算新值→写回新值
如果线程 A 刚读取完旧值(比如 5),还没来得及写回,线程 B 也读取了旧值(同样是 5),那么两个线程都会各自算出 6,然后都写回 6。本来应该变成 7 的计数器,结果只变成了 6,凭空"丢失"了一次加一操作。这就是所谓的**丢失更新(Lost Update)**问题。
用公式表示,理想情况下最终值应该是:
Cfinal=Cinitial+n×m
C_{final} = C_{initial} + n \times m
Cfinal=Cinitial+n×m
其中 nnn 是线程数,mmm 是每个线程执行加一的次数。但由于竞态条件的存在,实际结果通常是:
Cfinal≤Cinitial+n×m
C_{final} \le C_{initial} + n \times m
Cfinal≤Cinitial+n×m
1.2 竞态条件的时序图
下面用时序图展示两个线程同时读写同一个变量,导致更新丢失的过程:
1.3 代码示例:有竞态条件的版本(错误示范)
#include <iostream> // 用于标准输入输出,比如 std::cout
#include <thread> // 用于创建和管理线程 std::thread
#include <vector> // 用于存放多个线程对象
// 共享的计数器,没有任何保护措施,多个线程会同时读写它
long g_counter = 0;
// 每个线程要执行的函数:把计数器累加 times 次
void increment_unsafe(int times) {
for (int i = 0; i < times; ++i) {
// 这一行代码看起来是原子的,实际上会被拆成"读-改-写"三步
// 多个线程交叉执行这三步就会互相踩踏,导致更新丢失
g_counter = g_counter + 1;
}
}
int main() {
const int thread_count = 10; // 开 10 个线程
const int times_each = 100000; // 每个线程累加 10 万次
std::vector<std::thread> threads; // 用来保存所有线程对象,方便后面 join
// 创建并启动所有线程
for (int i = 0; i < thread_count; ++i) {
// emplace_back 会在 vector 内部直接构造 std::thread 对象
// 构造 std::thread 的同时,线程就已经开始执行 increment_unsafe 了
threads.emplace_back(increment_unsafe, times_each);
}
// 等待所有线程执行完毕,否则主线程可能提前退出
for (auto& t : threads) {
t.join(); // join 会阻塞,直到对应线程执行结束
}
// 理论上应该是 10 * 100000 = 1000000
std::cout << "期望值: " << thread_count * times_each << std::endl;
std::cout << "实际值: " << g_counter << std::endl; // 多次运行这个值大概率会小于期望值
return 0;
}
关键点解析:
g_counter = g_counter + 1;表面上是一行代码,但编译成机器指令后至少分为"读取内存到寄存器"“寄存器加一”"把寄存器写回内存"三步,线程切换随时可能发生在这三步之间。- 由于没有任何同步手段,多个线程的"读-改-写"三步会交叉进行,导致部分累加结果被覆盖丢失。
- 这个程序不会崩溃,只是结果不对,而且每次运行结果可能都不一样,这正是竞态条件"非确定性"的体现。
1.4 代码示例:使用互斥锁(mutex)修复
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // 引入互斥锁 std::mutex 和 std::lock_guard
long g_counter = 0;
std::mutex g_mutex; // 保护 g_counter 的互斥锁,同一时刻只允许一个线程持有它
void increment_safe(int times) {
for (int i = 0; i < times; ++i) {
// lock_guard 在构造时自动加锁,在离开作用域(这里是每次循环体结束)时自动解锁
// 这样即使中间抛异常,锁也一定会被释放,不会出现忘记解锁的问题
std::lock_guard<std::mutex> lock(g_mutex);
g_counter = g_counter + 1; // 现在这一整块被锁保护,同一时刻只有一个线程能执行
}
}
int main() {
const int thread_count = 10;
const int times_each = 100000;
std::vector<std::thread> threads;
for (int i = 0; i < thread_count; ++i) {
threads.emplace_back(increment_safe, times_each);
}
for (auto& t : threads) {
t.join();
}
std::cout << "期望值: " << thread_count * times_each << std::endl;
std::cout << "实际值: " << g_counter << std::endl; // 这次一定会等于期望值
return 0;
}
关键点解析:
std::mutex就像一把只有一把钥匙的锁,谁拿到钥匙(调用lock())谁才能进入临界区(访问共享数据),其他线程必须在门口等待。std::lock_guard是 RAII(资源获取即初始化)风格的封装:对象一创建就加锁,对象销毁(作用域结束)就自动解锁,避免手动调用unlock()时忘记写,或者中途异常导致锁没释放。- 加锁虽然解决了正确性问题,但也带来了性能开销——线程需要排队等待锁,失去了并发的优势,这也是为什么要"恰到好处"地使用锁,而不是无脑地把所有代码都锁起来。
2. 死锁(Deadlock)
2.1 什么是死锁
死锁指的是:两个或多个线程互相等待对方手里持有的资源,谁都不肯放手,结果所有相关线程永远卡在那里,程序表面上"卡死"了,但 CPU 占用可能是 0%(因为线程都在阻塞等待,不是在空转)。
最经典的例子:线程 A 拿着锁 1,想要锁 2;线程 B 拿着锁 2,想要锁 1。两个人都在等对方先放手,于是永远僵持下去。
2.2 死锁产生的四个必要条件
死锁的发生必须同时满足下面四个条件(这是经典的"死锁四要素"):
- 互斥:资源一次只能被一个线程占用。
- 持有并等待:线程已经持有至少一个资源,同时还在申请其他被占用的资源。
- 不可抢占:资源只能由持有它的线程主动释放,别人不能强行抢走。
- 循环等待:存在一个线程等待链,形成一个环,比如 A 等 B、B 等 C、C 等 A。
2.3 死锁的资源等待图
用图来表示上面提到的两个线程互相等待的循环依赖关系:
这张图里出现了一个环:线程1 → 锁2 → 线程2 → 锁1 → 线程1,环路一旦形成,死锁就发生了。
2.4 代码示例:产生死锁(错误示范)
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono> // 用于 std::chrono,制造一点延迟,让死锁更容易稳定复现
std::mutex mutex_A; // 资源 A 的锁
std::mutex mutex_B; // 资源 B 的锁
// 线程1:先锁 A,再锁 B
void thread1_job() {
std::lock_guard<std::mutex> lockA(mutex_A); // 先拿到 A
std::cout << "线程1 拿到了锁A,准备申请锁B" << std::endl;
// 故意 sleep 一下,让线程2 有机会先拿到 B,从而制造死锁的条件
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lockB(mutex_B); // 再申请 B(此时会被线程2卡住)
std::cout << "线程1 拿到了锁B" << std::endl;
}
// 线程2:先锁 B,再锁 A(注意顺序和线程1相反,这是死锁的根源)
void thread2_job() {
std::lock_guard<std::mutex> lockB(mutex_B); // 先拿到 B
std::cout << "线程2 拿到了锁B,准备申请锁A" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lockA(mutex_A); // 再申请 A(此时会被线程1卡住)
std::cout << "线程2 拿到了锁A" << std::endl;
}
int main() {
std::thread t1(thread1_job);
std::thread t2(thread2_job);
t1.join(); // 这里会永远阻塞,程序不会往下走
t2.join();
std::cout << "程序结束" << std::endl; // 这一行永远不会被打印出来
return 0;
}
关键点解析:
- 线程1 的加锁顺序是 A→B,线程2 的加锁顺序是 B→A,顺序相反是导致死锁的直接原因。
sleep_for只是为了让死锁"稳定复现"(不加的话死锁概率性发生,有时候程序碰巧跑完了),实际生产代码中不应该依赖 sleep 来暴露或掩盖问题。- 两个
lock_guard在各自的函数里都无法获得第二把锁,因此各自阻塞在lock_guard的构造函数里,t1.join()永远等不到线程1结束,程序卡死。
2.5 代码示例:用固定加锁顺序预防死锁
预防死锁最简单实用的办法之一,就是让所有线程都按照统一的顺序申请锁(比如永远先锁编号小的,再锁编号大的),这样就打破了"循环等待"这个必要条件。
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mutex_A;
std::mutex mutex_B;
// 线程1:统一约定,永远先锁A再锁B
void thread1_job() {
std::lock_guard<std::mutex> lockA(mutex_A);
std::cout << "线程1 拿到了锁A,准备申请锁B" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lockB(mutex_B);
std::cout << "线程1 拿到了锁B" << std::endl;
}
// 线程2:同样遵守约定,先锁A再锁B(顺序和线程1保持一致)
void thread2_job() {
std::lock_guard<std::mutex> lockA(mutex_A); // 顺序改成先A后B,和线程1一致
std::cout << "线程2 拿到了锁A,准备申请锁B" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lockB(mutex_B);
std::cout << "线程2 拿到了锁B" << std::endl;
}
int main() {
std::thread t1(thread1_job);
std::thread t2(thread2_job);
t1.join();
t2.join();
std::cout << "程序正常结束,没有死锁" << std::endl;
return 0;
}
关键点解析:
- 两个线程都遵循"先 A 后 B"的固定顺序,就不可能出现"我等你、你等我"的循环等待,因为大家都朝着同一个方向排队。
- 除了固定加锁顺序,C++ 标准库还提供了
std::lock(mutex_A, mutex_B)或std::scoped_lock,可以一次性、原子性地锁住多把锁,从底层帮你避免死锁,实际项目中更推荐使用这种方式。
3. 饥饿(Starvation)
3.1 什么是饥饿
饥饿指的是:某个线程一直无法获得它需要的资源(比如 CPU 时间片、锁),因为其他线程不断地抢占这个资源,导致这个线程"迟迟轮不到自己",一直原地等待,无法向前推进。
饥饿和死锁不一样:死锁是"谁都动不了",饥饿是"别人都在正常运转,只有倒霉的那个线程一直被晾在一边"。常见成因包括:
- 锁的实现不公平,总是让"手快"的线程优先拿到锁,运气不好的线程可能永远排在后面。
- 线程调度算法给某些线程分配了过低的优先级,导致高优先级线程持续抢占 CPU。
3.2 饥饿现象示意图
关键点解析:
- 饥饿问题很难用一段简单代码稳定复现,因为它依赖具体的锁实现和操作系统调度策略,这里用时序图帮助理解它的本质:并不是资源被永久占用(不是死锁),而是资源分配"不公平",导致某个线程总排在队尾。
- 解决办法通常是使用公平锁(按照申请顺序、先来先得地分配资源,比如票据锁 ticket lock)或者调整线程优先级、使用老化(aging)机制逐步提高等待线程的优先级。
4. 活锁(Livelock)
4.1 什么是活锁
活锁和死锁很像,都是线程之间互相谦让/互相冲突导致无法真正完成工作,区别在于:死锁中的线程是静止不动的(阻塞在那里),而活锁中的线程是一直在忙碌地做事,反复重试、反复退让,表面上看起来很"活跃",但实际上完全没有任何进展。
一个经典的生活比喻:两个人在走廊里迎面相遇,都想给对方让路,于是同时往左边闪,结果还是撞上;两人又同时往右边闪,结果又撞上……如此反复,谁都没能真正走过去。
4.2 活锁的时序图
4.3 代码示例:模拟活锁(错误示范)
#include <iostream>
#include <thread>
#include <atomic> // 使用原子变量 std::atomic 来模拟"资源是否被占用"
#include <chrono>
std::atomic<bool> resource_taken_by_A(false); // 资源当前是否被A占用的标记
std::atomic<bool> resource_taken_by_B(false); // 资源当前是否被B占用的标记
// 两个线程都想拿到唯一的资源,但设计成"发现冲突就都主动让步",
// 结果因为双方的让步时机完全一致,导致谁都拿不到资源
void polite_worker_A() {
for (int attempt = 0; attempt < 5; ++attempt) {
resource_taken_by_A = true; // A 表示"我想用资源了"
// 检测到 B 也想用资源,为了"礼貌",A 主动放弃,稍后重试
if (resource_taken_by_B) {
std::cout << "A 发现 B 也在用,A 让步" << std::endl;
resource_taken_by_A = false; // 主动放弃
// 两个人 sleep 的时长完全一样,导致节奏永远同步,无法错开
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue; // 回到循环开头重新尝试
}
std::cout << "A 成功使用资源" << std::endl;
resource_taken_by_A = false;
break;
}
}
void polite_worker_B() {
for (int attempt = 0; attempt < 5; ++attempt) {
resource_taken_by_B = true;
if (resource_taken_by_A) {
std::cout << "B 发现 A 也在用,B 让步" << std::endl;
resource_taken_by_B = false;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue;
}
std::cout << "B 成功使用资源" << std::endl;
resource_taken_by_B = false;
break;
}
}
int main() {
std::thread tA(polite_worker_A);
std::thread tB(polite_worker_B);
tA.join();
tB.join();
std::cout << "程序结束(尝试次数用完,可能双方都没有真正使用过资源)" << std::endl;
return 0;
}
关键点解析:
- 这里没有用锁把两个线程"卡死",两个线程始终在运行、始终在做事(设置标记、判断、sleep、重试),这正是活锁"看起来很忙但没有进展"的特点,和死锁的"静止阻塞"形成对比。
- 根本问题在于两个线程"让步"的行为完全对称、节奏完全一致,导致每次重试时又同时撞上。
- 解决活锁的常见办法是打破对称性:比如给每个线程设置随机的等待时间(而不是固定的 50 毫秒),或者引入优先级,让其中一方在冲突时"强硬"一次,不再无条件让步。
5. 四种问题的对比总结
| 问题类型 | 线程状态 | 根本原因 | 典型表现 |
|---|---|---|---|
| 竞态条件 | 线程都在正常运行 | 缺少同步,共享数据被并发读写 | 结果不确定、数据错误 |
| 死锁 | 线程永久阻塞 | 循环等待、资源不可抢占 | 程序卡死,CPU占用低 |
| 饥饿 | 部分线程长期等待 | 资源分配不公平 | 个别线程迟迟无法推进 |
| 活锁 | 线程一直在运行 | 重试策略过于对称 | 忙碌但没有任何进展 |
6. 应对多线程问题的常用手段
6.1 同步机制
使用锁(std::mutex)、互斥量等同步原语,保证同一时刻只有一个线程能访问共享数据,这是解决竞态条件最直接的方法(见前面第 1.4 节的代码示例)。
6.2 死锁预防与检测
- 预防:破坏死锁四个必要条件中的任意一个即可,最常用的是"固定加锁顺序"(见第 2.5 节代码),也可以使用
std::lock/std::scoped_lock一次性获取多把锁,避免出现"拿了一把再去抢下一把"的中间状态。 - 检测:系统在运行时维护一张资源分配图,定期检查图中是否出现环路(就是前面第 2.3 节画的那种图),一旦发现环路就判定发生了死锁,然后选择性地"牺牲"某个线程,强制释放它占用的资源来打破僵局。
6.3 线程调度
合理的线程调度算法能够兼顾公平性和效率,比如:
- 采用公平锁(先来先服务,谁先申请谁先拿到),从根源上减少饥饿的发生。
- 引入优先级老化机制:一个线程等待的时间越长,就逐步提升它的调度优先级,保证它最终一定能被调度到,避免被高优先级线程长期"插队"。
小结
多线程带来的四个经典问题可以这样简单记忆:
- 竞态条件:大家都在抢着改同一个数据,谁先谁后没定数,结果就乱了。
- 死锁:大家互相等对方手里的东西,谁都不肯先松手,全部卡住不动。
- 饥饿:大家都能正常干活,就是有一个人一直分不到资源,被"晾"在一边。
- 活锁:大家都很"客气",互相谦让,结果谦让来谦让去,谁都没能真正把事情做成。
理解这四个问题的本质区别(尤其是死锁的"静止"和活锁的"忙碌"的区别),是后续学习具体解决方案(锁、条件变量、无锁编程等)的基础。
多线程管理策略详解:从零理解如何写出安全高效的多线程程序
写多线程程序最头疼的地方,不是"怎么开一个线程",而是"多个线程同时跑的时候,怎么保证数据不乱、程序不卡死、性能还不差"。这篇笔记就是把常见的几种线程管理思路,从最基础的概念开始,一步步拆开讲清楚,每一段都配上能跑起来的 C++ 代码和图示。
整体思路可以先用一张图过一遍,后面每个模块再展开细讲:
下面逐个展开。
一、减少共享状态:能不共享就不共享
1.1 为什么要这么做
多线程出问题的根本原因,几乎都是"两个线程同时读写同一块内存"。如果一开始就让每个线程尽量只操作自己的私有数据,那么根本不需要加锁、不需要同步,自然也就不会有数据竞争(data race)。
这里的关键工具叫线程本地存储(Thread-Local Storage,简称 TLS)。它的意思是:同一个变量名,在不同线程里对应的是不同的内存地址,线程 A 改了它,线程 B 看到的还是自己那份,互不干扰。
用一个类比理解:全局变量像是"公司前台的一张共享便签纸",谁都能写,写乱了就麻烦;线程本地变量像是"每个员工自己抽屉里的便签本",各写各的,不会冲突。
1.2 C++ 代码示例:线程本地存储
#include <iostream> // 用于标准输出 std::cout
#include <thread> // 用于创建和管理线程 std::thread
#include <vector> // 用于存放多个线程对象
// 关键点:thread_local 关键字
// 每个线程调用这个函数时,counter 都是"自己专属"的一份拷贝,
// 不同线程之间互不可见、互不影响,因此完全不需要加锁。
void worker_task(int worker_id) {
// thread_local 修饰的局部静态变量:
// - 生命周期跨越整个函数多次调用(类似 static)
// - 但每个线程拥有独立的一份,不是所有线程共享同一份
thread_local int counter = 0;
for (int i = 0; i < 5; ++i) {
++counter; // 只操作本线程自己的 counter,不存在数据竞争
std::cout << "线程 " << worker_id
<< " 的 counter = " << counter << std::endl;
}
}
int main() {
std::vector<std::thread> threads; // 存放所有线程对象的容器
// 创建 3 个线程,每个线程各自独立累加自己的 counter
for (int i = 0; i < 3; ++i) {
// emplace_back 直接在 vector 内部构造 std::thread 对象,
// 参数 worker_task 是线程要执行的函数,i 是传给函数的参数
threads.emplace_back(worker_task, i);
}
// 等待所有线程执行完毕,防止主线程提前退出导致子线程被强制终止
for (auto& t : threads) {
t.join(); // join() 会阻塞,直到对应线程跑完
}
return 0;
}
代码讲解:
thread_local是这段代码的核心关键字。加了它之后,counter这个变量在每个线程第一次执行到这行代码时都会被单独初始化一份,三个线程各自维护自己的计数,互不可见。- 因为没有任何一个变量是"多个线程共同写"的,所以这段代码天然线程安全,不需要
mutex(互斥量)。 join()是必须的:如果不调用,main函数可能在子线程还没跑完时就结束,导致未定义行为(子线程被强制杀死或访问已销毁的资源)。
当然,实际系统里不可能所有数据都是私有的,总会有一部分数据必须共享(比如全局的任务计数、共享缓存)。对于这部分真正需要共享的数据,才需要用后面讲的锁或原子操作来保护,做到有的放矢,而不是"逢变量必加锁"。
二、锁的层次结构:按顺序拿锁,从根本上防止死锁
2.1 死锁是怎么产生的
死锁最经典的场景:线程 A 拿到了锁 1,等着锁 2;线程 B 拿到了锁 2,等着锁 1。两个人都在等对方先放手,结果谁也动不了,程序卡死。
线程 A:持有 锁1 ---> 等待 锁2 ┐
├── 互相等待,死锁!
线程 B:持有 锁2 ---> 等待 锁1 ┘
2.2 解决办法:锁的层次结构(Lock Hierarchy)
解决思路很直接:给所有的锁定义一个固定的获取顺序,规定"必须先拿粗粒度的锁,再拿细粒度的锁",所有线程都严格遵守这个顺序,就不可能出现"你等我、我等你"的循环等待。
- 粗粒度锁:保护的范围大,比如整个数据库、整个链表。
- 细粒度锁:保护的范围小,比如链表里的某一个节点、数据库里的某一行。
只要所有线程都遵守"从上往下拿锁,用完从下往上还锁"这个顺序,就永远不会出现循环等待。
2.3 C++ 代码示例:按固定顺序加锁避免死锁
#include <iostream>
#include <mutex> // 提供 std::mutex 和 std::scoped_lock
#include <thread>
std::mutex coarse_lock; // 第 0 层:粗粒度锁,保护范围大
std::mutex fine_lock; // 第 1 层:细粒度锁,保护范围小
// 关键点:无论从哪个函数进入,都严格按照"先 coarse_lock 后 fine_lock"的顺序加锁,
// 绝不会出现某个线程先拿 fine_lock 再等 coarse_lock 的情况。
void access_resource(int thread_id) {
// std::scoped_lock 可以一次锁多把锁,并且内部保证按安全顺序加锁,
// 即使传入顺序不同也不会死锁;这里为了演示"层次"概念,写成两次单独加锁。
std::lock_guard<std::mutex> lock1(coarse_lock); // 第一步:先拿粗粒度锁
std::cout << "线程 " << thread_id << " 获得了粗粒度锁" << std::endl;
{
std::lock_guard<std::mutex> lock2(fine_lock); // 第二步:再拿细粒度锁
std::cout << "线程 " << thread_id << " 获得了细粒度锁,开始操作数据" << std::endl;
// 这里执行真正需要保护的操作……
} // lock2 在这里自动释放(RAII,离开作用域自动解锁)
std::cout << "线程 " << thread_id << " 释放了细粒度锁,继续持有粗粒度锁" << std::endl;
} // lock1 在这里自动释放
int main() {
std::thread t1(access_resource, 1);
std::thread t2(access_resource, 2);
t1.join();
t2.join();
return 0;
}
代码讲解:
std::lock_guard是 RAII(资源获取即初始化)风格的锁管理工具:构造时自动加锁,析构(离开作用域)时自动解锁,不用手动写unlock(),也不用担心异常导致锁忘记释放。- 两个线程
t1、t2都是先申请coarse_lock,再申请fine_lock,顺序完全一致,所以无论谁先谁后,都不会出现"A 等 B 手里的锁,B 又等 A 手里的锁"这种循环。 - 如果反过来,让某个函数先拿
fine_lock再拿coarse_lock,就破坏了层次约定,才有可能死锁。
2.4 无锁数据结构简单提一下
除了按顺序加锁,还有一种更激进的思路:干脆不用锁,靠 CPU 提供的原子操作(std::atomic)来保证数据一致性。原子操作是硬件层面保证"不可被打断"的读写/比较交换指令,多个线程同时操作同一个原子变量时,不会读到"写了一半"的中间状态。
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
// 关键点:std::atomic<int> 保证了对 counter 的自增操作是"原子"的,
// 即使 100 个线程同时执行 ++counter,最终结果也是精确的 100,
// 不需要任何 mutex。
std::atomic<int> counter{0};
void increment_task() {
for (int i = 0; i < 1000; ++i) {
++counter; // 原子自增,底层由 CPU 的原子指令保证不会出现数据竞争
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_task);
}
for (auto& t : threads) {
t.join();
}
// 10 个线程各累加 1000 次,最终结果一定是精确的 10000
std::cout << "最终计数值: " << counter.load() << std::endl;
return 0;
}
原子操作的开销通常比加锁小很多,但只适合非常简单的操作(比如计数器、标志位),一旦涉及"多步骤、要保持一致性"的复杂逻辑,还是得用锁或者更复杂的无锁算法(比如无锁链表用的 CAS 循环)。
三、超时机制:不让线程无限等下去
如果一个线程申请锁的时候,锁一直被别人占着,它可能会永远卡在那里等待。给锁的获取设置一个超时时间,超过这个时间还没拿到锁就主动放弃、稍后重试,可以避免"死等"的情况。
#include <iostream>
#include <mutex>
#include <chrono> // 提供时间相关的类型,如 std::chrono::milliseconds
#include <thread>
std::timed_mutex resource_lock; // timed_mutex 支持带超时的加锁尝试
void try_access(int thread_id) {
// try_lock_for 关键点:
// 尝试在指定时间内获取锁,成功返回 true,超时未获取到返回 false,
// 不会像普通 lock() 那样无限期阻塞等待。
if (resource_lock.try_lock_for(std::chrono::milliseconds(200))) {
std::cout << "线程 " << thread_id << " 在超时时间内获取到锁" << std::endl;
// 模拟占用资源一段时间
std::this_thread::sleep_for(std::chrono::milliseconds(300));
resource_lock.unlock(); // 手动释放锁(也可以改用 lock_guard 自动管理)
} else {
// 没抢到锁,不是死等,而是打印提示后可以选择重试或做别的事
std::cout << "线程 " << thread_id << " 等待超时,放弃本次加锁,稍后重试" << std::endl;
}
}
int main() {
std::thread t1(try_access, 1);
std::thread t2(try_access, 2);
t1.join();
t2.join();
return 0;
}
代码讲解:try_lock_for 是 std::timed_mutex 特有的方法,普通的 std::mutex 没有这个功能。它的返回值是 bool,代码里用 if 分支处理"抢到锁"和"超时放弃"两种结果,这样即使某把锁被长期占用,其他线程也不会永久卡死,而是可以走"重试"或者"跳过做别的事"这条路。
四、线程池:复用线程 + 任务队列
4.1 为什么需要线程池
如果每来一个任务就 new 一个线程,用完再销毁,线程的创建和销毁本身就有不小的系统开销(涉及操作系统调度、栈空间分配等)。如果任务量很大、很频繁,这个开销会严重拖累性能。
线程池的思路是:提前创建好一批线程,让它们常驻、反复使用;任务来了以后,不是新开线程去跑,而是丢进一个"任务队列"里,由这些常驻线程从队列里按**先进先出(FIFO)**的顺序取任务执行。这样既避免了反复创建销毁线程的开销,也通过队列自然实现了负载均衡——谁先空闲,谁就去队列里取下一个任务。
4.2 整体架构图
用 ASCII 图再直观感受一下线程池内部的结构:
+-------------------+
提交任务 --> | 任务队列(FIFO) |
| [t4][t3][t2][t1] |
+---------+---------+
|
+------------+------------+
| | |
+----v----+ +----v----+ +----v----+
| 工作线程1 | | 工作线程2 | | 工作线程3 |
| (空闲/忙) | | (空闲/忙) | | (空闲/忙) |
+---------+ +---------+ +---------+
线程池的大小需要根据实际负载去调:池子太小,任务会在队列里堆积、迟迟得不到处理;池子太大,又会浪费系统资源(每个线程都占内存和调度开销)。
4.3 任务提交与执行的时序图
线程池内部逻辑稍微复杂一点,涉及"主线程提交任务、工作线程等待/被唤醒、任务出队执行"这几个环节的配合,用时序图梳理会更清楚:
4.4 C++ 完整实现:一个简易线程池
#include <iostream>
#include <vector>
#include <queue> // 用作任务队列,天然支持 FIFO
#include <thread>
#include <mutex>
#include <condition_variable> // 用于线程间"等待/通知",避免工作线程空转浪费 CPU
#include <functional> // std::function 用来存放"任意可调用对象"
#include <atomic>
class ThreadPool {
public:
// 构造函数:一次性创建 thread_count 个工作线程,之后一直复用它们
explicit ThreadPool(size_t thread_count) : stop_flag_(false) {
for (size_t i = 0; i < thread_count; ++i) {
// 关键点:每个工作线程执行的都是同一个 worker_loop 函数,
// 它们会一直循环"取任务->执行任务",直到线程池被销毁。
workers_.emplace_back([this] { this->worker_loop(); });
}
}
// 提交任务:外部代码调用这个函数,把一个任务(无参数、无返回值的函数)丢进队列
void submit(std::function<void()> task) {
{
// 关键点:修改共享的任务队列前必须加锁,防止多个线程同时提交任务导致队列数据错乱
std::lock_guard<std::mutex> lock(queue_mutex_);
tasks_.push(std::move(task)); // 把任务放到队尾
} // 锁在这里自动释放,尽量缩小加锁范围,减少线程间的等待时间
// 关键点:唤醒一个正在 wait() 上睡眠的工作线程,让它去处理新任务,
// 如果所有工作线程都在忙,这次通知不会立刻起作用,但任务已经在队列里,
// 等某个线程空闲下来自然会取到。
condition_.notify_one();
}
// 析构函数:线程池销毁时,通知所有线程停止,并等待它们都执行完当前任务、安全退出
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(queue_mutex_);
stop_flag_ = true; // 设置停止标志,通知工作线程"不要再等新任务了"
}
condition_.notify_all(); // 唤醒所有还在 wait() 上睡眠的线程,让它们检查停止标志
for (std::thread& worker : workers_) {
worker.join(); // 等待每个工作线程真正退出,避免线程对象析构时线程还在跑
}
}
private:
// 工作线程反复执行的主循环:不断地"取任务->执行任务"
void worker_loop() {
while (true) {
std::function<void()> task; // 用来存放本次要执行的任务
{
// 关键点:unique_lock 配合 condition_variable 使用,
// 因为 wait() 内部需要临时解锁再重新加锁,lock_guard 做不到这一点。
std::unique_lock<std::mutex> lock(queue_mutex_);
// 关键点:wait 的第二个参数是"谓词",
// 只有当 (队列非空) 或者 (收到停止信号) 时才会真正醒来继续往下执行,
// 否则会一直在这里阻塞睡眠,不占用 CPU,避免"忙等待"浪费资源。
condition_.wait(lock, [this] {
return stop_flag_ || !tasks_.empty();
});
// 如果收到停止信号,并且队列已经空了,说明可以安全退出这个循环了
if (stop_flag_ && tasks_.empty()) {
return;
}
// 从队首取出一个任务(FIFO,保证公平性,先提交的先被执行)
task = std::move(tasks_.front());
tasks_.pop();
} // 锁在这里释放,执行任务时不再持有锁,避免长时间占用队列锁阻塞其他线程提交/取任务
task(); // 真正执行任务,这一步在锁的保护范围之外
}
}
std::vector<std::thread> workers_; // 持有的所有工作线程
std::queue<std::function<void()>> tasks_; // 任务队列,FIFO 存放待执行任务
std::mutex queue_mutex_; // 保护 tasks_ 和 stop_flag_ 的互斥量
std::condition_variable condition_; // 用于"任务队列有新任务"或"该停止了"的通知
bool stop_flag_; // 标记线程池是否正在关闭
};
int main() {
ThreadPool pool(3); // 创建一个含 3 个工作线程的线程池
// 提交 6 个任务,任务量大于线程数,正好演示"任务排队、线程复用"的效果
for (int i = 1; i <= 6; ++i) {
pool.submit([i] {
std::cout << "任务 " << i << " 正在被线程 "
<< std::this_thread::get_id() << " 执行" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时工作
});
}
// 主线程稍作等待,确保看到任务执行的输出后再退出
// (实际项目中通常会用 future/promise 等机制等待任务真正完成,这里为了演示简化处理)
std::this_thread::sleep_for(std::chrono::seconds(1));
return 0; // ThreadPool 的析构函数会在这里被调用,自动等待所有线程安全退出
}
代码逐行讲解要点:
- 构造函数
ThreadPool(size_t thread_count):一次性把线程都建好放进workers_,之后不再新建线程,任务来了直接分给这些"现成"的线程,省去反复创建/销毁线程的开销。 submit函数:外部提交任务时,先加锁把任务塞进tasks_队列,然后用condition_.notify_one()唤醒一个可能在睡觉的工作线程。注意加锁的范围只包住"压入队列"这一步,尽量小,减少线程互相等待的时间。worker_loop函数(核心):- 用
std::unique_lock而不是std::lock_guard,因为condition_variable::wait()需要能在等待过程中临时释放锁(否则别的线程永远拿不到锁去提交任务),lock_guard不支持这种"临时解锁再加锁"的操作,unique_lock才支持。 wait(lock, predicate)的意思是:“如果 predicate 返回 false,就一直睡眠;只有 predicate 返回 true 时才真正往下走”,这里的 predicate 是"队列非空,或者要停止了",这样可以避免"虚假唤醒"(spurious wakeup,操作系统有时会无缘无故唤醒等待的线程)导致的错误判断。- 取出任务后先解锁再执行(通过让
lock在内层作用域结束时自动析构解锁),这一点很关键:如果占着锁去执行任务,其他线程在这段时间内既不能提交新任务、也不能取任务,整个线程池就退化成了"单线程串行执行"。
- 用
- 析构函数:先把
stop_flag_设为true,再notify_all()唤醒所有线程,让它们从wait()里醒过来检查停止标志、安全退出循环,最后依次join(),确保线程池对象销毁时,所有线程都已经真正结束运行,不会出现线程还在跑、但它依赖的对象已经被析构的悬空访问问题。
五、常见同步原语怎么选
不同的同步工具适用场景不一样,简单对比一下:
| 原语 | 一句话理解 | 典型使用场景 |
|---|---|---|
| 互斥量(mutex) | 同一时刻只允许一个线程进入某段代码 | 保护共享变量的读写 |
| 信号量(semaphore) | 允许最多 N 个线程同时进入 | 限制并发连接数、资源池容量控制 |
| 条件变量(condition variable) | 让线程"睡到满足某个条件才醒" | 生产者-消费者模型,比如上面线程池里等任务 |
| 原子操作(atomic) | 硬件级保证的不可分割操作 | 简单计数器、标志位,避免用锁的开销 |
选择的基本原则:能用原子操作解决的简单场景就不用锁;需要保护一段"多步骤"的逻辑用互斥量;需要限流、控制并发数量用信号量;需要"等某个条件成立再继续"用条件变量(并且几乎总是搭配互斥量一起用,就像线程池例子里那样)。
六、测试、调试与可伸缩性
多线程 bug 有个特点:不一定每次都复现,可能跑一千次才出一次问题,这种问题叫竞态条件(race condition),排查起来很痛苦。所以专门有一些工具用来帮忙揪出这类问题:
- 线程消毒器(Thread Sanitizer,简称 TSan):编译时加上特定选项,运行时会在检测到数据竞争的瞬间直接报错并打印出冲突的两处代码位置,比"猜"要高效得多。
- 性能剖析器(Profiler):帮忙看哪些锁被频繁争抢,找出真正的性能瓶颈在哪,而不是凭感觉去优化。
- 线程转储(Thread Dump):把某一时刻所有线程的调用栈打印出来,卡死时看每个线程"卡在哪一行",很容易定位是谁在等谁。
在性能和可伸缩性方面,核心思路是:线程数量不是越多越好,要跟机器的 CPU 核心数、任务本身的性质(是"算得多"的 CPU 密集型,还是"等得多"的 IO 密集型)相匹配,同时持续监控 CPU 利用率、锁的争抢情况,发现瓶颈及时调整线程池大小或者优化锁的粒度。
最后,团队层面上,多线程代码最好有统一的编码规范(比如约定死锁避免顺序、统一的锁封装方式),并且定期做代码评审,因为这类 bug 一旦漏进生产环境,往往很难复现、很难排查。
七、总结
把这几种手段串起来看,其实是一条从"根本上不需要同步"到"不得不同步时怎么同步得更安全高效"的思路链:
- 优先用线程本地存储让数据不共享,从源头消灭竞争;
- 真正需要共享的数据,简单场景用原子操作,复杂场景用锁;
- 用锁时按固定层次顺序获取,从根本上防止死锁;
- 给锁的获取加上超时,防止意外情况下的无限等待;
- 用线程池 + 任务队列复用线程资源,避免频繁创建销毁的开销;
- 根据场景选对同步原语(互斥量/信号量/条件变量/原子操作);
- 借助专门工具测试调试,持续监控性能与可伸缩性;
- 团队内部形成统一规范,让多线程代码可维护、可协作。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)