x86上的竞态条件问题

24

能否有人解释一下这个语句:

shared variables
x = 0, y = 0

Core 1       Core 2
x = 1;       y = 1;
r1 = y;      r2 = x;

在x86处理器上,怎么可能同时使r1 == 0r2 == 0呢?

来源:Bartosz Milewski的“并发语言”视频


https://preshing.com/20120515/memory-reordering-caught-in-the-act/ 使用汇编版本的示例来演示内存重排序,包括使用和不使用 mfence 的情况,并解释了为什么允许发生这种情况。在 C/C++ 中,如果它们不是原子性的,则只是普通的未定义行为;如果它们是原子性的,则只能使用 memory_order_release 或更弱的方式进行重排序。 - Peter Cordes
不幸的是,[tag: memory-order] 标签是 [tag: memory-barriers] 的同义词,因此关于内存排序的这个问题只能使用一个标签来防止重排序。(至少对于足够强的内存屏障……) - Peter Cordes
3个回答

28
The problem can arise due to optimizations involving 指令重排. In other words, both processors can assign r1 and r2 before assigning variables x and y, if they find that this would yield better performance. This can be solved by adding a 内存屏障, which would enforce the ordering constraint.
To quote the 幻灯片 you mentioned in your post:

现代多核 / 语言破坏了顺序一致性

Regarding the x86 architecture, the best resource to read is Intel® 64 和 IA-32 架构软件开发人员手册 (Chapter 8.2 Memory Ordering). Sections 8.2.1 and 8.2.2 describe the memory-ordering implemented by Intel486, Pentium, Intel Core 2 Duo, Intel Atom, Intel Core Duo, Pentium 4, Intel Xeon, and P6 family processors: a memory model called processor ordering, as opposed to program ordering (strong ordering) of the older Intel386 architecture (where read and write instructions were always issued in the order they appeared in the instruction stream).
该手册描述了处理器排序内存模型的许多排序保证(例如“加载不与其他加载重新排序”,“存储不与其他存储重新排序”,“存储不与旧加载重新排序”等),但它还描述了允许重新排序规则,这导致了OP帖子中的竞争条件:
8.2.3.4 加载可以与先前存储到不同位置的指令重新排序
另一方面,如果指令的原始顺序被交换:
shared variables
x = 0, y = 0

Core 1       Core 2
r1 = y;      r2 = x;
x = 1;       y = 1;

在这种情况下,处理器保证不允许r1 = 1r2 = 1的情况发生(由于8.2.3.3存储不会与早期加载重新排序的保证),这意味着这些指令永远不会在单个核心中重新排序。
要将其与不同的体系结构进行比较,请查看此文章:现代微处理器中的内存排序。您可以看到Itanium(IA-64)比IA-32体系结构做更多的重新排序:

Possible CPU reorderings for various architectures


可以做到。因此,第一步操作是将变量更改为常量。第二步是获取变量的值并设置为r[x]。因此,可能会尝试在执行第一个操作的同时获取变量值。 - Ation
编译器也可能是罪魁祸首,参见“How to Miscompile Programs with "Benign" Data Races” - http://www.usenix.org/events/hotpar11/tech/final_files/Boehm.pdf。 - Brian
1
@Brian:虽然很有趣,但那篇论文中的许多示例对于C/POSIX来说是错误的(编译器无法进行有效的转换),特别是如果省略的代码包含对sigprocmask的调用时,由于信号处理语义而出现问题。 - R.. GitHub STOP HELPING ICE

3
在具有较弱内存一致性模型(如SPARC、PowerPC、Itanium、ARM等)的处理器上,由于缺乏显式内存屏障指令对写操作执行缓存一致性,因此上述情况可能会发生。因此,基本上Core1y之前看到x的写入,而Core2x之前看到y的写入。在这种情况下不需要完整的屏障指令... 基本上,只需要强制执行写入或释放语义以确保在读取已写入的变量之前将所有写入提交并显示给所有处理器即可。像x86这样具有强内存一致性模型的处理器体系结构通常不需要这样做,但正如Groo所指出的那样,编译器本身也可以重新排序操作。您可以在C和C++中使用volatile关键字来防止编译器在给定线程内重新排序操作。这并不是说volatile将创建管理线程间读写可见性的线程安全代码...需要使用内存屏障。因此,虽然使用volatile仍然可能创建不安全的线程化代码,但在给定线程中,它将在编译的机器代码级别上强制执行顺序一致性。

CPU可以重新排序指令(同时保持与新顺序相关的高速缓存一致性)。 - Alexandre C.
1
Volatile关键字不能防止重排序,除非在旧编译器中使用"volatile"作为关键字来完全禁用优化器。您需要一个内存屏障指令。 - dascandy
3
如果volatile不能防止编译器在同一线程内重新排序,那么在内存映射的I/O中使用它就毫无意义...编译器仍然会重新排序读写操作,导致各种未定义的硬件行为。 volatile关键字不能防止CPU指令重排,也不能强制保证线程之间的读写可见性...这需要一个内存屏障...但是它确实可以防止编译器在给定的线程中重新排序源代码中定义的操作。 - Jason

2

这就是为什么有些人说:“线程是有害的

问题在于,两个语句之间没有任何顺序限制,因为它们不相互依赖。

  • 编译器知道xy没有别名,所以不需要对操作进行排序。

  • CPU知道xy没有别名,因此可以为了提高速度而重新排序。当CPU检测到写合并的机会时,很好的例子就是它可以将一个写与另一个写合并,如果它能够这样做而不违反一致性模型。

相互依赖看起来很奇怪,但实际上与任何其他竞态条件没有区别。直接编写共享内存线程代码非常困难,这就是为什么开发了并行语言和消息传递并行框架的原因,以便将并行危害隔离到小内核中,并将危害从应用程序本身中移除。


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