线程间共享数据

读完能区分竞态条件与数据竞争,用 std::mutex + std::lock_guard 保护共享数据,识别接口竞态,并用固定加锁层级或 std::scoped_lock 避免死锁。

学习目标

  • 能区分竞态条件与会令 C++ 程序行为未定义的数据竞争,并指出 counter++ 同时触发了哪两类问题
  • 能修改一段共享状态代码:用 std::mutex + std::lock_guard 圈定完整临界区,并把 empty() / top() / pop() 的接口竞态合并成单一操作
  • 能回答:两个线程各对一个共享变量 ++ 一次(初值 0),最后这个变量一定等于 2 吗?如果不一定,是哪一步出了问题、怎么用锁修好?

从一张共享订单开始

前两章你学会了喊帮厨、管帮厨。可一旦两个厨师在同一个后厨干活,新麻烦就来了:他们要共用东西——同一块砧板、同一张订单。两人各自埋头干没事,一旦同时去改同一张订单,就乱套了。

想象订单上写着「已做 0 道菜」。厨师甲看了一眼是 0,准备改成 1;与此同时厨师乙也看了一眼,还是 0,也准备改成 1。两人各自把「1」写上去——明明做了两道菜,订单上却只记了 1。少记的那道,账面上凭空蒸发了。问题不在某一个厨师,而在两人同时动同一张订单、谁先谁后没人管

这一章要解决的就是:当多个线程共用同一份数据时,怎么让他们有序地碰它,而不是一拥而上把数据改乱。最常用的办法,是给后厨配一把「唯一的厨刀」——谁拿到刀谁才能切,别人只能等。

竞态条件与数据竞争:先分清逻辑错误和语言规则

是一个逻辑层概念:只要正确性依赖“谁先谁后”,时序改变就可能暴露错误。它不等于“同时写一个变量”;后面 empty()pop() 的组合即使内部都加锁,仍然可能有竞态条件。

是更严格的 C++ 语言规则:两个线程冲突访问同一内存位置,至少一个写,并且没有原子操作或同步建立顺序,程序行为就是未定义。因此 counter++ 无锁并发既有时序上的竞态条件,也有数据竞争;不能把真实 C++ 程序的可能结果限定为 1 或 2。

一段共享数据通常带着某种约定——书里叫 。比如计数器 counter 的不变量是「它的值等于已经发生的 ++ 次数」。counter++ 看起来是一个动作,在机器层面其实是三步:出当前值 → 加一回。这三步之间,不变量是暂时破着的。

如果一个线程刚读完、还没写回,另一个线程也读了旧值,两次写回在一个简化机器模型里会形成“丢更新”。下面的交互演示用于理解这种交错,不是 C++ 未定义行为的完整结果枚举:

猜一猜:两个线程各 ++counter 一次(初值 0),最后 counter 一定是 2 吗?先选一种交错走一遍,再下结论。

玩过你就明白了:需要把一次完整的“读、改、写”作为不可被另一条同类操作穿插的单元。互斥量可以建立这个顺序;若只是独立计数,也可以使用满足所需内存顺序的原子类型,后者会在第 5 章展开。

互斥量:唯一的那把厨刀

让操作「不重叠」的标准工具,是 。把它想成后厨那把唯一的厨刀:要切砧板(碰共享数据),先得 lock 拿到刀;切完 unlock 放下刀。同一时刻只有一个人能攥着这把刀,别的线程想拿就只能

被这把刀保护起来的那段代码——也就是「拿到刀才能进、任一刻只容一个线程」的代码段——叫 。下面这张动画把两个线程争这把刀、临界区被强行串行化的过程演给你看:

可交互
mutex:谁拿到刀,谁才能切砧板临界区被串行化——任一时刻只有一个线程在改共享数据线程 A线程 Block临界区:切砧板unlock⏳ 等待(刀被 A 占着)lock临界区:切砧板交接:刀从 A 到 B时间 →

第 1 / 6 步 · 线程 A 先 lock:拿到唯一的那把厨刀(mutex)

只有一把刀(mutex):A 持刀切砧板时 B 只能等;A 放下刀 B 才拿。两段临界区永不重叠。可暂停、单步、拖进度。

互斥量(mutex)像后厨那把唯一的厨刀:谁 lock 拿到它,谁才能进入临界区操作 共享砧板,别的线程只能等它 unlock。临界区因此被串行化,任一刻只有一个线程在改数据。

看清楚了:A 持刀切砧板时,B 只能空等;A 放下刀,B 才拿到。两段临界区在时间轴上永不重叠——这正是竞态消失的原因。手动 lock / unlock 容易忘记解锁(尤其中途抛异常时),所以 C++ 给了一个 RAII 包装 ——它构造时上锁、析构时自动解锁,具体写法第六节细说。

互斥量不会“绑定”某个变量,正确性来自一条共同协议:所有可能触碰同一不变量的路径都使用同一保护机制,并且锁的作用域覆盖完整操作。漏掉一次读、提前返回内部引用,或把检查与执行拆到两个临界区,都会让协议失效。

接口竞态:每步都加锁,仍可能翻车

这里有个反直觉的坑:给每个操作都单独加了锁,组合起来用却仍可能有竞态。 经典例子是 std::stackempty()top()pop()。哪怕这三个成员函数各自内部都正确加了锁,下面这种「先查空、再取顶、再弹出」的组合用法仍然是错的:在你 empty() 返回 false 之后、top() 之前,别的线程可能把栈弹空了。问题不在单个操作,而在接口被设计成「查」和「用」分两步——这一节先建立认识,第六节给出根治的改法(把 top + pop 合并成一个返回值的原子操作)。

死锁:两厨师各攥一件对方要的工具

加锁能救竞态,但用多把锁时会引入新麻烦——。设想两把锁 lock1lock2:线程 A 先锁了 lock1、又伸手要 lock2;线程 B 先锁了 lock2、又伸手要 lock1。于是 A 等 B 手里的 lock2、B 等 A 手里的 lock1——环形等待,谁都动不了。像两个厨师各攥着一件对方要的工具,互不相让,后厨就此僵死。下面这张动画把环一步步画闭合:

可交互
死锁:两厨师各攥一件对方要的工具持有持有等待等待线程 A厨师 A线程 B厨师 Block1工具一(搅拌机)lock2工具二(量杯)💀 死锁环形等待·谁都不动实线=已持有的锁,虚线=正伸手要却要不到的锁

第 1 / 4 步 · ① 线程 A 锁住 lock1:A 持有 lock1(攥着第一件工具)

A 持 lock1 等 lock2、B 持 lock2 等 lock1——四条边接成环,谁都等对方先放手:死锁。可暂停、单步、拖进度。

死锁是「环形等待」:每个线程都攥着一把对方需要的锁、又在等对方手里的锁。 像两厨师各攥一件对方要的工具互不相让,后厨就此僵死。避免它的经典办法是 让所有线程按同一固定顺序加锁。

避免死锁最直接的办法是让所有线程遵守同一锁层级;也可以用 获取一组锁。算法执行期间可能暂时持有其中一部分并回退重试,准确承诺是“不会因为传入锁的获取顺序形成死锁”,不是每个中间瞬间都“全有或全无”。

还要遵守三条组合规则:能不嵌套就不嵌套;持锁时不要调用用户回调或其他未知代码;必须嵌套时,把锁层级写成可审查的不变量。固定顺序应使用稳定业务层级或 std::less 定义的指针全序,不能依赖无关对象裸指针的内建 < 比较。

灵活加锁:更趁手的几种锁

lock_guard 足够应付大多数场景,但它太「死板」——一旦构造就锁到作用域结束、中途不能解开、也不能转移。需要更灵活时,标准库还给了几样: 支持延迟上锁、提前解锁、移动转移; 用于线程安全的惰性初始化(某段初始化只跑一次); 则区分「读」和「写」——允许多个读线程同时进、但写线程独占,适合读多写少。这些都留到第六节用代码逐个上手。

上手玩一玩这三张图

这三张图都是可操作的教具,配合本章一起玩,比读十遍文字管用:

  • 竞态交错器 ``:选不同的「交错顺序」预设,单步走每个微步。重点盯「丢更新」那条——两个线程都先读到旧值 0,最后结果只有 1。换几条交错对比,体会「同样两次 ++,结果由顺序决定」。
  • mutex 串行化 <MutexSerializeDiagram />:单步看 A 持刀、B 等待、A 放刀、B 才拿——两段临界区在时间轴上错开不重叠,这就是锁消除竞态的原理。
  • 死锁环 <DeadlockCycleDiagram />:单步看四条边怎么接成一个环。盯住第 ③、④ 步——A 等 B 手里的锁、B 等 A 手里的锁,环一闭合就僵死。

玩熟这三张图,下一节的代码每一行 lock / unlock / std::lock 你都能对上图里某个节点。

代码:把共享数据一步步护好

翻车现场:无保护的竞态计数器

先看会出问题的代码。两个线程各对全局 counter 加 100 万次,期望最后是 200 万——但跑出来常常少:

#include <thread>
 
int counter = 0; // 共享数据,无任何保护
 
void add_many() {
    for (int i = 0; i < 1'000'000; ++i)
        ++counter; // 读—改—写,会被另一个线程打断
}
 
int main() {
    std::thread t1(add_many);
    std::thread t2(add_many);
    t1.join();
    t2.join();
    // 数据竞争:C++ 行为未定义,不能只假设“结果偏小”
}

交错器展示了常见的丢更新路径,但这段程序在 C++ 中有数据竞争,编译器无需维持该简化模型。修复目标不是“让结果大概率正确”,而是建立同步关系,彻底移除数据竞争。

加锁:std::mutex + std::lock_guard

counter 配一把 std::mutex,每次访问前用 std::lock_guard 圈出临界区:

#include <mutex>
#include <thread>
 
int counter = 0;
std::mutex m; // 保护 counter 的「那把刀」
 
void add_many() {
    for (int i = 0; i < 1'000'000; ++i) {
        std::lock_guard<std::mutex> guard(m); // 上锁,进入临界区
        ++counter;
    } // guard 析构 → 自动 unlock,离开临界区
}

std::lock_guard<std::mutex> guard(m); 在构造时锁住 m、在它离开作用域(这里是每轮循环结束)时自动解锁。这样两个线程的 ++counter 被串行化,最终一定是 200 万。

接口竞态:empty / top / pop 的组合陷阱

光给每个操作加锁还不够。看这段「线程安全栈」的危险用法——即便 empty()top()pop() 内部各自加了锁:

// 假设 s 是一个「每个成员都加了锁」的线程安全栈
if (!s.empty()) {       // ① 查:此刻非空
    int v = s.top();    // ② 取顶 —— 但 ① 之后可能已被别的线程弹空!
    s.pop();            // ③ 弹出
    use(v);
}

empty() 返回 false 后、top() 之前,另一个线程可能把栈弹空了——于是 top() 操作一个空栈,未定义行为。问题在接口把「查」和「用」拆成两步(check-then-act),两步之间有缝。

根治办法是改接口——把「取顶 + 弹出」合并成一个加了锁的原子操作,让外部无缝可乘:

#include <mutex>
#include <stack>
#include <optional>
 
template <typename T>
class SafeStack {
    std::stack<T> data;
    mutable std::mutex m;
public:
    void push(T v) {
        std::lock_guard<std::mutex> g(m);
        data.push(std::move(v));
    }
    // 合并 top+pop:一把锁内完成,空栈返回 nullopt
    std::optional<T> pop() {
        std::lock_guard<std::mutex> g(m);
        if (data.empty()) return std::nullopt;
        T v = std::move(data.top());
        data.pop();
        return v;
    }
};

现在调用方只用一句 auto v = s.pop();,「查空 + 取顶 + 弹出」在同一把锁里一气呵成,中间没有缝可钻。

这个简化版本还要求 T 的移动构造满足你需要的异常保证:若移动会抛出并修改源对象,栈顶可能保留一个被部分移动的值。生产接口可以约束 T 为 nothrow-move、在可用时先复制,或像原书示例那样返回 std::shared_ptr<T>,先安全构造返回对象再修改容器。

死锁与修法:固定顺序 / std::lock / std::scoped_lock

多把锁是死锁的温床。下面这段「交换两个账户余额」的代码,如果线程甲调 swap(a, b)、线程乙同时调 swap(b, a),就可能各持一把、互等另一把——死锁(正是上面死锁环演示的样子):

struct Account { std::mutex m; int balance; };
 
void swap_bad(Account& x, Account& y) {
    std::lock_guard<std::mutex> g1(x.m); // 甲先锁 x
    std::lock_guard<std::mutex> g2(y.m); // 乙先锁 y → 环形等待,死锁
    std::swap(x.balance, y.balance);
}

最干净的修法是用 std::scoped_lock(C++17)一次锁住两把,它内部用「全有或全无」算法避开死锁:

void swap_ok(Account& x, Account& y) {
    if (&x == &y) return; // 同一个 mutex 不能作为两个参数重复获取
    std::scoped_lock guard(x.m, y.m);
    std::swap(x.balance, y.balance);
}

灵活加锁:unique_lock(延迟 / 提前解锁)

lock_guard 不能中途解锁。要在临界区内「先解锁、做点不需要锁的耗时活、再上锁」,或想延迟上锁,就用 std::unique_lock

#include <mutex>
 
std::mutex m;
 
void process() {
    std::unique_lock<std::mutex> lk(m); // 上锁
    auto data = read_shared();          // 在锁内读共享数据
    lk.unlock();                        // 提前解锁:下面这步不碰共享数据
    auto result = heavy_compute(data);  // 耗时计算,不占着锁
    lk.lock();                          // 重新上锁
    write_shared(result);
}                                       // lk 析构,确保最终解锁

unique_lock 还支持 std::defer_lock(构造时先不锁、之后再 lock)和移动(把锁的所有权交给别的 unique_lock 或从函数返回),灵活得多——代价是比 lock_guard 略有运行时开销。条件变量(下一章)必须配它用。

惰性初始化:call_once + once_flag

某个昂贵资源想「用到时才初始化、且只初始化一次」,多线程下别手写双重检查锁定——用 std::call_once

#include <mutex>
#include <memory>
 
std::once_flag flag;
std::unique_ptr<Resource> res;
 
Resource& get_resource() {
    std::call_once(flag, [] {
        res = std::make_unique<Resource>();
    });
    return *res;                    // 之后每次调用都直接返回,已就绪
}

std::call_once 保证一次成功返回的初始化对其他返回的调用可见。若 callable 抛异常,本次调用是 exceptional,flag 不会被置为完成,后续调用还会重试;所以“最多执行一次”只适用于成功完成的那次初始化。C++11 起,函数内静态变量的初始化也由语言保证线程安全,适合能直接构造并返回的单例资源。

读多写少:shared_mutex 读写锁

如果一份数据被频繁读、偶尔写(如配置表、DNS 缓存),用普通 mutex 会让读线程也互相排斥,浪费并发。C++17 的 std::shared_mutex 区分两种锁:

#include <shared_mutex>
#include <map>
#include <string>
 
std::shared_mutex rw;
std::map<std::string, std::string> dns_cache;
 
// 读:共享锁,多个读线程可同时进
std::string lookup(const std::string& host) {
    std::shared_lock<std::shared_mutex> lk(rw);
    auto it = dns_cache.find(host);
    return it == dns_cache.end() ? "" : it->second;
}
 
// 写:独占锁,写时谁都不能进(连读也挡)
void update(const std::string& host, const std::string& ip) {
    std::lock_guard<std::shared_mutex> lk(rw);
    dns_cache[host] = ip;
}

读用 std::shared_lock(多读并行),写用 std::lock_guard / std::unique_lock(独占)。它是否快于普通 mutex 必须实测:读临界区太短时管理开销可能更高,而且标准不保证读者或写者公平,具体实现可能出现一方长期等待。

容易踩的坑

小结

  • 数据竞争是 C++ 的未定义行为条件;竞态条件是更广的逻辑时序错误,接口的 check-then-act 即使各步加锁也可能竞态
  • std::mutex + std::lock_guard 把读写共享数据的代码圈成临界区:构造上锁、析构自动解锁,任一刻只有一个线程能进,竞态消失
  • 接口竞态:每个操作都加锁,组合用(emptytoppop)仍可能 race;修法是把需要原子完成的复合逻辑合并成一个加锁的操作
  • 死锁:多把锁顺序不一致 → 环形等待僵死;用 std::lock / std::scoped_lock 一次锁多把,或约定全局固定加锁顺序避免
  • 灵活加锁:unique_lock 可延迟/提前解锁/移动,call_once 在异常后会重试初始化,shared_mutex 允许多读但性能与公平性由实现和负载决定

练习

问题 1(改代码型) 下面这段统计代码存在数据竞争,行为未定义。请用 std::mutex + std::lock_guard 把它改成线程安全,并说明为什么每个访问点必须服从同一把锁的协议(本题暂不改成局部累加)。

long total = 0;
 
void accumulate(const std::vector<int>& v) {
    for (int x : v)
        total += x; // 竞态:读—改—写被打断
}

问题 2(问答型) 同事写了一个「线程安全队列」,empty()front()pop() 三个成员函数内部都正确加了锁。他在多个线程里这样消费:while(!q.empty()){ auto v=q.front(); q.pop(); handle(v); }。他说「每个操作都加锁了,肯定线程安全」。他对吗?指出隐患并给修法。

问题 3(独立实现型) 实现一个线程安全的计数器类 Counter:成员 increment()(加一)、get()(读当前值)。要求用 std::mutex 保护,且 get() 必须能在 const 对象上调用。提示:被 const 成员函数用到的 mutex 要声明成 mutable

名词解释

本章出现的专业名词,用大白话再讲一遍。

竞态条件(race condition)

程序结果或正确性依赖并发操作相对时序的逻辑问题。它比数据竞争更广:即使每次访问都加锁,分开的检查与执行仍可能形成接口竞态。

数据竞争(data race)

对同一内存位置的冲突并发访问缺少 happens-before 关系,至少一个是写,而且没有合适原子语义。C++ 规定存在数据竞争的程序行为未定义,不能只按源码推演几个结果。

不变量(invariant)

一段数据在「正确状态」下始终成立的约定。比如「计数器的值 = 已发生的事件数」「链表里每个 next 都指向真实的下一个节点」。修改数据的中途,不变量可能被暂时打破(如计数器读了还没写回),必须在别的线程看到之前修复好——竞态之所以坏事,就是因为别的线程在不变量被打破的窗口里插了进来。详见本章「竞态条件」一节。

互斥量(mutex)

mutual exclusion(互斥)的缩写,一种同步原语。像后厨那把唯一的厨刀lock() 拿到它、unlock() 放下;同一时刻最多一个线程能攥着它。把读写共享数据的代码圈在 lock 与 unlock 之间,就保证任一刻只有一个线程在碰这份数据。C++ 标准库为 std::mutex。详见本章「互斥量」一节。

临界区(critical section)

一段同一时刻最多只允许一个线程执行的代码,通常就是读写某份共享数据的那几行。用互斥量把它「圈」起来:进入前 lock、离开时 unlock。后厨里相当于「只有拿到那把唯一厨刀的人才能站上去切」的那块砧板操作区。详见本章「互斥量」一节。

std::lock_guard

一个 RAII 工具类:构造时对传入的 mutex 上锁、析构时自动解锁。把 std::lock_guard g(m); 写在临界区开头,离开作用域(正常返回或异常退出)时自动 unlock,绝不漏锁——比手动 lock()/unlock() 安全得多。是保护临界区最常用的方式。详见本章「加锁」一节。

死锁(deadlock)

两个或多个线程互相等待对方持有的锁,谁都不肯先释放,于是全部僵在原地、永远走不下去。最常见的成因是不同线程以不同顺序获取多把锁,形成环形等待。像两个厨师各攥着一件对方要的工具互不相让,后厨就此僵死。详见本章「死锁」一节。

std::lock / std::scoped_lock

std::lock 用死锁规避算法获取多把 Lockable,成功返回时全部已锁,抛异常时解开本次已经取得的锁。C++17 的 std::scoped_lock 把多锁获取和 RAII 解锁组合起来;同一 mutex 不能作为多个参数重复传入。

std::unique_lock

lock_guard 更灵活的 RAII 锁:支持延迟上锁(先不锁、之后再 lock)、中途提前解锁、以及移动(把锁的所有权转移给别的 unique_lock 或从函数返回)。代价是比 lock_guard 略有开销。条件变量(下一章)必须配它使用。详见本章「灵活加锁」一节。

call_once / once_flag

std::once_flagstd::call_once:保证某段初始化代码在多线程下只被执行一次,且别的线程会等它执行完再继续。用于线程安全的「用到时才初始化」(惰性初始化),比手写「双重检查锁定」更简单、也没有数据竞争。详见本章「惰性初始化」一节。

读写锁(shared_mutex)

C++17 的 std::shared_mutex:允许多个读线程同时持有「共享锁」、但写线程独占(写时连读也挡)。读多写少的共享数据用它,并发度比普通 mutex(读也互斥)高得多。读用 std::shared_lock,写用 std::lock_guard / std::unique_lock。详见本章「读多写少」一节。

资料与写作方式声明

本章以C++ 并发编程实战(第2版)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…