从零掌握 Linux C++ 多线程编程:线程、互斥锁、条件变量与线程池
从零掌握 Linux C++ 多线程编程:线程、互斥锁、条件变量与线程池
本文适合学过 C++ 基础语法、想理解多线程但被"竞态条件""虚假唤醒"吓退的读者。读完你能自己写出一个线程池,并看懂 TinyWebServer(github的一个项目) 的并发部分。
前置知识(30 秒自检)
- C++ 基础语法:函数、类、
std::vector、Lambda 表达式 - Linux 命令行:会用
g++编译,能在终端运行程序 - 不需要:没碰过 pthread 没关系,本文用 C++11 标准库
一、为什么需要多线程
假设你开了一家小饭馆。
刚开始只有你一个人:客人来了你招呼、点了菜你做、做完你端上去、然后收钱。一桌客人还好,三桌同时来呢?后面的人要等前面全部流程走完才能点菜 — 这就是单线程程序。
你雇了几个厨师:你负责招呼客人和分配任务(主线程),每个厨师接到任务就干活(工作线程)。几个厨师同时炒菜,互不影响 — 这就是多线程。
1.1 线程与进程:一张图说清楚
进程 = 一家饭馆(独立营业执照、独立账本)
├── 主线程 = 老板(招呼客人、分派任务)
├── 工作线程 1 = 厨师 A(炒菜中)
├── 工作线程 2 = 厨师 B(切菜中)
└── 工作线程 3 = 厨师 C(洗碗中)
线程共享:菜板(堆内存)、菜单(全局变量)
线程独立:每人手里的菜刀(栈)、当前在干嘛(寄存器)
线程是操作系统调度的最小单位。 一个进程(饭馆)可以有多个线程(员工),它们共享进程的内存空间,但各自拥有独立的栈和寄存器上下文。
1.2 创建线程(C++11 一行搞定)
#include <thread>
#include <iostream>
void cook(const std::string& dish) {
std::cout << "正在做: " << dish << std::endl;
}
int main() {
std::thread chef(cook, "宫保鸡丁"); // 构造函数执行时线程就启动了
chef.join(); // 老板等厨师做完再下班
return 0;
}
std::thread 的构造函数接受一个可调用对象(函数、函数对象、Lambda)+ 参数。关键点:构造完成的那一刻线程就启动了,不需要像 Java 那样显式调 start()。
二、线程的"善后":join 与 detach
线程启动后,你必须决定怎么处理它。这就像厨师下班:你是等他做完菜再关门(join),还是让他自己走不管了(detach)?
2.1 一张表看懂区别
join() |
detach() |
|
|---|---|---|
| 你等它吗 | 等,调用 join 的线程会阻塞直到目标线程结束 | 不等,调用后各走各的 |
| 谁回收资源 | 调用 join 的线程负责 | 操作系统在线程结束时自动回收 |
| 能拿返回值吗 | 不能(std::thread 不支持,需要 std::future) |
不能 |
| 适用场景 | 我需要线程的结果才能继续 | 发出去的任务,不关心结果 |
| 最大风险 | 忘记调用 join → 程序崩溃 | 线程访问了已销毁的变量 → 悬空指针 |
2.2 最危险的坑:忘记 join
void dangerous() {
std::thread t(cook, "红烧肉");
// 函数结束,t 的析构函数被调用
// 但 t 还是 joinable 状态(既没 join 也没 detach)
// → std::terminate() 被调用 → 程序崩溃!
}
规则很简单: 每个 std::thread 对象销毁前,要么调了 join(),要么调了 detach()。二选一,必须选,不能两个都选。
还有个隐藏坑:既不 join 也不 detach 的线程结束后,线程的 PCB(进程控制块)和栈资源不会被回收 → 内存泄漏(俗称僵尸线程)。
2.3 线程执行顺序:你说了不算
谁先执行、谁后执行,由操作系统的调度器决定,程序员无法预测,也无法控制。
std::thread t1(task, "A");
std::thread t2(task, "B");
// A 和 B 谁先打印?不知道。每次运行可能不一样。
调度器的基本规则:
- 调了
sleep的线程进入等待队列,不参与 CPU 轮转 - 多个线程同时 sleep 到期,谁先被唤醒取决于内核定时器精度和当前 CPU 负载
- 时间片轮转只发生在就绪队列的线程之间
三、互斥锁:保护共享数据
3.1 一行 C++ 代码的"三重人格"
先看一段看似人畜无害的代码:
int counter = 0;
void add10000() {
for (int i = 0; i < 10000; i++) {
counter++; // 就这一行,线程安全吗?
}
}
int main() {
std::thread t1(add10000);
std::thread t2(add10000);
t1.join(); t2.join();
std::cout << counter << std::endl; // 你猜输出是多少?
}
你的直觉:20000。实际输出:每次都不一样,15000 到 20000 之间随机。
为什么?因为 counter++ 虽然只写了一行,底层 CPU 却是三条指令:
mov eax, [counter] ← 第1步:从内存读到寄存器
add eax, 1 ← 第2步:在寄存器里加 1
mov [counter], eax ← 第3步:从寄存器写回内存
两个线程同时执行时,可能发生这样的穿插:
线程 A: mov eax, [counter] → eax=5
线程 B: mov eax, [counter] → eax=5 ← 也读到了 5!
线程 A: add eax, 1 → eax=6
线程 A: mov [counter], eax → counter=6
线程 B: add eax, 1 → eax=6 ← 在 5 的基础上加了 1
线程 B: mov [counter], eax → counter=6 ← 覆盖!本来应该是 7
两次 ++ 操作,最终只加了一次。这就是竞态条件(Race Condition) — 多个线程同时访问共享数据,结果取决于它们执行的精确时序,而这种时序你无法控制。
3.2 互斥锁:独木桥原理
互斥锁(mutex = mutual exclusion,互斥)就像一个独木桥:每次只允许一个人通过。
#include <mutex>
int counter = 0;
std::mutex mtx;
void add10000_safe() {
for (int i = 0; i < 10000; i++) {
mtx.lock(); // 拿锁。如果锁被别的线程持有,这里会阻塞
counter++; // 现在我是唯一的访问者
mtx.unlock(); // 放锁,其他人可以进来了
}
}
3.3 lock_guard:为什么不推荐手动 lock/unlock
手动 lock/unlock 有一个致命缺陷:如果 lock 和 unlock 之间的代码抛了异常,unlock 永远不会执行 → 死锁。
// 危险写法
mtx.lock();
riskyFunction(); // 如果这里抛异常……
mtx.unlock(); // 这行永远不会执行!锁永远不释放 → 死锁
// 安全写法:用 lock_guard
{
std::lock_guard<std::mutex> guard(mtx); // 构造时自动 lock
riskyFunction(); // 即使抛异常……
} // 离开作用域时,guard 的析构函数自动 unlock ← 利用 C++ 的栈展开保证
// lock_guard 本质上就两件事:
// 构造函数: mtx.lock()
// 析构函数: mtx.unlock()
这个技巧叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化) — 把资源的释放绑定到对象的生命周期上,利用 C++ 析构函数"离开作用域必定执行"的保证来管理资源。这是 C++ 最重要的惯用法之一。
3.4 锁的代价:并行变串行
加锁不是免费的:
- 性能:被锁保护的代码段(临界区)同一时刻只有一个线程在执行,等于把并行变回了串行
- 粒度:临界区越小越好 — 只保护必须保护的数据,不要把不相干的代码也锁进去
- 死锁:线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1 → 两人永远等下去
四、条件变量:没活睡觉,有活叫醒
4.1 互斥锁解决不了的问题
互斥锁能保护共享数据不被同时修改,但有一件事它做不到:让线程在没有任务时睡觉,有任务时被叫醒。
想象一个餐馆:厨师没活干的时候怎么办?
如果没有条件变量,只能这样做:
// 愚蠢的轮询(忙等 Busy Waiting)
while (true) {
mtx.lock();
if (!tasks.empty()) {
// 取一个任务干活
}
mtx.unlock();
// 没活 → 回到循环头 → 立刻又 lock → 还是没活 → unlock → lock...
// CPU 一直在空转,占用率 100%,全部是无效检查!
}
这就好比你每隔 0.1 秒跑到后厨看一眼有没有新订单 — 你什么都没干,但累得满头大汗(CPU 100%)。
条件变量的意义:没活时睡觉(不占 CPU),有活时被摇铃叫醒。
4.2 核心操作
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<std::string> tasks;
// ============ 消费者(厨师):等待任务 ============
void chef() {
std::unique_lock<std::mutex> lock(mtx);
// wait 做了三件事:
// 1. 检查条件 → 满足 → 直接返回(不睡觉)
// 2. 不满足 → 解锁 mtx → 进入等待状态(睡觉)
// 3. 被唤醒 → 重新加锁 mtx → 再检查条件 → 不满足继续睡
cv.wait(lock, []{ return !tasks.empty(); });
std::string task = tasks.front();
tasks.pop();
lock.unlock(); // 取完任务立刻解锁,不持锁干活
// 执行 task...
}
// ============ 生产者(老板):添加任务 ============
void boss(const std::string& order) {
{
std::lock_guard<std::mutex> lock(mtx);
tasks.push(order);
}
cv.notify_one(); // 摇铃:叫醒一个睡觉的厨师
}
4.3 wait 为什么必须传判断条件
因为虚假唤醒(Spurious Wakeup)。
POSIX 标准明确规定:操作系统可能在你没有调用 notify 的情况下把线程踢醒。这是底层内核实现决定的,不是 bug。如果 wait 不检查条件就继续执行,会拿着空队列干活 → 逻辑错误。
// 不安全(被虚假唤醒就会出错)
cv.wait(lock);
// 安全(被唤醒后先验条件,不满足就接着睡)
cv.wait(lock, []{ return !tasks.empty(); });
cv.wait(lock, condition) 等价于:
while (!condition()) {
wait(lock); // 解锁 → 睡觉 → 被唤醒 → 加锁 → 再检查条件
}
4.4 notify_one 还是 notify_all
notify_one() |
notify_all() |
|
|---|---|---|
| 唤醒多少线程 | 1 个 | 全部 |
| 真正干活的 | 1 个 | 1 个(因为只有一把锁,只有一个能抢到任务) |
| 其他线程 | 继续睡觉,不受影响 | 被吵醒 → 争锁 → 发现任务已经被抢了 → 回去睡 |
| 性能 | 无浪费 | 额外的锁竞争 + 空唤醒 = 惊群效应 |
| 什么时候用 | 日常来新任务 | 系统关闭(每个线程都要起床检查退出条件) |
一个原则足矣:日常用 notify_one,关闭时用 notify_all。
4.5 两个常见问题
Q1:取了任务后为什么要先 unlock 再干活?
如果拿着锁干活:其他厨师连看黑板(检查队列)的权利都没有。但黑板已经空了,锁应该释放。
取任务 → 立刻 unlock → 干活(让其他人也能看黑板)
Q2:unique_lock 和 lock_guard 有什么区别?
lock_guard |
unique_lock |
|
|---|---|---|
| 能手动 unlock 吗 | 不能(析构才解锁) | 能(lock.unlock()) |
能配合 cv.wait 吗 |
不能 | 能(wait 内部需要临时解锁再加锁) |
| 开销 | 更小 | 稍大(多一个 bool 管理状态) |
| 适用场景 | 简单的"锁住 → 干活 → 解锁" | 条件变量、需要灵活锁控制的场景 |
五、线程池:把三样东西串起来
线程、互斥锁、条件变量,分开学的时候你可能觉得"还行,不难"。但要真正理解它们,必须看一个同时用到了三者的例子:线程池。
5.1 一个请求的一生
主线程(老板) 工作线程 1..N(厨师们)
──────────── ──────────────────
线程池构造时就启动了,全部在 cv.wait() 睡觉
↓
HTTP 请求到达!
↓
老板: append(task) ──→ 任务队列 ──→ cv.notify_one() 摇铃
↓
一个厨师被叫醒
加锁 → 取任务 → 解锁
↓
不持锁,从容做菜
↓
做完,回到循环头继续 cv.wait()
5.2 核心代码拆解
class ThreadPool {
std::vector<std::thread> m_threads; // 线程数组
std::queue<std::function<void()>> m_tasks; // 任务队列
std::mutex m_mtx; // 保护队列
std::condition_variable m_cv; // 通知机制
bool m_stop = false; // 关闭标志
int m_max_tasks; // 队列上限
public:
// ========== 1. 构造函数:招厨师 ==========
ThreadPool(int thread_num, int max_tasks)
: m_max_tasks(max_tasks) {
for (int i = 0; i < thread_num; i++) {
// emplace_back 直接在 vector 里构造 thread 对象
// workerLoop 是成员函数,需要传 this 作为对象指针
m_threads.emplace_back(&ThreadPool::workerLoop, this);
}
}
// ========== 2. 添加任务(老板视角)==========
bool addTask(std::function<void()> task) {
std::unique_lock<std::mutex> lock(m_mtx);
if (m_tasks.size() >= m_max_tasks) {
return false; // 队列满了,拒绝任务(防止内存爆炸)
}
m_tasks.push(std::move(task));
lock.unlock(); // 先解锁
m_cv.notify_one(); // 再通知(先解锁后通知减少不必要的唤醒)
return true;
}
// ========== 3. 工作循环(厨师视角)==========
void workerLoop() {
while (true) {
std::unique_lock<std::mutex> lock(m_mtx);
// 等两个条件之一:要么有活,要么要关门了
m_cv.wait(lock, [this] {
return m_stop || !m_tasks.empty();
});
if (m_stop && m_tasks.empty()) {
return; // 要关门且没活了,下班
}
std::function<void()> task = std::move(m_tasks.front());
m_tasks.pop();
lock.unlock(); // 取完任务立刻解锁
task(); // 不持锁干活
}
}
// ========== 4. 析构函数:优雅关门 ==========
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(m_mtx);
m_stop = true; // 挂出打烊牌子
}
m_cv.notify_all(); // 叫醒所有厨师(不是 notify_one!)
for (auto& t : m_threads) {
t.join(); // 等所有人收拾完下班
}
}
};
5.3 为什么析构时用 notify_all 而不是 notify_one
因为 notify_one 只叫醒一个线程。假如被叫醒的那个线程正在处理一个长任务,它暂时不会回到 wait 检查 m_stop,那其他线程就永远不会被叫醒 → 析构函数里的 join 永远在等 → 程序卡死。
notify_all 则确保每个线程最终都会醒来检查退出条件。
六、对照 TinyWebServer:理论到实战
TinyWebServer 用 C 风格的 pthread 和 sem_t,但你学完 C++11 版本后,对照着看就很容易了:
| 概念 | C++11 | TinyWebServer(pthread) |
|---|---|---|
| 创建线程 | std::thread 构造函数 |
pthread_create() |
| 线程回收 | join() |
pthread_detach() 或不 detach + pthread_join() |
| 互斥锁 | std::mutex + lock_guard |
pthread_mutex_t + 自定义 locker 封装类 |
| 条件变量/信号量 | std::condition_variable |
sem_t(POSIX 信号量)+ 自定义 sem 封装类 |
| 等待操作 | cv.wait(lock, condition) |
sem.wait() |
| 通知操作 | cv.notify_one() |
sem.post() |
| 任务容器 | std::queue<std::function<void()>> |
std::list<T*> |
注意:TinyWebServer 用信号量而非条件变量。信号量的本质是一个整数计数器,sem.wait() 把计数器减 1(减到负就阻塞),sem.post() 把计数器加 1(有线程在等就唤醒)。条件变量没有"计数"的概念 — 它只管"通知"。
七、常见 Bug 速查表
每行都是真实出现过的问题:
| Bug 现象 | 根因 | 修复 |
|---|---|---|
counter 结果比预期少 |
counter++ 不是原子操作 |
加互斥锁保护临界区 |
| 程序直接崩溃 | 线程对象析构前没 join 也没 detach | 确保每个线程都 join 或 detach |
| 整个程序卡住不动 | 异常导致 unlock 永远不执行 | 用 lock_guard / unique_lock 代替手动锁 |
| CPU 占用 100% 但没在处理任务 | 用轮询代替条件变量 | 改 while(true) 空转为 cv.wait() |
| 偶尔取到空任务 | wait 没带判断条件,被虚假唤醒 | 必须写 cv.wait(lock, []{ return 条件; }) |
| 日志输出乱码/错行 | 多线程同时写 cout |
加锁保护输出,或用专门日志线程 |
| 性能比单线程还差 | 干活时持有锁,其他线程全在等 | 取完任务立刻 unlock,不持锁干活 |
| 锁竞争导致 CPU 飙高 | 不必要的 notify_all(惊群) |
日常用 notify_one |
| 关闭时卡住 | 析构用了 notify_one 而不是 notify_all |
关闭用 notify_all |
detach 后程序随机崩溃 |
detach 的线程访问了已销毁的栈变量 | detach 前确保线程不依赖会被销毁的数据 |
八、核心概念速查表
| 概念 | 一句话 | 类比 |
|---|---|---|
| 线程 | 操作系统调度的最小单位,共享进程内存 | 饭馆里的一个员工 |
| 进程 | 操作系统资源分配的最小单位,独立内存空间 | 一家独立的饭馆 |
| 竞态条件 | 多线程同时访问共享数据,结果取决于执行时序 | 两个人同时改同一行 Excel |
| 互斥锁(mutex) | 保证同一时刻只有一个线程访问临界区 | 独木桥,每次只过一个人 |
| 临界区 | 被锁保护的代码段 | 独木桥本身 |
| 死锁 | 多个线程互相等待对方释放锁 | 两人在独木桥两端互不相让 |
| lock_guard | RAII 风格的锁,构造 lock 析构 unlock | 自动门:进门自动锁,出门自动开 |
| RAII | 资源获取即初始化,资源释放绑定对象生命周期 | 你把钥匙交给保安,你离开时保安负责锁门 |
| 条件变量 | 让线程在条件不满足时睡觉,满足时被叫醒 | 厨师的摇铃 |
| 虚假唤醒 | 操作系统在没人 notify 时也可能唤醒线程 | 你做梦被惊醒,看看厨房没活,继续睡 |
| 惊群效应 | notify_all 后所有线程醒来,只有一个抢到活 | 一群人被叫醒冲向唯一的任务 |
| 线程池 | 预创建一组线程,任务来了分配,复用线程 | 提前雇好的厨师团队,来单就做 |
九、自测题
复习时用自己的话回答,答不出来就回头看对应章节:
counter++为什么不是线程安全的?(提示:从 CPU 指令角度回答)join()和detach()的区别?什么时候用哪个?- 为什么
lock_guard比手动lock/unlock安全? - 条件变量的
wait为什么必须传判断条件? - 互斥锁和条件变量各解决什么问题?为什么不能只用其中一个?
- 线程池关闭时为什么用
notify_all而不是notify_one? - 模拟一遍:3 个任务进来,2 个工作线程,谁先执行?执行过程是怎样的?
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)