计算机体系结构 25:原子操作与同步边界

核心问题

缓存一致性保证单个地址的副本不会任意分裂;内存一致性模型规定跨地址操作的可见顺序。程序员真正写同步代码时,还要面对第三个问题:一个读改写操作怎样做到不可分割,发布数据时怎样把普通数据写和同步变量写绑定起来。

本篇把原子操作拆成三件事:

  • 原子性:一次 read-modify-write 不能被另一个写切开。
  • 顺序性:release/acquire、fence、aq/rl 等机制约束其他内存操作的可见顺序。
  • 前进性:CAS 或 LR/SC 失败后是否最终能成功,是另一类保证,不能从一次成功轨迹推出。

本文附件:

证据等级:功能执行。Python 模型给出事件图和 LR/SC 重试反例;C11 pthread 程序在本机原生环境中运行 2000 轮,验证有定义的 release/acquire 消息发布正例。没有性能计数器、没有 false sharing 计时、没有真实 RISC-V 硬件 LR/SC 运行记录。

边界

C11 N1570 的多线程规则把 sequenced-beforesynchronizes-withhappens-before 和 visible side effect 串成判断框架。memory_order_release 用于发布之前的副作用,memory_order_acquire 用于取得之后的可见性;二者只有在 acquire 操作读到对应 release 操作写出的值,或读到 release sequence 中的值时,才建立同步关系。若普通对象上的两个冲突访问没有 happens-before 约束,就形成 data race,程序行为未定义。

RISC-V A 扩展把原子操作分成 AMO 与 LR/SC 两类。AMO 是单条 read-modify-write;LR/SC 用 load-reserved 和 store-conditional 组成条件写入。指令上的 aqrl 位提供 acquire/release 排序语义,但排序不等于原子性;一次 sc 失败也不是内存写。

本文不把普通 C 数据竞争当作硬件反例。删除同步边后的 plain payload 读取在 C 语言层面是未定义行为,不能解释成“合法读到旧值”。若要讨论无同步但有定义的旧值读取,payload 自身也必须是原子对象,且只能得到 relaxed 原子操作允许的结论。

release/acquire 的读来源

消息发布的 C 形态如下:

1
2
3
4
5
6
payload = 42;
atomic_store_explicit(&flag, 1, memory_order_release);

while (atomic_load_explicit(&flag, memory_order_acquire) != 1) {
}
observed = payload;

判断这段代码不能只看 flag 上用了 acquire。关键是 acquire load 的读来源。如果它读到初始值 0,循环条件不成立时 payload 读取不会执行。不能把这个分支写成“payload 读到 0”。模型把它标成 not_executed

如果 acquire load 读到 producer 的 release store 写出的 1,就建立 sw 边:

1
2
3
payload_store_42  po  flag_store_release_1
flag_store_release_1 sw flag_load_acquire
flag_load_acquire po payload_load

po ∪ sw 的闭包给出 payload_store_42 hb payload_load。因此 payload 的普通写被 happens-before 保护,consumer 读取 42 是有定义的。

Python 事件图还保留两个反例:

  • 删除 sw 后仍读取 plain payload,模型结果是 undefined_data_race_if_plain_payload
  • 若 payload 也改成 atomic relaxed,删除 sw 后不再是 C 数据竞争,但 payload 读值只能列为 [0, 42],没有发布顺序保证。

这两个反例的价值在于防止一种常见误写:把“没有同步”解释成“硬件允许读旧值”。在 C 程序里,普通对象先越过 data race 规则才谈得上值;没有定义的程序不能当作硬件内存模型的证据。

C11 正例校验

C11 程序只验证正例:producer 先写普通 payload,再 release store flag=1;consumer acquire load 看到 flag=1 后读取 payload。这个路径没有数据竞争,因为 acquire 读到 release 后建立同步,payload 写 happens-before payload 读。

复跑命令:

1
examples/computer-architecture/memory-model/src/run_c11_release_acquire.sh

本次输出:

1
c11_release_acquire: iterations=2000 failures=0 expected_payload=42

这个结果说明:在本机 clang/pthread 环境下,2000 轮有定义消息发布正例都读到 42。它不证明未同步版本会失败,也不证明所有平台、所有调度下的性能或进度性质。

LR/SC 的失败与重试

LR/SC 的基本结构是:先 lr 读取旧值并建立 reservation,再用 sc 尝试写入新值。若 reservation 期间有冲突写入,sc 可以失败。一次失败不是 bug;若程序想完成某个原子增量,就必须重试或改用其他原子 RMW。

本文模型设置初值 0。T0 第一次 lr 读到 0,准备写 1。期间 T1 做一次原子 RMW,把值从 0 改成 1,并使 T0 的 reservation 失效。T0 第一次 sc 失败。若不重试,最终值是 1,T0 的增量意图丢失。重试后 T0 再读到 1,并成功写 2:

1
2
3
4
5
T0 lr -> 0, intended 1
T1 atomic_rmw: 0 -> 1, invalidates T0
T0 sc fails
T0 lr -> 1, intended 2
T0 sc succeeds, final 2

lrsc_retry.json 的结论写得很窄:一次干扰后,重试循环保持了这个原子增量意图。它没有证明 lock-free、wait-free,也没有证明任意 LR/SC 受限循环都会最终成功。RISC-V 对 LR/SC 的前进性有约束形式和实现条件;本文模型只展示失败必须被程序处理。

正确性与竞争成本

锁、原子计数器和引用计数常把多个核心集中到同一 cache line。正确性上,原子 RMW 可以维持不可分割更新;性能上,同一个 line 的所有权会在核心之间来回迁移,可能引入高延迟和 false sharing。本文没有复用 scaling 批次的真实计时附件,也没有硬件计数器,所以不声称“同线比隔离慢多少”。

因此,本篇验收限定在正确性:release/acquire 的有定义消息发布、删除同步边后的数据竞争边界、LR/SC 失败重试。竞争成本需要单独基准、固定线程亲和性或至少真实计时样本,不能由事件图推出。

验收结果

复跑命令:

1
2
python3 examples/computer-architecture/memory-model/src/run_cases.py
examples/computer-architecture/memory-model/src/run_c11_release_acquire.sh

本次输出:

1
2
memory model cases: PASS
c11_release_acquire: iterations=2000 failures=0 expected_payload=42

本文验收项对应为:

  • 能说明 acquire load 只有读到 release store 时才建立同步。
  • 能说明 flag 读到初始 0 时 payload 读取没有执行,不能写成 payload 读 0。
  • 能区分 plain payload 删除同步边后的 data race 与 atomic relaxed payload 的有定义弱顺序。
  • 能说明 LR/SC 的 sc 失败需要重试;一次有限成功不证明一般前进性。

练习

练习 1

在消息发布程序中,consumer 的 acquire load 读到 flag 的初始值 0,并且代码写成 if (flag == 1) read payload。此时 payload 的结果应写成 0、42,还是未执行?

答案线索:未执行。只有进入 if 后才读 payload。把它写成 0 会把控制流和内存可见性混在一起。

练习 2

T0 执行 lr 读到 0 后,T1 先执行一次原子 RMW 写 1。若 T0sc 失败后不重试,最终值是多少?若重试一次并成功,最终值是多少?

答案线索:不重试时最终值是 1,T0 的增量没有完成。重试成功时,T0 重新读 1 并写 2,最终值是 2。

参考资料