PAYATHON 2026

C++ CAS 失败时使用 seq_cst 能否保证断言不触发?

数据阿明

结论

第一种情况的理解正确:原始代码允许出现 result == 0 && flag == false,因此断言可能触发。

第二种情况需要加一个条件:只有在 result == 0 时,才能推出 expected 初值为 0 的 CAS 不可能成功。如果 result 不为 0,CAS 可以成功。

第三种情况在 C++17 及之后成立:保持 expected == 1,将成功序设为 relaxed、失败序设为 seq_cst,可以保证题中的断言不触发。C++11/14 对失败序有额外限制,这种参数组合违反其要求,属于未定义行为。旧要求见 N4140;删除相关限制的修改见 P0418R2,修改后的规则见 N4659。

情况expected 初值成功序 / 失败序当 result == 0 时
原始代码1seq_cst / relaxedCAS 可以失败,断言可能触发
只修改初值0seq_cst / relaxedCAS 必须失败,断言会触发
交换内存序1relaxed / seq_cstC++17 及之后 CAS 必须成功,断言不触发

下面的推导都以题中没有其他线程访问 x、y 为前提。

CAS 的内存序由实际结果决定

对于双内存序重载:

x.compare_exchange_strong(expected, desired, success, failure);

成功时,整个操作是使用 success 内存序的原子 read-modify-write(RMW);失败时,对 x 的操作是使用 failure 内存序的原子 load,并将读到的值写回 expected。这两个参数不是分别指定一次 RMW 的读取部分和写入部分的内存序。标准草案中的 CAS 语义明确区分了成功和失败这两种结果。

因此,要判断 #4 能否放入 seq_cst 全序,需要看这次 CAS 是成功还是失败。

第一种:失败使用 relaxed,断言可能触发

用 S 表示 seq_cst 操作的单一全序,<_S 表示在该全序中先于。

线程 t1 内部的顺序要求:

#1 <_S #2

y 只有初值 0 和 #3 写入的 1。当 #2 读到 0 时,有:

#2 <_S #3

因此:

#1 <_S #2 <_S #3

这些关系来自 seq_cst 全序对线程内顺序和同一原子对象访问的约束,见 Order and consistency。

如果 #4 失败,它的实际内存序是 relaxed,不属于 S。虽然 #3 在线程 t2 中先于 #4,但不能据此在上述全序后直接追加 #4,并断定它必须读到 x == 1。

这里没有建立从 #1 到 #4 的跨线程 happens-before 关系,来禁止 #4 读取 x 的初值。因此,以下结果是允许的:

#2 读取 y 的初值 0
#4 读取 x 的初值 0
#4 比较失败,将 expected 更新为 0
result == 0,flag == false

这是一组允许的读取结果,并非四个操作的真实执行时间线。断言可能触发,但标准允许这一结果,并不意味着每种硬件和编译器都会实际出现这一结果。

第二种:expected 为 0,结论取决于 result

保持原来的内存序,只改为:

int expected = 0, new_v = 2;

假设 result == 0,仍然有:

#1 <_S #2 <_S #3

再假设 CAS 成功。此时 #4 是 seq_cst RMW,在线程 t2 中位于 #3 之后,因此:

#1 <_S #2 <_S #3 <_S #4

CAS 成功意味着 #4 读到 0。但在这种执行中,对 x 的修改只有初始化、#1 和 #4。上述全序要求 #1 先于 #4,而 RMW 必须读取其在 modification order 中紧邻的前一个修改所写入的值,因此这里应读到 1。这与成功所需的 0 矛盾。RMW 的读取要求见 标准草案。

所以,当 result == 0 时,这次 CAS 必须失败,题中的断言会触发。

CAS 在其他执行中仍然可以成功。例如,线程 t2 可以先完成 #3,再由 #4 成功把 x 从 0 改为 2;随后线程 t1 执行 #1,再由 #2 读到 y == 1。这时 CAS 成功,但 result == 1,不会进入断言所在的分支。

第三种:失败使用 seq_cst,可以排除断言失败

恢复 expected == 1,将 CAS 改为:

flag = x.compare_exchange_strong(
    expected, new_v,
    std::memory_order::relaxed,
    std::memory_order::seq_cst); // #4

在 C++17 及之后的标准规则下,假设同时出现 result == 0 和 CAS 失败:

  1. result == 0 要求 #1 <_S #2 <_S #3。
  2. CAS 失败时,#4 是 seq_cst load;线程内顺序要求 #3 <_S #4。
  3. compare_exchange_strong 不会伪失败。由于期望值为 1,失败必须读到其他值。失败分支不会写入 2,程序中又没有其他写入者,所以只能读到 0。
  4. #4 作为 seq_cst load 读到初值 0,要求它在 S 中先于写入 1 的 #1。

合起来形成一个不可能的环:

#1 <_S #2 <_S #3 <_S #4 <_S #1

因此,result == 0 和 CAS 失败不能同时成立。也就是说,只要 result == 0,CAS 就必须成功,断言不会触发。这是将标准规则应用于题中代码后得出的结论。

如何修改,以及需要注意什么

如果目标只是保证题中的断言不触发,可以保持 expected == 1,将成功和失败都设为 seq_cst,这样更便于阅读和推理:

int expected = 1, new_v = 2;
flag = x.compare_exchange_strong(
    expected, new_v,
    std::memory_order::seq_cst,
    std::memory_order::seq_cst);

也可以使用单内存序重载:

flag = x.compare_exchange_strong(
    expected, new_v, std::memory_order::seq_cst);

该重载会让成功与失败都使用 seq_cst,这一选择也满足 C++11/14 对内存序组合的限制。单内存序重载规则

题中的 result 和 flag 不必为了这个例子改成原子变量:它们分别由一个工作线程写入,主线程在相应的 join() 返回后才读取。join() 让主线程能够观察工作线程的结果,但不会让两个工作线程中的所有原子访问自动变成 seq_cst。

不要直接把这里的 compare_exchange_strong 换成单次 compare_exchange_weak。后者允许伪失败,即使失败时读到的是 1,也可能返回 false,第三种情况下排除失败的推理就不再成立。

备注:内容仅供参考。