标志位已经是原子的,为什么另一个线程仍可能看不到配套数据?

时间:2026/04/09

线程 A 准备一份普通数据,再把原子标志设为 true;线程 B 看到标志后读取数据。这看起来像最简单的线程通信:

1
2
3
4
5
6
7
8
9
10
Payload payload;
std::atomic<bool> ready{false};

// 线程 A
payload = make_payload();
ready.store(true, std::memory_order_relaxed);

// 线程 B
while (!ready.load(std::memory_order_relaxed)) {}
consume(payload);

ready 的读写确实不会发生数据竞争,但这还不足以建立 payload 的跨线程可见性。原子性只回答“这个原子操作能否被并发破坏”,内存序还要回答“它与其他内存操作建立了什么顺序关系”。

本文从这个发布场景出发,讲清 std::atomic、relaxed、acquire/release、顺序一致性和 CAS。重点不是背六个枚举值,而是学会先画出需要的 happens-before 关系,再选择满足它的最弱且可维护的同步方式。

1. 普通共享变量为什么会产生数据竞争?

下面的递增不是一个由 C++ 保证不可分割的动作:

1
2
3
4
int counter = 0;

// 多个线程同时执行
++counter;

若多个线程无同步地访问同一内存位置,至少一个访问为写,且这些访问没有适当的 happens-before 关系,就会发生数据竞争。对普通对象的数据竞争属于未定义行为,不只是“最后少加几次”。

改成原子读改写可以让计数本身安全:

1
2
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);

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
2
3
4
5
std::atomic<std::uint64_t> requests{0};

void record_request() {
requests.fetch_add(1, std::memory_order_relaxed);
}

如果程序只关心最终计数,不用“计数到达某个值”证明其他数据已经准备好,relaxed 往往足够。线程结束后通过 join() 读取最终值时,可见性还由线程连接关系保证。

它不适合直接替换开头的发布协议:

1
2
payload = make_payload();
ready.store(true, std::memory_order_relaxed); // 没有发布 payload

另一个线程的 relaxed load 即使读到 true,也没有因此与 payload 的普通写建立 happens-before。程序若并发读写 payload,就缺少所需同步保证。

不能靠写一个压力测试来“证明 relaxed 错误”:弱序机器可能暴露问题,强序机器可能一直正常,编译器版本也会改变表现。缺少标准保证本身就是问题。

4. release/acquire 怎样完成一次发布?

发布线程使用 release store:

1
2
payload = make_payload();
ready.store(true, std::memory_order_release);

消费线程使用 acquire load:

1
2
3
4
while (!ready.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
consume(payload);

当 acquire load 读到 release store 写入的值,并满足标准定义的关联条件时,release 操作 synchronizes-with 该 acquire 操作。于是:

1
2
3
4
5
6
7
8
9
10
线程 A                              线程 B
写 payload
│ sequenced-before
release store ready=true
│ synchronizes-with
acquire load 读到 true
│ sequenced-before
读 payload

写 payload happens-before 读 payload

真正保证普通数据安全的是最后这条 happens-before 链,而不是“release 会立刻把所有缓存刷到主存”这样的硬件类比。不同体系结构可以用不同指令实现同一语言语义。

如果 acquire load 没有读到对应发布所产生的值,就不能凭空获得这条同步关系。协议必须明确哪个原子值代表哪一批数据已经就绪。

5. 如何写出一个可运行的发布—获取示例?

下面使用 C++11 的 std::async 管理两个异步任务。生产者先写普通 Payload,再 release 发布;消费者 acquire 观察标志后读取。ready 是一次性标志,不会重置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <atomic>
#include <array>
#include <future>
#include <iostream>
#include <thread>

struct Payload {
int code = 0;
std::array<char, 6> message{{'\0', '\0', '\0', '\0', '\0', '\0'}};
};

int main() {
Payload payload;
Payload observed;
std::atomic<bool> ready{false};

std::future<void> producer = std::async(std::launch::async, [&] {
payload.code = 200;
payload.message = {{'r', 'e', 'a', 'd', 'y', '\0'}};
ready.store(true, std::memory_order_release);
});

std::future<void> consumer = std::async(std::launch::async, [&] {
while (!ready.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
observed = payload;
});

producer.get();
consumer.get();

std::cout << "code=" << observed.code
<< ", message=" << observed.message.data() << '\n';
}

在支持 C++11 线程库的 Clang 环境中编译:

1
2
clang++ -std=c++11 -O2 -Wall -Wextra -pedantic -pthread publish.cpp -o publish
./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
2
3
4
ready.store(true); // 默认 seq_cst
if (ready.load()) {
// ...
}

它能排除一些弱内存序允许的观察结果,因此通常更容易建立全局直觉。它不表示所有普通指令都严格按源码时间排成一条物理队列,也不表示必然生成“最重”的同一条指令;实现方式依架构和操作而异。

选择策略上,先用清楚且正确的同步最重要。只有 profiling 证明原子顺序是热点,并且能够形式化说明更弱顺序仍满足协议时,才降低内存序。把所有操作从 seq_cst 批量替换为 relaxed 不是优化方法。

8. acq_rel 应该用在什么操作上?

std::memory_order_acq_rel 用于读—修改—写(read-modify-write,RMW)操作,让它既获取此前发布的数据,又发布当前线程此前的操作:

1
2
const int previous = state.fetch_add(
1, std::memory_order_acq_rel);

普通纯 store 不能使用 acquire,纯 load 不能使用 release;内存序必须与操作种类匹配。是否真的需要双向效果取决于协议,而不是因为 RMW 就机械使用 acq_rel。纯统计 fetch_add 仍可能只需要 relaxed。

内存栅栏(fence)还能建立更复杂关系,但比原子变量上直接使用 acquire/release 更难推理。基础协议优先让同步含义绑定在传递状态的原子对象上。

9. CAS 为什么是无锁算法的基础?

CAS(compare-and-exchange)表达:仅当原子当前值等于期望值时,才替换为新值。

1
2
3
4
5
6
7
8
9
10
11
std::atomic<int> maximum{0};
int candidate = 42;
int expected = maximum.load(std::memory_order_relaxed);

while (expected < candidate &&
!maximum.compare_exchange_weak(
expected,
candidate,
std::memory_order_relaxed)) {
// 失败时 expected 已更新为 maximum 的当前值
}

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
2
3
4
5
6
7
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等
}

// 临界区

flag.clear(std::memory_order_release);

高竞争、长临界区或线程被抢占时,自旋会浪费 CPU。生产代码优先使用标准互斥锁,除非目标平台、等待时间和退避策略都经过测量。

11. 为什么一个 CAS 栈还需要解决内存回收?

无锁链表或栈不能只把头指针改成原子。线程 A 读到节点地址后,线程 B 可能移除并释放该节点;线程 A 随后解引用就会使用已释放内存。

即使地址仍有效,也可能出现 ABA:

1
2
3
线程 A 观察头指针为 A
线程 B 把 A 弹出,又经过操作让头指针重新变为地址 A
线程 A 的 CAS 看到“还是 A”并成功

指针值看起来没变,中间状态却已经发生过变化。标记指针、版本计数只能处理部分 ABA 场景,安全回收通常还需要 hazard pointer、epoch-based reclamation、RCU 或其他成熟方案。

因此,“用 CAS 写一个栈”作为教学片段可以展示原子更新,却不是完整可投入生产的容器。除非同步算法本身是项目核心能力,否则优先采用经过验证的库。

12. 原子操作为什么经常遇到伪共享?

两个线程修改不同原子变量,如果它们位于同一缓存行,仍可能让缓存行所有权来回转移:

1
2
3
4
struct Counters {
std::atomic<std::uint64_t> left{0};
std::atomic<std::uint64_t> right{0};
};

这叫伪共享。原子性保证正确性,却不会消除一致性流量。缓解方式通常是每线程局部累加后汇总,或在确认硬件布局后分隔热点对象。

1
2
3
struct alignas(64) Counter {
std::atomic<std::uint64_t> value{0};
};

64 字节只是常见硬件上的示例,不是可移植缓存行常量。过度填充还会增加内存占用。应通过目标平台信息和性能计数器验证,而不是给每个原子都加固定 padding。

共享所有权也可能隐藏原子流量:复制 std::shared_ptr 通常要以线程安全方式更新控制块引用计数。热路径不需要共享生命周期时,明确借用或 unique_ptr 往往更简单,但不能为了省原子操作而制造悬空指针。

13. 原子什么时候不如互斥锁合适?

当一个不变量跨越多个字段时,锁通常更直接:

1
2
3
4
5
struct Account {
std::mutex mutex;
std::int64_t balance;
std::vector<Transaction> history;
};

一次转账既要改变余额又要记录历史。分别把字段原子化不能自动生成一致快照,也不能解决容器修改。互斥锁把整个状态转换定义为临界区,更容易验证异常安全。

原子适合简单标志、独立统计、单调状态和经过设计的并发数据结构基础操作。选择标准不是“单变量一定用原子”,而是能否用简洁协议完整表达所有不变量。

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。

最重要的结论是:

  1. 原子性、线程间顺序和业务不变量是三个不同层次的问题。
  2. relaxed 适合独立原子状态,不会自动发布旁边普通数据。
  3. acquire 只有读到相应发布值时,才获得 release 之前操作的可见性。
  4. seq_cst 更易推理,但再强的内存序也修不好错误生命周期和复合协议。
  5. 无锁描述进展保证,不保证更快;CAS 算法还必须处理 ABA 与安全回收。

实践中每次选择非默认内存序,都在代码旁写清楚“哪个 release 与哪个 acquire 配对、发布了哪些数据”。如果这句话写不出来,优先使用更强顺序或更容易验证的锁。