计算机体系结构 25:原子操作与同步边界
计算机体系结构 25:原子操作与同步边界
核心问题
缓存一致性保证单个地址的副本不会任意分裂;内存一致性模型规定跨地址操作的可见顺序。程序员真正写同步代码时,还要面对第三个问题:一个读改写操作怎样做到不可分割,发布数据时怎样把普通数据写和同步变量写绑定起来。
本篇把原子操作拆成三件事:
- 原子性:一次 read-modify-write 不能被另一个写切开。
- 顺序性:release/acquire、fence、
aq/rl等机制约束其他内存操作的可见顺序。 - 前进性:CAS 或 LR/SC 失败后是否最终能成功,是另一类保证,不能从一次成功轨迹推出。
本文附件:
- run_cases.py.txt
- release_acquire.json
- lrsc_retry.json
- c11_release_acquire.c
- run_c11_release_acquire.sh.txt
- c11_release_acquire_output.txt
- memory_model_summary.txt
证据等级:功能执行。Python 模型给出事件图和 LR/SC 重试反例;C11 pthread 程序在本机原生环境中运行 2000 轮,验证有定义的 release/acquire 消息发布正例。没有性能计数器、没有 false sharing 计时、没有真实 RISC-V 硬件 LR/SC 运行记录。
边界
C11 N1570 的多线程规则把 sequenced-before、synchronizes-with、happens-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 组成条件写入。指令上的 aq 和 rl 位提供 acquire/release 排序语义,但排序不等于原子性;一次 sc 失败也不是内存写。
本文不把普通 C 数据竞争当作硬件反例。删除同步边后的 plain payload 读取在 C 语言层面是未定义行为,不能解释成“合法读到旧值”。若要讨论无同步但有定义的旧值读取,payload 自身也必须是原子对象,且只能得到 relaxed 原子操作允许的结论。
release/acquire 的读来源
消息发布的 C 形态如下:
1 | |
判断这段代码不能只看 flag 上用了 acquire。关键是 acquire load 的读来源。如果它读到初始值 0,循环条件不成立时 payload 读取不会执行。不能把这个分支写成“payload 读到 0”。模型把它标成 not_executed。
如果 acquire load 读到 producer 的 release store 写出的 1,就建立 sw 边:
1 | |
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 | |
本次输出:
1 | |
这个结果说明:在本机 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 | |
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 | |
本次输出:
1 | |
本文验收项对应为:
- 能说明 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。若 T0 的 sc 失败后不重试,最终值是多少?若重试一次并成功,最终值是多少?
答案线索:不重试时最终值是 1,T0 的增量没有完成。重试成功时,T0 重新读 1 并写 2,最终值是 2。






