CHAPTER 27 / Linux 与并发系统

原子操作与 happens-before

为什么计数用 relaxed 可以,而用 relaxed 发布普通对象却不够?

阅读与推演约 60 分钟练习时间另计

这一章要弄清楚

  • 区分原子性与跨对象顺序
  • 画出 release/acquire 发布链
  • 识别复合原子操作的业务竞态

先备知识:线程、互斥锁与死锁 / 条件变量、有界队列与关闭协议

C++20 / macOS 与 Linux;本章的硬件模型只推演逻辑,不代表设备性能。

先回答“到底要保护什么”

原子(atomic)操作使一个对象的单次访问不可被其他线程观察成撕裂的中间值,并遵守该对象的修改顺序。它不自动把多个变量组成事务,也不意味着一定 lock-free。用原子整数计数完成了多少任务,与用一个标志宣布“旁边的普通对象可以读了”,需要的保证不同。把所有原子都写成 relaxed 虽然短,却可能漏掉真正的数据发布关系。

第一个例子只关心一个计数器的最终值。四个线程各用 fetch_add 增加一万次,主线程 join 后再读,结果为四万。更新不能互相丢失;与主线程的完成顺序由 join 提供。因此这里的原子加法不需要为其他对象建立额外顺序,relaxed 足够。若把 fetch_add 改成 relaxed load 后再 relaxed store,单独访问仍无 data race,却可能出现丢更新,因为两个操作不是一个不可分割的读改写。

发布需要一条完整的链

第二个例子中,生产者先写普通整数 payload=42,再对 ready 做 release store。消费者对 ready 做 acquire load,且观察到这次 release 写入的 true,之后才读取 payload。生产者中的写 payload 先于 release;release 与读取它的 acquire 同步;acquire 先于消费者读 payload。把这些关系连起来,payload 的写 happens-before 读,所以这一非原子对象访问是安全的。

这里“观察到这次写”很重要。并不是某个线程任意执行了 acquire,世界上的所有写入就自动可见。若 acquire 仍读到最初的 false,它没有因此获得这次发布的保证。若生产者 release 后又改 payload,而消费者同时读取,那么旧发布关系也不能保护后来的并发写。示例是一次性交接,不是可以无限重复使用的消息队列。

yield只是调度提示(补充,另估10分钟)

std::this_thread::yield()向实现提供一次重新调度的机会;this_thread指当前调用它的线程。它不等待ready变true,可以很快返回,也不保证公平性、特定线程先运行或固定次数后结束。它本身没有同步作用,不能使普通payload的并发读写变安全。yield合同

按一个允许的观察序列推演23-b:第一次acquire读取ready得到false,调用yield;回来第二次仍可读到false,继续循环;直到某次acquire读到生产者release写入的true,才离开循环读取payload=42。这里的两次false只是给定推演,不是实测重试次数。如果第一次就读到true,循环体执行零次,仍可沿本节已证明的发布链读取42。安全来自那条release/acquire关系,不来自执行过yield。

三种常见误解

第一,volatile 不是线程同步工具。它的用途与特定可观察访问有关,不能替代 C++ 原子或锁建立 happens-before。第二,顺序一致 seq_cst 是很好的初学默认,但不是多个操作的事务边界。先检查余额再原子扣款,其他线程仍可能在中间改变条件。第三,lock-free 只是一类进展性质;不是每个线程都一定在有限步内完成,也不保证比 mutex 更快。

比较交换 compare-exchange 将“值仍等于预期才更新”作为一次原子动作。失败时需要基于观察到的新值重新推导;weak 版本可虚假失败,因此通常置于循环。指针算法还会涉及 ABA 与内存回收:某个地址离开结构、释放、又被新对象复用,地址相等并不保证还是同一个逻辑对象。初学阶段不把这些问题压缩成一句“用 CAS 就好”。

工程上的选择与验证

当需要维护跨字段不变量、复杂关闭状态或阻塞等待时,mutex 加条件变量通常更容易证明。只有测量指出争用瓶颈、且协议能清晰描述时,再引入更弱的内存顺序。代码审查时为每个非原子共享字段写出写者、读者及其同步链,比背六种 memory_order 的名字更有价值。

本章发布示例用 yield 循环只是保持机制短小;真实长等待应考虑原子 wait/notify 或条件变量,避免忙等。ThreadSanitizer 可以帮助检测缺少发布关系的实际执行;即使测试通过,也不能替代协议证明。面试回答先区分单对象原子性、跨对象可见性和算法进展,再选择最小充分工具。

两个程序也在启动前建立线程清理守卫:若后续线程创建失败,先等待已经启动的有限工作,再让被借用的数据离开作用域。下载例子显式注入第二个线程创建前的异常,并检查第一个线程的计数或发布已经完成。这验证拥有关系的失败路径,不改变 relaxed 计数和 release/acquire 发布本身的内存顺序证明。

观察 · 推演

发布协议的程序顺序与 synchronizes-with

生产者写 payload尚未写release store尚未发布acquire load看到 false消费者读 payload禁止读取01 / 03 · TIMELINE生产者写 payload尚未写release store尚未发布acquire load看到 false消费者读 payload禁止读取01 / 03 · TIMELINE
消费者尚不能读取

ready=false 不表示 payload 已发布。四个位置始终表示同一个动作。

1 / 3
阅读完整推演文字
  1. 消费者尚不能读取

    生产者写 payload:尚未写;release store:尚未发布;acquire load:看到 false;消费者读 payload:禁止读取

    ready=false 不表示 payload 已发布。四个位置始终表示同一个动作。

  2. 先写数据,再发布

    生产者写 payload:42;release store:true;acquire load:尚未读到 true;消费者读 payload:仍不能读取

    生产者先写 payload=42,再执行 ready.store(true, release)。消费者还没有观察到对应的 true。

  3. 观察到发布,读取数据

    生产者写 payload:42;release store:true;acquire load:读到该 true;消费者读 payload:42

    中间箭头仅在 acquire 读到该 release 发布的值时表示 synchronizes-with;两侧是线程内程序顺序。这条链建立 payload 写入 happens-before 读取。

跟着例子,走完一遍

例题 01C++20 · 本机可运行

relaxed 原子加法只做计数

4 个线程,每个做 10000 次 fetch_add,主线程 join 后检查。

  1. fetch_add 是不可分割的读改写。
  2. 此计数不用于发布其他对象。
  3. join 保证主线程在工作完成后读结果。
23-a.cpp
下载
#include <array>
#include <atomic>
#include <iostream>
#include <stdexcept>
#include <thread>
struct InjectedFailure {};
void increment(std::atomic<int>& count,bool inject_failure=false) {
    std::array<std::thread,4> workers;
    struct JoinAll {
        std::array<std::thread,4>& threads;
        ~JoinAll() {for(auto& t:threads) if(t.joinable()) t.join();}
    } join_all{workers};
    std::size_t started=0;
    for(auto& t:workers) {
        if(inject_failure && started==1) throw InjectedFailure{};
        t=std::thread([&]{
            for(int i=0;i<10000;++i) count.fetch_add(1,std::memory_order_relaxed);
        });
        ++started;
    }
    for(auto& t:workers) t.join();
}
int main() {
    std::atomic<int> count{0}; increment(count);
    const int result=count.load(std::memory_order_relaxed);
    if(result!=40000) throw std::runtime_error("count");
    std::atomic<int> partial{0}; bool caught=false;
    // Test-only failure before thread two; the first thread must finish before catch.
    try {increment(partial,true);} catch(const InjectedFailure&) {caught=true;}
    if(!caught || partial.load(std::memory_order_relaxed)!=10000)
        throw std::runtime_error("join on creation failure");
    std::cout<<"count="<<result<<'\n';
}

如何编译和运行下载的 .cpp 文件 →

结果与解释

count=40000

relaxed 保留原子性;完成关系由线程 join 提供。

在本机运行这个例子

下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 23-a.cpp -o example && ./example

预期标准输出:

count=40000
例题 02C++20 · 本机可运行

release/acquire 发布一次普通数据

payload 初始 0,ready 初始 false;生产者发布 42。

  1. 生产者先写 payload,再 release-store ready。
  2. 消费者 acquire-load 直到看到 true。
  3. 读取 payload 并 join,重复 100 次验证协议。
23-b.cpp
下载
#include <array>
#include <atomic>
#include <iostream>
#include <stdexcept>
#include <thread>
struct InjectedFailure {};
int publish(int& payload,bool inject_failure=false) {
    int observed=0; std::atomic<bool> ready{false};
    std::array<std::thread,2> workers;
    struct JoinAll {
        std::array<std::thread,2>& threads;
        ~JoinAll() {for(auto& t:threads) if(t.joinable()) t.join();}
    } join_all{workers};
    workers[0]=std::thread([&]{payload=42; ready.store(true,std::memory_order_release);});
    // Model consumer construction failure while retaining and joining the producer.
    if(inject_failure) throw InjectedFailure{};
    workers[1]=std::thread([&]{
        while(!ready.load(std::memory_order_acquire)) std::this_thread::yield();
        observed=payload;
    });
    for(auto& worker:workers) worker.join();
    return observed;
}
int main() {
    for(int trial=0;trial<100;++trial) {
        int payload=0;
        if(publish(payload)!=42) throw std::runtime_error("publication");
    }
    int partial_payload=0; bool caught=false;
    try {
        const int unexpected=publish(partial_payload,true);
        std::cout<<"unexpected publication result="<<unexpected<<'\n';
        return 1;
    } catch(const InjectedFailure&) {caught=true;}
    if(!caught || partial_payload!=42) throw std::runtime_error("join on creation failure");
    std::cout<<"published=42 trials=100\n";
}
结果与解释

published=42 trials=100

消费者观察对应 release 后才访问普通数据,建立 happens-before。

在本机运行这个例子

下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 23-b.cpp -o example && ./example

预期标准输出:

published=42 trials=100
常见错误

把原子 load 加 store 当作原子加一

两个线程均 load 到 0,分别 store 1,最终只增加了一次,即使所有操作都是原子也会丢更新。

修正思路:使用 fetch_add 或正确的 compare-exchange 循环;另外单独证明相关普通对象的发布关系。

轮到你动手

先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。

练习 1

将计数例子的 fetch_add 改为 load+store,给出一个合法但错误的交错,不必运行故意错误版本。

给我一点提示
  1. 所有操作原子并不意味着操作组合原子。
  2. 两个线程先读再分别写。
查看答案与推理

甲 load=0,乙 load=0,甲 store=1,乙 store=1。最终 1 而非 2。该错误是业务竞态而不是这些原子访问之间的 data race。

练习 2

发布 42 后生产者立即把 payload 改成 43,原有 ready 协议还能保护消费者吗?

给我一点提示
  1. release 只排序它前面的相关操作。
  2. 消费者可能正在读 payload。
查看答案与推理

不能。后续普通写与消费者普通读可能并发且没有顺序,构成 data race。需要应答/双缓冲/锁等明确协议,确保消费者结束读取后才能复用存储。

把理解说出来

先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。

解释

原子性与内存顺序分别解决什么?

参考回答 / English answer

原子性约束单次对象操作;顺序用于推理其他读写之间的可见性关系。二者不等价。

Atomicity protects an individual operation. Memory ordering relates that operation to other accesses across threads.
预测

relaxed fetch_add 是否会丢失某次增加?

参考回答 / English answer

对同一原子对象的 fetch_add 不会像 load+store 那样丢更新;但计数器溢出和业务语义仍需单独约束。

Fetch_add is one atomic read-modify-write operation. Relaxed ordering does not turn it into separate loads and stores.
找错

ready 是 atomic,payload 就一定安全了吗?

参考回答 / English answer

不一定,需建立匹配的 release/acquire 发布链,且发布后没有未排序的修改。

An atomic flag alone is insufficient. The payload needs a valid publication relationship and a lifetime protocol.
解释

acquire load 读到初始 false,会获取后续发布吗?

参考回答 / English answer

不会自动获取尚未观察到的发布;必须根据读到的值及对应的同步操作推理。

An acquire load does not acquire a future release. The synchronization depends on which write the load observes.
追问

seq_cst 能让多个账户操作成为事务吗?

参考回答 / English answer

不能。它提供原子操作的额外顺序保证,但多个操作之间仍可插入别的线程操作。

Sequential consistency is not transactionality. Other threads can interleave between multiple atomic operations.
追问

什么时候继续使用 mutex 更合理?

参考回答 / English answer

跨字段不变量、复杂状态转换和阻塞等待更适合容易证明的锁协议;先测量再优化。

A mutex is often clearer for compound invariants and blocking protocols. I prefer the simplest provable design before optimizing contention.

继续查证

公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。