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,需要区分:
- 求值左操作数
a,确定被修改的对象;求值右操作数42,得到待写入的值。 - 修改对象 a,这是赋值的副作用。
- 产生整个赋值表达式的结果:一个指代 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 的全序要求
实际代码应先确定需要保证的数据发布和观察关系,再选择原子内存序或互斥锁。仅凭源代码中的先后顺序,无法替代跨线程同步设计。
备注:内容仅供参考。
