CHAPTER 27 / Linux 与并发系统
原子操作与 happens-before
为什么计数用 relaxed 可以,而用 relaxed 发布普通对象却不够?
这一章要弄清楚
- 区分原子性与跨对象顺序
- 画出 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
ready=false 不表示 payload 已发布。四个位置始终表示同一个动作。
生产者先写 payload=42,再执行 ready.store(true, release)。消费者还没有观察到对应的 true。
中间箭头仅在 acquire 读到该 release 发布的值时表示 synchronizes-with;两侧是线程内程序顺序。这条链建立 payload 写入 happens-before 读取。
阅读完整推演文字
- 消费者尚不能读取
生产者写 payload:尚未写;release store:尚未发布;acquire load:看到 false;消费者读 payload:禁止读取
ready=false 不表示 payload 已发布。四个位置始终表示同一个动作。
- 先写数据,再发布
生产者写 payload:42;release store:true;acquire load:尚未读到 true;消费者读 payload:仍不能读取
生产者先写 payload=42,再执行 ready.store(true, release)。消费者还没有观察到对应的 true。
- 观察到发布,读取数据
生产者写 payload:42;release store:true;acquire load:读到该 true;消费者读 payload:42
中间箭头仅在 acquire 读到该 release 发布的值时表示 synchronizes-with;两侧是线程内程序顺序。这条链建立 payload 写入 happens-before 读取。
跟着例子,走完一遍
relaxed 原子加法只做计数
4 个线程,每个做 10000 次 fetch_add,主线程 join 后检查。
- fetch_add 是不可分割的读改写。
- 此计数不用于发布其他对象。
- join 保证主线程在工作完成后读结果。
#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';
}count=40000
relaxed 保留原子性;完成关系由线程 join 提供。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 23-a.cpp -o example && ./example预期标准输出:
count=40000
release/acquire 发布一次普通数据
payload 初始 0,ready 初始 false;生产者发布 42。
- 生产者先写 payload,再 release-store ready。
- 消费者 acquire-load 直到看到 true。
- 读取 payload 并 join,重复 100 次验证协议。
#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,给出一个合法但错误的交错,不必运行故意错误版本。
给我一点提示
- 所有操作原子并不意味着操作组合原子。
- 两个线程先读再分别写。
查看答案与推理
甲 load=0,乙 load=0,甲 store=1,乙 store=1。最终 1 而非 2。该错误是业务竞态而不是这些原子访问之间的 data race。
练习 2
发布 42 后生产者立即把 payload 改成 43,原有 ready 协议还能保护消费者吗?
给我一点提示
- release 只排序它前面的相关操作。
- 消费者可能正在读 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.继续查证
- C++ draft:Atomic order ↗
memory_order_relaxed、release/acquire、synchronizes with
- C++ draft:Data races ↗
happens before 与非原子冲突访问
公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。