cppreference中有关松散顺序的解释是否错误?

14

cppreference.com上有关于std::memory_order的文档,其中提供了一个放松序列的示例:

放松序列

被标记为memory_order_relaxed的原子操作不是同步操作;它们不会强制实施并发内存访问之间的顺序。它们只保证原子性和修改顺序的一致性。

例如,假设x和y最初都为零,

// 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

它允许产生r1 == r2 == 42,因为尽管A在线程1内序列化于 B,C在线程2内序列化于 D,但是没有任何东西阻止D在y的修改顺序中出现在A之前,B在x的修改顺序中出现在C之前。D在y上的副作用可能会对线程1中的加载A可见,而B在x上的副作用可能会对线程2中的加载C可见。特别地,如果D在线程2中先于C完成,无论是由于编译器重新排序还是在运行时,都可能发生这种情况。

它说“C在线程2内的执行发生在D之前”。

根据评估顺序中的定义(可以在此处找到),如果A先于B序列化,则A的评估将在B开始之前完成。由于C在线程2内序列化于D,因此C必须在D开始之前完成,因此快照的最后一句话的条件部分永远不会被满足。


你的问题是否特别涉及C++11? - curiousguy
不,它也适用于C++14、17。我知道编译器和CPU可能会重新排列C和D。但如果重新排序发生,C在D开始之前无法完成。因此,我认为句子“在线程1中A被序列化在B之前,在线程2中C被序列化在D之前”存在术语误用。更准确的说法是“在代码中,A在线程1中被放置在B之前,在线程2中C被放置在D之前”。这个问题的目的是确认这个想法。 - abigaile
在“重新排序”方面没有定义任何内容。 - curiousguy
3个回答

14

我认为cppreference是正确的。我认为这归结于"as-if"规则[intro.execution]/1。编译器只需要复制您代码描述的程序的可观察行为。从执行这些评估的线程的角度来看,只建立了一个"sequenced-before"关系[intro.execution]/15。也就是说,当在某个线程中顺序出现两个已排序的评估时,实际运行在该线程中的代码必须表现得好像第一个评估确实影响了第二个评估所做的任何事情。例如

int x = 0;
x = 42;
std::cout << x;

必须打印42。但是,编译器在读取对象x中的值并将其打印出来之前,实际上不必将值42存储到该对象中。它可以记住最后一个存储在x中的值是42,然后直接打印值42,在对值42进行实际存储之前,不必创建对象或者实际存储值42,如果x是局部变量,那么它可以跟踪该变量在任何时候被分配的最后一个值,甚至根本不创建对象或者实际存储值42。线程无法区分这种差异。行为始终会像存在变量和先存储值42到对象x中一样。但这并不意味着生成的机器代码必须实际存储和加载任何东西。需要的只是生成的机器代码的可观察行为与如果所有这些事情都实际发生时的行为不可区分。

如果我们看一下

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

是的,C在D之前被排序。但是从这个线程的视角来看,C所做的任何事情都不会影响D的结果。而D所做的任何事情也不会改变C的结果。唯一能够相互影响的方式是作为其他线程中发生事件的间接后果。然而,通过指定std::memory_order_relaxed,你明确说明了另一个线程观察到的读写顺序是无关紧要的。因为其他线程不能按照任何特定顺序观察到读写操作,没有其他线程可以以一致的方式使C和D相互影响。因此,实际执行读写操作的顺序是无关紧要的。编译器因此可以重新排列它们。正如该示例下面的解释所述,如果从D中的存储在C的加载之前执行,则r1==r2==42确实可能出现…


4
根据发生它们的线程所能观察到的情况,C 必须在 D 之前发生。从另一个上下文观察可能与该观点不一致。 - Deduplicator
5
这个说法似乎与您之前在C++方面的宣传类似。@curiousguy在这里提出了一个问题并作了一些评论。链接中的问题也是由@curiousguy提出的,您可能会对其中的某些解答感兴趣。 - Lightness Races in Orbit
1
是的,编译器可以在线程1中重新排列AB,在线程2中重新排列CD。即使编译器没有执行重新排序,某些CPU也可能在运行时重新排序AB和CD。但是,如果发生了这样的重新排序,C在开始之前不会完成,A在B开始之前也不会完成。因此,在句子“ A在线程1中被序列化为B,C在线程2中被序列化为D”中存在术语误用。更好的说法是“在源代码中,A在线程1中放置在B之前,C在线程2中放置在D之前”,对吗? - abigaile
1
@curiousguy Michael已经发布了一篇详细的解释,并附上了标准中相关章节的链接。 - Lightness Races in Orbit
2
@curiousguy 标准确实在脚注中将其规定之一标记为“as-if rule”:“有时将此规定称为‘as-if’规则”[intro.execution] (https://timsong-cpp.github.io/cppwp/n3337/intro.execution#footnote-5)。 - Caleth
显示剩余12条评论

1
有时候,一个动作可以相对于其他两个动作序列被排序,而不意味着这些序列内的动作相对于彼此有任何相对顺序。
例如,假设有以下三个事件:
存储1到p1 将p2加载到temp中 存储2到p3
并且p2的读取是在写入p1之后和写入p3之前独立排序的,但是没有特定的顺序让p1和p3同时进行。根据对p2的处理方式,编译器可能无法推迟p1直到p3并仍然实现需要的p2语义。然而,假设编译器知道上述代码是更大序列的一部分:
将1存储到p2 [在加载p2之前排序] [执行上述操作] 将3存储到p1 [在另一个存储p1之后排序]
在这种情况下,它可以确定将p1的存储重新排序为上述代码之后,并与后续存储合并,从而导致编写p3的代码而不先写入p1。
  • 将temp设置为1
  • 将temp存储到p2
  • 将2存储到p3
  • 将3存储到p1

尽管数据依赖关系可能会使某些部分的顺序关系表现出传递性,但编译器可以识别出表面上不存在数据依赖关系的情况,并因此不会产生预期的传递效应。


1
如果有两个语句,编译器将按顺序生成代码,因此第一个语句的代码将放置在第二个语句之前。但是CPU内部具有管道并且能够并行运行汇编操作。语句C是加载指令。当正在获取内存时,管道将处理下几条指令,并且如果它们不依赖于加载指令,则可能在C完成之前执行它们(例如,D的数据在缓存中,C在主内存中)。
如果用户确实需要按顺序执行这两个语句,则可以使用更严格的内存排序操作。一般来说,只要程序在逻辑上正确,用户就不会关心。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接