标志位已经是原子的,为什么另一个线程仍可能看不到配套数据?
时间:2026/04/09
线程 A 准备一份普通数据,再把原子标志设为 true;线程 B 看到标志后读取数据。这看起来像最简单的线程通信:
1 | Payload payload; |
ready 的读写确实不会发生数据竞争,但这还不足以建立 payload 的跨线程可见性。原子性只回答“这个原子操作能否被并发破坏”,内存序还要回答“它与其他内存操作建立了什么顺序关系”。
本文从这个发布场景出发,讲清 std::atomic、relaxed、acquire/release、顺序一致性和 CAS。重点不是背六个枚举值,而是学会先画出需要的 happens-before 关系,再选择满足它的最弱且可维护的同步方式。
1. 普通共享变量为什么会产生数据竞争?
下面的递增不是一个由 C++ 保证不可分割的动作:
1 | int counter = 0; |
若多个线程无同步地访问同一内存位置,至少一个访问为写,且这些访问没有适当的 happens-before 关系,就会发生数据竞争。对普通对象的数据竞争属于未定义行为,不只是“最后少加几次”。
改成原子读改写可以让计数本身安全:
1 | std::atomic<int> counter{0}; |
fetch_add 是一次原子读—修改—写操作。所有针对同一个原子对象的修改形成一条修改顺序(modification order),不会把两个更新简单覆盖成一次。
但这段代码只说明 counter 正确累加,并没有自动发布旁边其他普通对象。原子操作携带何种跨对象顺序,取决于内存序和线程间读到的具体值。
2. 原子性、可见性和顺序为什么不能混为一谈?
可以把问题分成三层:
| 层次 | 要回答的问题 | 典型机制 |
|---|---|---|
| 原子性 | 单次操作是否会被并发访问破坏 | 原子 load/store/RMW |
| 顺序关系 | 一个线程的操作是否先于另一个线程的操作 | synchronizes-with、happens-before |
| 业务不变量 | 多个字段是否始终保持一致 | 常用互斥锁或更高层协议 |
编译器和 CPU 都可能在语言允许的范围内改变执行方式。比“某条机器指令是否重排”更可靠的推理方法,是看 C++ 内存模型是否建立同步关系。只要关系正确,实现就必须给出符合语义的结果;若关系缺失,某台 x86 机器上反复正常也不能证明程序可移植正确。
std::atomic 不是“带缓存刷新的普通变量”,也不是互斥锁的自动替代品。它是一组带明确内存模型语义的操作。
3. relaxed 到底保证了什么?
std::memory_order_relaxed 保证该原子对象上的操作仍然是原子的,并遵守该对象的修改顺序,但不借此为其他普通内存访问建立线程间同步。
它很适合与其他数据无发布关系的统计:
1 | std::atomic<std::uint64_t> requests{0}; |
如果程序只关心最终计数,不用“计数到达某个值”证明其他数据已经准备好,relaxed 往往足够。线程结束后通过 join() 读取最终值时,可见性还由线程连接关系保证。
它不适合直接替换开头的发布协议:
1 | payload = make_payload(); |
另一个线程的 relaxed load 即使读到 true,也没有因此与 payload 的普通写建立 happens-before。程序若并发读写 payload,就缺少所需同步保证。
不能靠写一个压力测试来“证明 relaxed 错误”:弱序机器可能暴露问题,强序机器可能一直正常,编译器版本也会改变表现。缺少标准保证本身就是问题。
4. release/acquire 怎样完成一次发布?
发布线程使用 release store:
1 | payload = make_payload(); |
消费线程使用 acquire load:
1 | while (!ready.load(std::memory_order_acquire)) { |
当 acquire load 读到 release store 写入的值,并满足标准定义的关联条件时,release 操作 synchronizes-with 该 acquire 操作。于是:
1 | 线程 A 线程 B |
真正保证普通数据安全的是最后这条 happens-before 链,而不是“release 会立刻把所有缓存刷到主存”这样的硬件类比。不同体系结构可以用不同指令实现同一语言语义。
如果 acquire load 没有读到对应发布所产生的值,就不能凭空获得这条同步关系。协议必须明确哪个原子值代表哪一批数据已经就绪。
5. 如何写出一个可运行的发布—获取示例?
下面使用 C++11 的 std::async 管理两个异步任务。生产者先写普通 Payload,再 release 发布;消费者 acquire 观察标志后读取。ready 是一次性标志,不会重置。
1 |
|
在支持 C++11 线程库的 Clang 环境中编译:
1 | clang++ -std=c++11 -O2 -Wall -Wextra -pedantic -pthread publish.cpp -o publish |
预期输出:
1 | code=200, message=ready |
显式指定 std::launch::async,避免实现把任务延迟到 get() 所在线程才执行;否则消费者先执行并自旋时,生产者可能没有机会启动。任务中的异常会在对应 get() 处重新抛出。具体实现可能为任务创建线程或采用其他符合标准的机制,资源策略仍需结合标准库验证。示例的生产步骤只做固定大小赋值;真实生产过程若可能失败,协议还必须发布“失败/关闭”状态,不能让消费者只等待成功标志。
6. 这段代码为什么能安全读取普通字符串?
生产者是唯一修改 payload 的线程,并在修改完成后执行 release store。消费者在 acquire load 读到 true 之前不访问 payload。这对操作建立 happens-before,因此复制 payload 时,生产者不再与它并发冲突。
消费者写入 observed,主线程只在 consumer.get() 返回后读取它;异步共享状态的完成与等待方返回之间提供相应同步。于是两个跨线程边界都有清晰关系。
删除 release 或 acquire 中任意一端,原子标志仍然安全,旁边普通数据的发布链却不再成立。把 payload 的每个字段都改成原子也不是自然解决方案:字符串并非简单标量,多个字段还需要作为一个一致快照发布。
为什么示例中的自旋只适合短等待?
yield() 只是给调度器的提示,不保证线程休眠,也不保证公平。若生产者可能等待 I/O、长时间计算或失败而不发布,消费者会持续占用调度资源甚至永不退出。
C++20 为原子类型提供 wait/notify_one/notify_all,实现可以更高效地阻塞等待;C++11 项目可使用条件变量。无论选什么工具,都应设计超时、关闭或失败状态,不能只等待一个永远可能不来的 true。
7. seq_cst 为什么更容易推理?
未显式指定时,多数原子操作默认使用 std::memory_order_seq_cst(sequentially consistent,顺序一致)。除了相应操作的 acquire/release 等效果外,所有 seq_cst 操作还要符合一条单一总顺序,并满足标准规定的一致性约束。
1 | ready.store(true); // 默认 seq_cst |
它能排除一些弱内存序允许的观察结果,因此通常更容易建立全局直觉。它不表示所有普通指令都严格按源码时间排成一条物理队列,也不表示必然生成“最重”的同一条指令;实现方式依架构和操作而异。
选择策略上,先用清楚且正确的同步最重要。只有 profiling 证明原子顺序是热点,并且能够形式化说明更弱顺序仍满足协议时,才降低内存序。把所有操作从 seq_cst 批量替换为 relaxed 不是优化方法。
8. acq_rel 应该用在什么操作上?
std::memory_order_acq_rel 用于读—修改—写(read-modify-write,RMW)操作,让它既获取此前发布的数据,又发布当前线程此前的操作:
1 | const int previous = state.fetch_add( |
普通纯 store 不能使用 acquire,纯 load 不能使用 release;内存序必须与操作种类匹配。是否真的需要双向效果取决于协议,而不是因为 RMW 就机械使用 acq_rel。纯统计 fetch_add 仍可能只需要 relaxed。
内存栅栏(fence)还能建立更复杂关系,但比原子变量上直接使用 acquire/release 更难推理。基础协议优先让同步含义绑定在传递状态的原子对象上。
9. CAS 为什么是无锁算法的基础?
CAS(compare-and-exchange)表达:仅当原子当前值等于期望值时,才替换为新值。
1 | std::atomic<int> maximum{0}; |
compare_exchange_weak 允许伪失败,适合本来就要重试的循环;strong 不允许这种伪失败,更适合一次判断。两者在比较失败时都会把 expected 更新为实际观察值,这也是循环能继续基于新状态判断的原因。
CAS 可以分别指定成功与失败内存序。失败路径只进行了读取,不能使用 release 或 acq_rel;失败顺序也受成功顺序约束。若没有充分把握,先使用默认 seq_cst 或封装在经过审查的组件里。
CAS 循环为什么仍可能很慢?
高竞争时,许多线程读取同一旧值,只有一个成功,其余全部重试。缓存行在核心间转移,加上重试和退避成本,无锁算法完全可能慢于一把实现良好的锁。
10. “无锁”究竟承诺什么?
并发算法常见进展保证大致有:
| 属性 | 直观含义 |
|---|---|
| blocking | 某线程持锁停顿可能阻塞其他线程 |
| lock-free | 系统整体保证持续有线程取得进展 |
| wait-free | 每个操作在有限步骤内完成 |
这些是进展性质,不是速度排名。lock-free 不保证公平、低延迟或低缓存流量,也不表示代码里没有循环等待。
std::atomic<T> 也不保证对所有 T 和平台都由无锁指令实现。可以查询对象的 is_lock_free();C++17 还有 is_always_lock_free。结果与类型、ABI、对齐和目标平台有关,正确性不能依赖“本机碰巧 lock-free”,除非系统明确检查并提供后备方案。
std::atomic_flag 由标准提供特定的无锁保证,但用它拼一个自旋锁仍然属于锁,只是等待方式不同:
1 | while (flag.test_and_set(std::memory_order_acquire)) { |
高竞争、长临界区或线程被抢占时,自旋会浪费 CPU。生产代码优先使用标准互斥锁,除非目标平台、等待时间和退避策略都经过测量。
11. 为什么一个 CAS 栈还需要解决内存回收?
无锁链表或栈不能只把头指针改成原子。线程 A 读到节点地址后,线程 B 可能移除并释放该节点;线程 A 随后解引用就会使用已释放内存。
即使地址仍有效,也可能出现 ABA:
1 | 线程 A 观察头指针为 A |
指针值看起来没变,中间状态却已经发生过变化。标记指针、版本计数只能处理部分 ABA 场景,安全回收通常还需要 hazard pointer、epoch-based reclamation、RCU 或其他成熟方案。
因此,“用 CAS 写一个栈”作为教学片段可以展示原子更新,却不是完整可投入生产的容器。除非同步算法本身是项目核心能力,否则优先采用经过验证的库。
12. 原子操作为什么经常遇到伪共享?
两个线程修改不同原子变量,如果它们位于同一缓存行,仍可能让缓存行所有权来回转移:
1 | struct Counters { |
这叫伪共享。原子性保证正确性,却不会消除一致性流量。缓解方式通常是每线程局部累加后汇总,或在确认硬件布局后分隔热点对象。
1 | struct alignas(64) Counter { |
64 字节只是常见硬件上的示例,不是可移植缓存行常量。过度填充还会增加内存占用。应通过目标平台信息和性能计数器验证,而不是给每个原子都加固定 padding。
共享所有权也可能隐藏原子流量:复制 std::shared_ptr 通常要以线程安全方式更新控制块引用计数。热路径不需要共享生命周期时,明确借用或 unique_ptr 往往更简单,但不能为了省原子操作而制造悬空指针。
13. 原子什么时候不如互斥锁合适?
当一个不变量跨越多个字段时,锁通常更直接:
1 | struct Account { |
一次转账既要改变余额又要记录历史。分别把字段原子化不能自动生成一致快照,也不能解决容器修改。互斥锁把整个状态转换定义为临界区,更容易验证异常安全。
原子适合简单标志、独立统计、单调状态和经过设计的并发数据结构基础操作。选择标准不是“单变量一定用原子”,而是能否用简洁协议完整表达所有不变量。
14. volatile 为什么不能代替 atomic?
volatile 主要服务于具有特殊访问语义的对象,例如某些内存映射硬件寄存器,并受具体实现和平台规则影响。它不提供:
- 原子读—修改—写;
- 线程间 synchronizes-with;
- 保护普通数据的 happens-before;
- 互斥或复合状态一致性。
1 | volatile bool ready = false; // 不能作为标准 C++ 线程同步协议 |
线程通信应使用原子、互斥锁、条件变量等标准同步原语。不要从 Java 或 C# 中同名关键字的语义类推 C++ volatile。
15. 常见误区如何纠正?
误区一:只要标志是原子的,旁边数据就安全
不对。必须通过 acquire/release、锁、线程连接等机制建立普通数据写入到读取之间的 happens-before。
误区二:relaxed 什么顺序都没有
不准确。它仍保证原子性,并且同一个原子对象的修改有一致的修改顺序;只是不会借该操作为其他普通内存建立所需同步。
误区三:acquire 总能看到所有线程的最新数据
不对。它需要读到与发布链关联的值,才能获得对应 release 之前操作的可见性。它不是全局“刷新最新值”按钮。
误区四:seq_cst 能修复所有并发设计
不对。它提供更强原子顺序,不能修复越界、生命周期错误、多个字段缺少事务性或错误的等待协议。
误区五:lock-free 等于没有等待且一定更快
不对。CAS 重试和缓存行争用都可能非常昂贵。lock-free 描述系统进展保证,不描述平均耗时。
误区六:CAS 成功就说明节点仍可安全访问
不对。无锁指针结构还必须解决 ABA 和对象回收,生命周期问题与原子更新同等重要。
16. 什么时候应该使用哪种内存序?
可以从协议而不是枚举名称出发:
| 需求 | 常见起点 |
|---|---|
| 独立统计,不发布其他数据 | relaxed |
| 一个线程发布普通数据,另一个线程获取 | release store + acquire load |
| RMW 同时获取旧状态并发布新状态 | 根据协议考虑 acq_rel |
| 尚未证明弱顺序正确 | 默认 seq_cst |
| 多字段复合不变量 | 优先互斥锁,而非堆叠原子 |
这张表只是起点。复杂的多生产者协议、release sequence、多个原子之间的关系和内存回收不能靠一张速查表设计,应使用形式化推理、专门测试和成熟实现。
17. 总结
回到开头,原子 ready 只能保证自己的访问不发生数据竞争。要让消费者安全读取普通 payload,还需要 release/acquire 在两线程间建立 synchronizes-with,进而形成 happens-before。
最重要的结论是:
- 原子性、线程间顺序和业务不变量是三个不同层次的问题。
- relaxed 适合独立原子状态,不会自动发布旁边普通数据。
- acquire 只有读到相应发布值时,才获得 release 之前操作的可见性。
- seq_cst 更易推理,但再强的内存序也修不好错误生命周期和复合协议。
- 无锁描述进展保证,不保证更快;CAS 算法还必须处理 ABA 与安全回收。
实践中每次选择非默认内存序,都在代码旁写清楚“哪个 release 与哪个 acquire 配对、发布了哪些数据”。如果这句话写不出来,优先使用更强顺序或更容易验证的锁。