PAYATHON 2026

C++11 中 sequenced-before 如何约束求值与副作用?

数据小许

不能从 evaluation 定义中的 initiation 推出“副作用可以在后续完整表达式之后才在语言语义上完成”。C sequenced-before D 约束的是 C++ 抽象机中的求值关系,其中包括相关副作用。它不要求处理器按相同的物理时间顺序执行全部指令,也不会自动建立其他线程可以依赖的同步关系。

evaluation 的组成说明也不等于执行步骤。标准说,求值包含 value computations 和副作用的启动,但没有规定所有表达式都必须“先计算自身结果,再执行副作用”。

sequenced-before 是否包含副作用?

包含。对于这里两个连续的表达式语句,前一个 full-expression 的每个 value computation 和每个 side effect,都 sequenced-before 后一个 full-expression 的对应操作。C++11 时期的工作草案 N3337 在 [intro.execution]/14 明确给出了这一保证。N3337:程序执行规则

因此,在线程 2 的抽象执行中:

r2 = x.load(std::memory_order_relaxed); // C
y.store(42, std::memory_order_relaxed); // D

C 中读取 x、把结果赋给 r2 等相关求值均位于 D 之前。不能将它理解为只启动对 r2 的修改,然后允许 D 在语言语义上抢先完成。

“副作用完成”也不意味着所有外部物理过程都已结束。例如,库 I/O 调用返回时,其副作用可以在语言层面被视为完成,实际设备操作仍可能继续。标准对这类情况有专门说明,不能据此把普通赋值普遍理解为异步提交。N3337:副作用与求值的定义

为什么 relaxed 示例仍允许两个结果都是 42?

假设 x、y 是在线程启动前已初始化为 0 的原子对象,r1、r2 没有被其他线程并发访问,最终结果在适当同步后读取。原示例为:

// Thread 1:
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B
// Thread 2:
r2 = x.load(std::memory_order_relaxed); // C
y.store(42, std::memory_order_relaxed); // D

线程内的顺序仍然是:

A --sequenced-before--> B
C --sequenced-before--> D

当两个结果都是 42 时,读取来源为:

D 写入 y 的 42 --被 A 读取--> A
B 写入 x 的 42 --被 C 读取--> C

relaxed load 读取某次 store 写入的值,单凭这一点并不会与该 store 建立 synchronizes-with 关系。因此,不能将这两条读取来源关系都当成 happens-before 关系,再把所有关系拼成一条必须遵守的全局时间线。

每个原子对象都有各自的 modification order,relaxed 操作也必须遵守相应的一致性规则,但不同对象的这些顺序不要求合成为一个全局顺序。N3337:多线程执行与 modification order

N3337 的 [atomics.order]/10 直接包含这个示例,说明允许得到两个 42。值的来源是:D 写入常量 42,A 读到这个值,B 将它写入 x,C 读到 B 写入的值。N3337:relaxed ordering 示例

这说明该结果符合内存模型,但不意味着每种编译器和处理器都会产生它,反复运行也不一定能观察到它。

“D 先于 C 完成”应该怎样理解?

原引用在这里转向了实现层面的描述。

编译器生成的指令以及处理器执行这些指令的方式,可以与源程序的抽象执行顺序不同。最终行为必须属于标准允许的行为,机器指令不必逐一复现抽象机的步骤。N3337:抽象机与 as-if rule

因此,“D 先于 C 完成”可以用来描述一种可能的底层执行方式,但不能由此在抽象机中添加 D sequenced-before C。

也不能把问题简化为“只是写 r2 比写 y 慢”。C 包含读取 x 和赋值给 r2;仅仅推迟 r2 的物理写入,不足以解释 C 为什么能够读到 42。分析时必须结合原子操作允许的读取来源和实现的执行方式。

原引用还有一处术语问题:A、C 是 load,不修改原子对象,本身不属于 modification order。严格来说,应当表述为“A 读取 D 写入的值”和“C 读取 B 写入的值”。

为什么赋值可以先修改对象,再产生表达式结果?

因为“操作数的 value computation”和“整个赋值表达式的 value computation”是不同的求值。

例如:

int a = 0;
int& ref = (a = 42);

对于内建赋值表达式 a = 42,需要区分:

  1. 求值左操作数 a,确定被修改的对象;求值右操作数 42,得到待写入的值。
  2. 修改对象 a,这是赋值的副作用。
  3. 产生整个赋值表达式的结果:一个指代 a 的 lvalue,因此 ref 可以绑定到 a。

赋值副作用 sequenced-after 两个操作数的 value computations,并且 sequenced-before 赋值表达式自身的 value computation。这是内建赋值运算符的规则。N3337:赋值与复合赋值运算符

第一步也不意味着在 C++11 中左操作数必须先于右操作数求值。这里只列出它们都必须满足的前置条件。

你给出的四步推导从一开始就把 C 的“读取结果”和“整个赋值表达式的结果”混在了一起。读取 x 得到待赋值的数值在前,随后修改 r2,赋值表达式自身的结果在修改之后产生。没有统一的“整个表达式先返回值,再启动其副作用”规则。

unsequenced 和 indeterminately sequenced 也有区别:后者表示两次求值中必有一个先于另一个,只是不指定哪一个先;不能把它理解成已经确定两个方向都不存在 sequenced-before 关系。

旧代码依赖的 sequence point 保证会失效吗?

改用 sequenced-before 的术语,不会让原有的完整表达式之间的顺序保证失效。例如:

int i = 0;
i++;
int j = i; // j == 1

后缀递增的结果计算与修改 i 的副作用可以有先后之分,但该副作用必须位于下一条完整表达式之前,不能以 initiation 为理由让 j 读取递增前的值。

这也不能推断出“所有旧程序切换标准后都绝对兼容”。迁移时仍需检查语言变化,以及旧代码是否依赖未指定行为、未定义行为或平台扩展。尤其要注意跨线程共享数据:sequence point 原本就不是线程同步机制;非原子冲突访问若缺乏适当的 happens-before 关系,可能构成 data race。N3337:data race 规则

如果需要排除两个 42,应怎样处理?

对于原示例,且没有其他相关访问时,可以将四个原子操作全部改为 std::memory_order_seq_cst:

// Thread 1:
r1 = y.load(std::memory_order_seq_cst); // A
x.store(r1, std::memory_order_seq_cst); // B
// Thread 2:
r2 = x.load(std::memory_order_seq_cst); // C
y.store(42, std::memory_order_seq_cst); // D

这些操作具有与线程内顺序一致的全局顺序。如果两个读取结果都是 42,就需要同时满足:

C < D < A < B < C

这一要求形成循环,因此无法满足该全局顺序。这样可以排除两个结果同时为 42,但并不指定程序一定产生哪组其他结果。N3337:seq_cst 的全序要求

实际代码应先确定需要保证的数据发布和观察关系,再选择原子内存序或互斥锁。仅凭源代码中的先后顺序,无法替代跨线程同步设计。

备注:内容仅供参考。