C++ 多线程从入门到能用:线程、锁、条件变量与线程池

从「为什么单线程不够用」讲起,把 std::thread、数据竞争、互斥锁、死锁、条件变量、原子操作、future 和线程池串成一条线。没写过多线程的同学可以顺着读;写过但心里总没底的,可以直接跳到第五节的死锁和第九节的线程池。

动漫少女侧卧在白色床铺上的插画,用作 C++ 多线程文章封面

从「为什么单线程不够用」讲起,把 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_relaxedacquirerelease 这些参数。它们控制的是编译器和 CPU 允许把指令重排到什么程度——为了性能,你写的顺序并不是真正执行的顺序,而多线程下别的线程能观察到这种重排。

std::atomic 默认用最严格的 memory_order_seq_cst(顺序一致),行为符合直觉,代价是最保守的优化。

诚恳的建议:默认值就用着,直到你有 profiler 数据证明这里是瓶颈。放宽内存序引入的 bug 极难复现、极难调试,而收益通常只在极高频的无锁数据结构里才看得见。唯一低风险的例外是纯统计用的计数器(比如日志条数),用 relaxed 是安全的,因为你不关心它和别的操作的相对顺序。

八、future / async:我只要结果,不想管线程

前面所有的工具都在管「线程怎么协作」。但很多时候我们的真实需求朴素得多:把这个耗时的活扔出去算,算完了把结果给我。中间要不要开线程、怎么同步,我不想操心。

这就是 std::asyncstd::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_ptrstd::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;把每一处共享变量在注释里写清楚「由哪把锁保护」;以及在提交前问自己一句——这里真的需要共享吗?

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注