从「为什么单线程不够用」讲起,把 std::thread、数据竞争、互斥锁、死锁、条件变量、原子操作、future 和线程池串成一条线。没写过多线程的同学可以顺着读;写过但心里总没底的,可以直接跳到第五节的死锁和第九节的线程池。
一、为什么要多线程,它到底难在哪
先把「异步」和「多线程」分清楚,这是最容易混的一对概念。
还是用厨房打比方。异步解决的是「一个厨师别站在锅前傻等」——汤炖着的时候他转头去切菜,效率提升来自不浪费等待的时间。但如果活本身就很重,比如要切一百斤萝卜,那再怎么聪明地调度,一个厨师也快不了。
多线程解决的是另一件事:真的多雇几个厨师,让他们同时切萝卜。现代 CPU 有多个核心,只有多线程才能真正把它们跑满。
所以判断标准很清晰:
- I/O 密集(等网络、等磁盘)→ 异步就够了,线程再多也只是一起等。
- CPU 密集(计算、编解码、图像处理)→ 需要多线程,把活分给多个核心。
但多雇厨师是有代价的:他们要共用同一个厨房——同一块砧板、同一个灶台、同一罐盐。两个厨师同时伸手去拿同一把刀,或者一个人正切菜时另一个把砧板端走了,就会出乱子。而且这种乱子往往不是每次都出现,这才是多线程真正折磨人的地方。
记住这一句,后面所有的锁、原子、条件变量都是为它服务的:多线程的全部困难,都来自「多个线程同时读写同一份可变数据」。没有共享数据,多线程就是免费的午餐。
二、std::thread:把活交出去
C++11 之后,起一个线程只需要把「要干的活」交给 std::thread:
#include <thread>
#include <iostream>
void 切萝卜(int 斤数) {
std::cout << "切了 " << 斤数 << " 斤\n";
}
int main() {
std::thread 厨师A(切萝卜, 50); // 构造完成,线程立刻就开始跑了
std::thread 厨师B(切萝卜, 50);
厨师A.join(); // 站在这里等厨师A干完
厨师B.join();
return 0;
}
注意 std::thread 一构造出来线程就已经在跑了,没有单独的 start()。join() 的意思是「我在这里等你干完再往下走」。
join 还是 detach,必须选一个
每个 std::thread 对象在析构之前,必须被 join()(等它结束)或者 detach()(放它自己跑,不管了)。两个都没做就析构,程序会直接 std::terminate 崩掉。
这个设计看起来很严厉,其实是好意:如果允许悄悄析构,那线程可能还在访问已经销毁的局部变量,变成极难查的野指针。C++ 宁愿让你当场崩,也不让你带着隐患跑下去。
detach() 要非常小心——分离出去的线程如果还在用主线程栈上的变量,而主线程已经返回了,就是未定义行为。能 join 就别 detach。
传参默认是拷贝
void 加盐(int& 盐量) { 盐量 += 1; }
int 盐 = 0;
std::thread t1(加盐, 盐); // ❌ 编译不过:传进去的是拷贝
std::thread t2(加盐, std::ref(盐)); // ✅ 明确说「我要传引用」
std::thread 会把参数拷贝一份到线程自己的存储里,这是为了避免「主线程的变量先没了,子线程还在用」。想传引用必须用 std::ref 显式表态——这一步的啰嗦是故意的,逼你想清楚那个变量的生命周期够不够长。
本节铁律:线程对象离开作用域前,join 或 detach 二选一;传引用必须 std::ref,并且你要为那个引用的存活负责。
三、数据竞争:多线程真正的敌人
先看一段看起来完全没问题的代码:
int 计数 = 0;
void 数一万次() {
for (int i = 0; i < 100000; ++i) {
++计数; // 就这一行,问题就出在这里
}
}
int main() {
std::thread a(数一万次), b(数一万次);
a.join(); b.join();
std::cout << 计数 << "\n"; // 期望 200000,实际可能是 137204
}
结果几乎永远不是 200000,而且每次跑还不一样。原因是 ++计数 在 CPU 眼里不是一个动作,而是三个:
- 读:把
计数的值从内存搬到寄存器; - 改:寄存器里加 1;
- 写:把结果写回内存。
两个线程可以在这三步中间的任何位置被打断。如果 A 和 B 都读到了 100,各自加成 101,再各自写回,那么两次自增只涨了 1——有一次更新被凭空吃掉了。
回到厨房:这就是两个厨师同时看了一眼订单本上的「已出餐 100 份」,各自划掉改成 101。账目就此错乱。
它比「结果不准」严重得多
很多人以为数据竞争的后果是「数字算错了」,可以接受。但在 C++ 标准里,数据竞争是未定义行为(UB)。这意味着编译器在优化时是假设你的程序没有数据竞争的,一旦有,它可能把循环整个优化掉、可能读到半个指针值、可能表现出任何荒诞的行为,而不只是数字偏小。
数据竞争最恶毒的地方是概率性:本地跑一百次全对,压力一上来就出错,而且错误现场往往离真正的 bug 很远。所以对付它只能靠设计上保证正确,不能靠测试碰运气。
顺带一句:-fsanitize=thread(ThreadSanitizer)能在运行时把数据竞争抓出来,是排查这类问题最有效的工具,值得默认在调试构建里开着。
四、互斥锁与 RAII:lock_guard 和 unique_lock
解决办法很直接:给砧板加一个使用权令牌。谁拿到令牌谁才能上砧板,别人只能等。这个令牌就是 std::mutex(互斥量)。
#include <mutex>
int 计数 = 0;
std::mutex 砧板锁;
void 数一万次() {
for (int i = 0; i < 100000; ++i) {
砧板锁.lock();
++计数; // 这段区域同一时刻只有一个线程能进来
砧板锁.unlock();
}
}
被锁保护起来的这段代码叫临界区。加了锁之后结果稳定是 200000。
但手动 lock / unlock 是个陷阱
上面的写法有个致命隐患:如果临界区里抛了异常,或者中途 return 了,unlock() 就永远执行不到。锁没被释放,其他线程全部卡死——整个程序就这么挂了。
C++ 的答案是 RAII:把锁的生命周期交给一个栈上对象,构造时上锁、析构时自动解锁。不管你是正常走完、提前 return、还是抛异常,栈上对象的析构函数一定会被调用。
void 数一万次() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> 令牌(砧板锁); // 构造即上锁
++计数;
} // 出作用域自动解锁
}
不要再手写 lock/unlock 了,这不是风格问题,是正确性问题。
lock_guard 和 unique_lock 怎么选
std::lock_guard—— 最简单,进作用域锁、出作用域解,中间不能干别的。默认用它,开销最小。std::unique_lock—— 更灵活:可以中途unlock()、可以延迟上锁、可以转移所有权,而且条件变量只能配它用(下一节会看到)。代价是多一点点开销。- C++17 的
std::scoped_lock—— 能一次锁住多把锁而不死锁,锁多个对象时用它(第五节细说)。
临界区要尽量小
// ❌ 把耗时的活也锁在里面,等于退化成单线程
{
std::lock_guard<std::mutex> 令牌(锁);
auto 数据 = 从网络读取(); // 慢!别人全在等
结果表[键] = 处理(数据);
}
// ✅ 只锁真正需要保护的那一下
auto 数据 = 从网络读取(); // 慢活放在锁外面
auto 值 = 处理(数据);
{
std::lock_guard<std::mutex> 令牌(锁);
结果表[键] = 值; // 只有这一行需要互斥
}
本节铁律:锁一定用 RAII 包起来,临界区里只放「必须互斥的那几行」,任何慢操作都挪到锁外面。锁的粒度太粗,多线程就白写了。
五、死锁:怎么来的,怎么躲
加锁能解决数据竞争,但会带来一个新问题。回到厨房:切菜要同时用刀和砧板。厨师 A 先抢到了刀,正准备去拿砧板;厨师 B 先抢到了砧板,正准备去拿刀。于是两个人各拿着一半,都在等对方先放手——谁都不动了。这就是死锁。
std::mutex 账户A锁, 账户B锁;
void 从A转到B() {
std::lock_guard<std::mutex> l1(账户A锁); // 先锁 A
std::lock_guard<std::mutex> l2(账户B锁); // 再锁 B
// ...
}
void 从B转到A() {
std::lock_guard<std::mutex> l1(账户B锁); // 先锁 B ← 顺序反了!
std::lock_guard<std::mutex> l2(账户A锁); // 再锁 A
// ...
}
两个函数在不同线程里同时跑,就有概率卡死:一个拿着 A 等 B,另一个拿着 B 等 A。注意它同样是概率性的,大部分时候相安无事,偏偏在生产环境高并发时挂住。
三条能落地的规则
教科书会列死锁的四个必要条件,但实践中你只需要记住三条做法:
1. 全局固定加锁顺序。约定好「永远先锁账户号小的那个」,死锁的环就永远形成不了。这是最根本的解法。
2. 要同时锁多把,就用 std::scoped_lock。它内部用死锁避免算法一次性获取全部锁,不会出现「拿一半等一半」的中间状态:
void 转账() {
std::scoped_lock 令牌(账户A锁, 账户B锁); // C++17,一次锁两把,顺序无关
// ...
} // 一起解锁
C++17 之前对应的是 std::lock(m1, m2) 配 std::adopt_lock,能用 C++17 就直接用 scoped_lock。
3. 持锁时不要调用你不了解的代码。包括回调、虚函数、别人的库。因为那段代码可能反过来又去锁同一把锁(自锁),或者以相反顺序锁别的锁。锁里只放你自己看得见的几行——这也是上一节「临界区要小」的另一个理由。
本节铁律:只要有两把以上的锁,就必须有一个明确的加锁顺序;能用 scoped_lock 就别手动嵌套 lock_guard。
六、条件变量:让线程学会「等」
锁解决的是「不许同时动」,但还有一类需求它答不了:厨师要等订单来了才开工。没订单的时候他该干什么?
最笨的办法是不停地看订单本(忙等 / 轮询):
// ❌ 忙等:这个线程会把一个 CPU 核心烧满,什么也没干
while (订单队列.empty()) { }
正确的办法是睡下去,等人叫我。这就是 std::condition_variable——厨房里的那个呼叫铃。
#include <condition_variable>
#include <queue>
std::queue<int> 订单队列;
std::mutex 锁;
std::condition_variable 呼叫铃;
// 服务员:放一个订单进去,然后按铃
void 下单(int 订单) {
{
std::lock_guard<std::mutex> 令牌(锁);
订单队列.push(订单);
} // 先解锁,再按铃,减少无谓争抢
呼叫铃.notify_one();
}
// 厨师:没订单就睡,有订单就做
void 厨师干活() {
while (true) {
std::unique_lock<std::mutex> 令牌(锁);
呼叫铃.wait(令牌, []{ return !订单队列.empty(); }); // 关键这一行
int 订单 = 订单队列.front();
订单队列.pop();
令牌.unlock(); // 做菜很慢,先把锁放开
做菜(订单);
}
}
wait 那一行到底做了什么
wait(令牌, 谓词) 是一个三合一的原子操作,这也是为什么它必须配 unique_lock(需要中途解锁再重新上锁,lock_guard 做不到):
- 检查谓词,为真就直接往下走;
- 为假则解开锁并让线程睡过去(不占 CPU);
- 被唤醒后重新上锁,再检查一遍谓词——不满足就继续睡。
「解锁」和「睡下」必须是原子的,否则会有一个致命缝隙:刚解锁还没睡着时对方按了铃,这一声就永远丢了,线程从此睡死。条件变量把这两步合成一步,就是为了堵住这个缝。
必须带谓词,不能裸 wait
呼叫铃.wait(令牌); // ❌ 危险
呼叫铃.wait(令牌, []{ return !订单队列.empty(); }); // ✅ 正确
原因是虚假唤醒:条件变量允许在没人 notify 的情况下自己醒过来(这是操作系统实现留下的余地,标准明确允许)。裸 wait 醒了就往下走,会对着空队列执行 front(),直接未定义行为。带谓词的版本醒来会自己再查一遍,不满足就继续睡。
notify_one 叫醒一个等待者,notify_all 叫醒全部。放一个订单就 notify_one;如果是「打烊了,所有人收工」这种状态变化,用 notify_all。
本节铁律:wait 永远带谓词,配 unique_lock;notify 之前先把锁放开;不要用忙等循环代替条件变量。
七、原子操作与内存序
回到第三节那个计数器。为了保护一个 int 自增而搬出一把 mutex,有点像为了锁一个抽屉而给整个厨房上锁——能用,但太重了。加锁解锁涉及可能的系统调用和线程切换,在高频场景下开销很显眼。
对于「单个变量的简单操作」,有更轻的武器:std::atomic。
#include <atomic>
std::atomic<int> 计数{0};
void 数一万次() {
for (int i = 0; i < 100000; ++i) {
++计数; // 这一次,读-改-写 是一个不可分割的整体
}
}
结果稳定是 200000,而且比加锁快得多。原子类型靠 CPU 提供的原子指令(比如 lock xadd)保证「读-改-写」三步不会被打断,根本不需要锁。
atomic 和 mutex 怎么选
判断标准只有一条:需要保持一致的是一个变量,还是多个变量?
- 一个变量的独立操作(计数、标志位、指针替换)→ 用
atomic。 - 多个变量必须一起变、中间状态不能被看见 → 用
mutex。
后者是最常见的误区。把每个成员都换成 atomic 并不能让一组操作变原子:
std::atomic<int> 余额A{100}, 余额B{0};
// ❌ 每一行都是原子的,但整个转账不是
余额A -= 50;
// ← 别的线程正好在这里看了一眼:钱凭空消失了 50
余额B += 50;
转账要么全成要么全不成,这个「要么」跨越了两个变量,只能靠锁保护。原子性是针对一段逻辑说的,不是针对一个变量说的。
内存序:知道它存在,但先别碰
你会看到 memory_order_relaxed、acquire、release 这些参数。它们控制的是编译器和 CPU 允许把指令重排到什么程度——为了性能,你写的顺序并不是真正执行的顺序,而多线程下别的线程能观察到这种重排。
std::atomic 默认用最严格的 memory_order_seq_cst(顺序一致),行为符合直觉,代价是最保守的优化。
诚恳的建议:默认值就用着,直到你有 profiler 数据证明这里是瓶颈。放宽内存序引入的 bug 极难复现、极难调试,而收益通常只在极高频的无锁数据结构里才看得见。唯一低风险的例外是纯统计用的计数器(比如日志条数),用
relaxed是安全的,因为你不关心它和别的操作的相对顺序。
八、future / async:我只要结果,不想管线程
前面所有的工具都在管「线程怎么协作」。但很多时候我们的真实需求朴素得多:把这个耗时的活扔出去算,算完了把结果给我。中间要不要开线程、怎么同步,我不想操心。
这就是 std::async 和 std::future 的位置。future 就是第二节那张取餐小票——代表一个「以后才会有的值」。
#include <future>
int 算个大数() { /* 很慢 */ return 42; }
int main() {
std::future<int> 小票 = std::async(std::launch::async, 算个大数);
干点别的(); // 这期间大数在另一个线程里算着
int 结果 = 小票.get(); // 要结果了。没算完就在这里等
}
没有 mutex,没有 condition_variable,也没有 join——线程的创建、结果的传递、异常的转发全被封装好了。而且如果那个函数抛了异常,异常会被存进 future,在你调用 get() 时重新抛出,不会像裸线程那样直接让程序 terminate。这一点非常省心。
两个必须知道的坑
1. 一定要显式写 std::launch::async。如果不传启动策略,标准允许实现选择 deferred——那样函数根本不会新起线程,而是等你调用 get() 时在当前线程里同步执行。你以为在并行,其实完全是串行的。
auto f1 = std::async(算个大数); // ❌ 可能退化成同步执行
auto f2 = std::async(std::launch::async, 算个大数); // ✅ 保证新起线程
2. async 返回的 future 析构时会阻塞。它的析构函数会等任务跑完才返回,这是为了避免任务还在访问已销毁的对象。所以下面这行不是「并发发起三个任务」:
// ❌ 临时 future 当场析构 → 当场阻塞 → 完全串行
std::async(std::launch::async, 活1);
std::async(std::launch::async, 活2);
// ✅ 先把 future 都存下来,再统一 get
auto f1 = std::async(std::launch::async, 活1);
auto f2 = std::async(std::launch::async, 活2);
f1.get(); f2.get();
如果需要更手动的控制,std::packaged_task(把可调用对象包成能返回 future 的任务)和 std::promise(手动往 future 里塞值)是配套的两件工具。下一节的线程池就会用到 packaged_task。
本节铁律:只要结果、不要线程时优先用 async;启动策略永远显式写 launch::async;future 一定接住不要当临时对象丢掉。
九、手写一个线程池
为什么需要线程池?因为创建线程本身是有成本的——要向操作系统申请栈空间、注册调度实体,通常是几十微秒的量级。如果你有十万个小任务,每个任务只算几微秒,那「为每个任务开一个线程」的开销会远远超过任务本身。
厨房的类比很自然:你不会为每一张订单去外面招一个新厨师。你固定雇 8 个人,让他们守着订单队列,谁空了就拿下一单。这就是线程池。
而且线程池刚好把前面八节的东西全串起来了:任务队列(共享可变状态)+ mutex(保护队列)+ 条件变量(没活时睡着)+ packaged_task / future(把结果还给调用方)。
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
#include <stdexcept>
class 线程池 {
public:
explicit 线程池(size_t 人数) {
for (size_t i = 0; i < 人数; ++i) {
工人.emplace_back([this] {
while (true) {
std::function<void()> 任务;
{
std::unique_lock<std::mutex> 令牌(锁);
呼叫铃.wait(令牌, [this] {
return 打烊 || !任务队列.empty();
});
// 打烊了并且活也干完了,才真正下班
if (打烊 && 任务队列.empty()) return;
任务 = std::move(任务队列.front());
任务队列.pop();
} // 出作用域解锁,再干活
任务(); // 干活不持锁,这是关键
}
});
}
}
~线程池() {
{
std::lock_guard<std::mutex> 令牌(锁);
打烊 = true;
}
呼叫铃.notify_all(); // 把所有睡着的人都叫醒
for (auto& 人 : 工人) 人.join(); // 等他们各自收尾
}
// 禁止拷贝:池子持有线程,拷贝没有意义
线程池(const 线程池&) = delete;
线程池& operator=(const 线程池&) = delete;
template <class F>
auto 提交(F 活) -> std::future<decltype(活())> {
using 返回类型 = decltype(活());
// 用 shared_ptr 是因为 std::function 要求可拷贝,
// 而 packaged_task 只能移动,包一层就绕过去了
auto 任务 = std::make_shared<std::packaged_task<返回类型()>>(std::move(活));
std::future<返回类型> 小票 = 任务->get_future();
{
std::lock_guard<std::mutex> 令牌(锁);
if (打烊) throw std::runtime_error("线程池已关闭");
任务队列.emplace([任务] { (*任务)(); });
}
呼叫铃.notify_one();
return 小票;
}
private:
std::vector<std::thread> 工人;
std::queue<std::function<void()>> 任务队列;
std::mutex 锁;
std::condition_variable 呼叫铃;
bool 打烊 = false;
};
几个设计要点
取任务持锁,干任务不持锁。注意工人循环里那对花括号——它把临界区精确地限制在「从队列里取出一个任务」这几行,取完就解锁,然后才执行 任务()。如果把 任务() 也写在锁里面,8 个工人会排队一个一个干,线程池就退化成单线程了。这是第四节「临界区要小」最典型的应用。
退出条件是「打烊 且 队列空」。如果写成「打烊就 return」,那么析构时队列里剩下的任务会被直接丢掉,调用方手里的 future 永远等不到结果(会抛 broken_promise)。当前写法保证已经提交的任务一定会被做完。
析构里先改标志再 notify_all。改 打烊 必须持锁(它是共享可变状态),但 notify_all 要在解锁之后调用,否则被叫醒的线程立刻又会卡在锁上。这也是第六节说过的规矩。
为什么 packaged_task 要套一层 shared_ptr。std::function 要求它包裹的对象可拷贝,而 packaged_task 是只能移动的。用 shared_ptr 包一层,lambda 捕获的就是可拷贝的智能指针了。这是个很常见的工程技巧。
用起来
int main() {
线程池 池(std::thread::hardware_concurrency()); // 通常取核心数
std::vector<std::future<int>> 小票们;
for (int i = 0; i < 100; ++i) {
小票们.push_back(池.提交([i] { return i * i; }));
}
long long 总和 = 0;
for (auto& 票 : 小票们) 总和 += 票.get(); // 按需收结果
std::cout << 总和 << "\n";
}
池子大小一般取 std::thread::hardware_concurrency():CPU 密集任务开到核心数就够了,再多只会增加切换开销;I/O 密集任务可以适当超配,因为线程大部分时间在等。
一个容易踩的死锁
不要在池里的任务中,去 get() 另一个提交给同一个池的任务。如果所有工人都卡在等待各自的子任务,而子任务还排在队列里没人取——池子就整体死锁了。这本质上还是第五节那条规则:持有资源(这里是工人线程)时不要去等另一个需要同样资源的东西。
本节铁律:取任务持锁、执行任务不持锁;退出条件必须是「停止且队列空」;任务内部不要等待同一个池里的其他任务。
十、小结
把这几件工具放在一张表里,选择的时候按「我要解决的是哪个问题」去找:
| 工具 | 解决什么问题 | 最容易踩的坑 |
|---|---|---|
std::thread | 把一段活交给另一个核去跑 | 忘了 join / detach 就析构,直接 terminate |
std::mutex + lock_guard | 多个变量必须一起变,中间状态不能被看见 | 手写 lock/unlock 漏解锁;临界区里放慢操作 |
std::scoped_lock | 一次安全地锁住多把锁 | 不用它而手动嵌套 → 加锁顺序不一致 → 死锁 |
condition_variable | 没活干的时候睡着,有活了被叫醒 | 裸 wait 不带谓词 → 虚假唤醒;notify 时还持着锁 |
std::atomic | 单个变量的计数、标志、指针替换 | 误以为能让「一组操作」原子化;过早去调内存序 |
std::async / future | 我只要结果,不想管线程 | 不写 launch::async 可能变同步;future 当临时对象丢掉会当场阻塞 |
| 线程池 | 任务很多、每个任务很短 | 执行任务时还持着锁;任务里等同一个池的其他任务 |
如果只能带走一句话,我希望是这句:多线程的难点从来不在 API,而在于「共享的可变状态」。这十节里的每一样工具,本质上都在回答同一个问题——这份数据,同一时刻允许谁碰。mutex 的回答是「一次只许一个」,atomic 的回答是「这一下不许被打断」,条件变量的回答是「没轮到你就先睡」,future 的回答是「你别碰,我算完给你」。
所以真正高明的多线程代码,往往不是把锁用得多么精妙,而是从设计上让线程之间几乎不共享东西:把数据切成互不重叠的块各自处理、用消息传递代替共享内存、能用 future 拿结果就不用全局变量。锁是不得不共享时的兜底手段,不是多线程的主角。
写完这段代码之后,建议做三件事:调试构建默认打开 -fsanitize=thread;把每一处共享变量在注释里写清楚「由哪把锁保护」;以及在提交前问自己一句——这里真的需要共享吗?

没有回应