上期我们介绍了C++原子操作的基本内存序模型,本期聚焦一个在标准讨论和实践中屡屡引发争议的问题:在C++原子操作中,生成的值是否允许循环依赖自身的计算?换言之,当原子变量A的写入依赖于对A或另一原子变量B的读取,而B的读取又反向依赖于A的写入时,这种“自指”循环是否违背C++内存模型规范?

问题缘起:从“空想值”到循环依赖

假设有两个原子变量xy,线程1执行:

x.store(1, memory_order_relaxed);
y.store(x.load(memory_order_relaxed), memory_order_relaxed);

线程2执行:

y.store(2, memory_order_relaxed);
x.store(y.load(memory_order_relaxed), memory_order_relaxed);

在不加任何同步的情况下,理论上xy最终可能取任意值——甚至出现“无中生有”的情况:比如x读到了42,但42从未在代码的任何store中出现过,仅凭循环依赖“证明”了自身的存在。这种现象被称为“out-of-thin-air”读值(空想值),是C++标准明确要杜绝的。

但问题在于:当循环依赖通过更复杂的内存序(如memory_order_consumeacquire-release)串联时,是否可能合法地产生由循环决定的值?例如,使用memory_order_seq_cst能否让这种循环变得安全?

标准立场:禁止“空想”,但允许有序循环

C++标准委员会在多年争议后,于C++20中通过P0668R5提案正式引入了对抗空想值的规则。C++标准 [intro.races] 第21段明确指出:对原子对象M的求值,必须由某个修改M的副作用X来确定,且X在M的修改顺序中不晚于该求值。同时,标准禁止“凭空出现”的值——即导致循环依赖中没有任何真正写入源的读取。

但请注意,标准并不一概禁止所有形式的循环依赖。关键在于能否构建一个合法的“happens-before”或“dependency-ordered before”链条,使得循环内的每个读取都能追溯到某个确定的外部写入。例如:

// 线程1
atomic<int> x(0), y(0);
x.store(1, memory_order_release);
int r1 = y.load(memory_order_acquire); // 若r1==2,则某处写入了2

// 线程2
y.store(2, memory_order_release);
int r2 = x.load(memory_order_acquire); // 若r2==1,则线程1的写入可见

这里的xy的依赖是双向的,但通过release-acquire同步建立了明确的顺序:要么线程1先完成,要么线程2先完成,不会出现“中间值”。此时循环依赖是良定义的,因为每次读取都对应一个真实的写入。

危险区域:memory_order_relaxed与consume的陷阱

当使用memory_order_relaxed时,循环依赖的破坏力最强。由于没有同步,两个线程的store和load可以任意交错,导致读取到从未显式store的值——这正是标准所禁止的。编译器可以优化时消除依赖,但最终程序行为未定义。

memory_order_consume(C++17中已弃用,C++20中建议避免)则更为微妙。它试图携带数据依赖,但循环依赖可能打破“carries dependency”规则:如果依赖链形成环,则是否携带依赖变得不可判定。因此,标准强烈建议不要在生产代码中依赖consume来构建循环,改用acquire

业界专家观点

Herb Sutter(C++标准委员会主席)在CppCon演讲中曾明确指出:“C++内存模型禁止任何形式的循环因果关系——即不允许一个值仅通过自身读取-修改-写操作来证明自身存在。所有原子操作必须有一个真正的、位于修改顺序中的写入作为最终来源。” 而Anthony Williams(《C++ Concurrency in Action》作者)则补充道:“实践中,使用seq_cst可以打破大多数循环歧义,因为它在所有线程间建立了单一全序。但这并不意味着循环依赖变得‘安全’,它只是让顺序可预测。”

实践建议

  1. 避免显式构造循环依赖:即使标准可能允许,也极易引入不可移植的、依赖于编译器实现的代码逻辑。使用更高级的同步原语(如mutex、latch)往往更清晰。
  2. 首选memory_order_seq_cst:在需要跨多个原子变量的复杂交互时,全序内存模型可以消除大多数循环歧义,代价是性能损耗。
  3. 关注编译器行为:GCC和Clang在优化relaxed循环时可能生成意想不到的代码。测试前务必使用-fsanitize=thread等工具检测数据竞争。

结语

C++原子操作中的循环依赖并非“一刀切”禁止。标准允许通过有明确同步关系的循环,但严格禁止导致“空想值”的未定义循环。理解这一界限,需要深入掌握内存序的happens-before和dependency-ordered before概念。对于多数开发者而言,最好的策略是:若依赖关系形成环,那就用更强内存序(如seq_cst)打破环的歧义性,或者干脆重构设计。

(下期预告:我们将深入探讨memory_order_consume在C++20中的实际废弃状态,以及数据依赖在编译器优化中的映射。)