在单处理器上,内存重排序对其他线程是否可见?

22

现代CPU架构通常采用可以导致乱序执行的性能优化。在单线程应用程序中,可能会发生内存重排序,但对程序员来说,这是不可见的,就好像内存是按程序顺序访问的一样。而对于SMP,内存屏障则用于强制执行某种内存顺序。

我不确定的是,在单处理器的情况下多线程如何实现。考虑以下示例:当线程1运行时,对f的存储可以在对x的存储之前发生。假设在写入f后、在写入x之前发生了上下文切换。现在线程2开始运行,它结束循环并打印0,这显然是不可取的。

// Both x, f are initialized w/ 0.
// Thread 1
x = 42;
f = 1;

// Thread 2
while (f == 0)
  ;
print x;

上述情况是否可能发生?或者在线程上下文切换期间是否有保证物理内存已经被提交?

根据这个 维基百科 页面,

当程序在单CPU机器上运行时,硬件会执行必要的簿记以确保程序执行的顺序与程序员指定的内存操作顺序(程序顺序)相同,因此不需要内存屏障。

虽然它没有明确提到单处理器多线程应用程序,但包括了这种情况。

我不确定它是否正确/完整。请注意,这很大程度上取决于硬件(弱/强内存模型)。因此,您可能希望在答案中包含您所知道的硬件信息。谢谢。

PS.设备I/O等不是我关心的问题。而且这是一个单核单处理器。

编辑: 感谢Nitsan的提醒,我们假设这里没有编译器重新排序(只有硬件重新排序),线程2中的循环没有被优化掉..再次强调,魔鬼在细节中。


如果你禁止(C++)编译器的重新排序,你想知道“编译后的代码(=机器码)在特定的机器架构上如何运行”吗?你应该指定目标平台(机器架构)并删除C++标签。 - yohjp
那么硬件指令重排序不是与内存操作(即需要缓存一致性的问题,这不是您场景的一部分)不同的问题吗?即使有强大的内存模型,仍然需要内存屏障来防止指令重排序。 - Chris O
@yohjp,如果你坚持的话,你可以将f设置为volatile,这样循环条件就不会被优化掉;) 现在,问题(我想要关注的问题)仍然存在,对吧? - Eric Z
4
如果我理解正确,您只是指正在CPU内部执行的内存访问重排序吗?如果是这样,我不相信上下文切换可以发生在已经在CPU流水线中的指令之间?在这种情况下,我相信流水线将被清空,所有指令将被提交或回滚,从而使得无序的内存访问变得不可能被看到。 - skoy
@skoy,是的,那很有道理。只是找不到任何支持它的参考资料。 - Eric Z
显示剩余4条评论
8个回答

20
作为一个C++问题,答案必须是程序包含数据竞争,因此行为是未定义的。实际上这意味着它可能打印出42以外的内容。
这与底层硬件无关。正如已经指出的那样,循环可以被优化掉,编译器可以重新安排线程1中的赋值顺序,因此即使在单处理器机器上也可能发生这种情况。
现在你说,你想假设编译器重新排序或循环消除不会发生。这样一来,我们就已经离开了C++的领域,而是真正地询问相关机器指令。如果你想消除编译器重新排序,我们可能还可以排除任何形式的SIMD指令,并只考虑一次操作单个内存位置的指令。
因此,本质上,线程1有两个按store到x, store到f的指令,而线程2有test-f-and-loop-if-not-zero(这可能是多个指令,但涉及load-from-f),然后是从x中读取的指令。
在我所知道的或可以合理想象的任何硬件架构上,线程2将打印出42。
原因之一是,如果由单个处理器处理的指令在自己之间不是顺序一致的,你几乎无法断言程序的影响。
这里唯一可能干扰的事件是中断(用于触发抢占式上下文切换)。一台假想机器,当中断发生时会存储其当前执行管道状态的整个状态,并在从中断返回时恢复它,可以产生不同的结果,但这样的机器并不实用,据我所知也不存在。这些操作将创建相当多的附加复杂性和/或需要额外的冗余缓冲区或寄存器,所有这些都是没有好理由的 - 除了破坏你的程序。真正的处理器要么在中断时刷新或回滚当前管道,这足以为单个硬件线程上的所有指令保证顺序一致。

而且不用担心内存模型问题。较弱的内存模型源于单独的缓冲区和缓存,这些缓冲区和缓存将独立的硬件处理器与实际共享的主内存或第n级缓存隔开。单个处理器没有类似分区资源,并且没有为多个(纯软件)线程设置它们的充分理由。再次强调,如果没有单独的处理资源(处理器/硬件线程)来保持这些资源繁忙,就没有必要让处理器和/或内存子系统意识到像独立的线程上下文这样的东西,以使体系结构变得复杂并浪费资源。


他只是问线程2是否会打印0。答案很明确,不会。我很困惑,你先说“事实上这意味着它可能会打印除42以外的其他内容”,然后又说“在我知道或可以合理想象的任何硬件架构上,线程2将打印42”。那么到底是现实还是想象? - user1252446
@arrows:请注意,on声明基于更具体的一组假设,而另一个纯粹基于高级C++语言水平,其中这是UB并且可能导致您所有人同时怀孕仓鼠。 - PlasmaHH
按照 C++ 的规则,它可以打印任何东西或者做那只仓鼠的事情。 - JoergB
根据C++的规则,它可以打印任何内容或执行那个仓鼠的操作。使用真正的C++编译器,即使在单处理器(具有中断驱动的抢占式多线程)上,由于编译器重新排序,它也可能打印0(如果x的初始值为0)。根据我们被告知要做出的假设(没有编译器重新排序,单处理器),"……将打印42" 的声明是正确的。 - JoergB

5
强内存序是指执行内存访问指令时按程序定义的确切顺序进行,通常称为“程序排序”。
较弱的内存序可以使处理器重新排列内存访问以获得更好的性能,通常称为“处理器排序”。
据我所知,在英特尔ia32架构中,上述情况不可能发生,其处理器排序禁止这种情况的出现。相关规则如下(参见intel ia-32软件开发手册Vol3A 8.2内存排序):
写入与其他写入不会重新排序,除了流式存储、CLFLUSH和字符串操作。
为了说明规则,它给出了一个类似于以下示例的示例:
内存位置x,y初始化为0;
线程1:
mov [x] 1
mov [y] 1

线程 2:

mov r1 [y]
mov r2 [x]

r1 == 1 and r2 == 0 不被允许。

在你的例子中,线程1在存储x之前不能存储f。

回应@Eric的评论。

快速字符串存储指令"stosd"可能会在其操作中无序地存储字符串。在多处理器环境中,当一个处理器存储一个字符串"str"时,另一个处理器可能观察到str[1]在str[0]之前被写入,而逻辑顺序被认为是在写入str[0]之前写入str[1];

但这些指令不会与任何其他存储器重新排序,并且必须具有精确的异常处理。当stosd中间发生异常时,实现可以选择延迟它,以便所有无序子存储(不一定是整个stosd指令)在上下文切换之前必须提交。

编辑以回应对此问题是否为C++问题的声明:

即使考虑到C++的上下文,据我所知,符合标准的编译器不应该重新排列线程1中x和f的赋值。

$1.9.14 与完整表达式相关联的每个值计算和副作用都要评估的下一个完整表达式相关联的每个值计算和副作用之前被排序


+1,没错。如果您能举出一些弱内存模型的例子,在线程上下文切换之前交换STOREs并提交所有内存操作,那就更好了,就像@skoy上面提到的那样。 - Eric Z
可能这是Itanium的做法,但绝对是一种弱内存模型。 - Chris O

2
这不是一个关于C或C ++的问题,因为您明确假设没有加载/存储重排序,而这两种语言的编译器完全可以这样做。
为了论证这个假设,需要注意除非您:
- 给编译器一些理由相信 `f` 可能会改变(例如,通过将其地址传递给某个无法内联的函数,该函数可能会修改它); - 将其标记为 `volatile`,或者 - 使其成为显式原子类型并请求获取语义。
在硬件方面,您担心在上下文切换期间物理内存在“提交”时不是问题。两个软件线程共享同一内存硬件和缓存,因此无论哪个一致性/协议适用于核之间,都不存在不一致的风险。
假设发出了两个存储器,内存硬件决定重新排序它们。这真的意味着什么?也许 `f` 的地址已经在缓存中,因此它可以立即被写入,但是 `x` 的存储器则要等待该缓存行被提取。好吧,从 `x` 中读取是依赖于相同的地址,因此:
- 加载不能发生直到提取发生,此时明智的实现必须在排队的存储器之前发出排队的加载。 - 或者,加载可以窥视队列并获取 `x` 的值,而不必等待写入。
请考虑内核抢占所需的装载/存储屏障以确保内核调度程序状态的一致性,应该很明显,在这种情况下,硬件重排序不可能是问题。
真正的问题(您试图回避)是您假设没有编译器重排序:这是错误的。

你并没有真正阅读我的问题,是吗?在您发布任何答案之前,请注意我在此线程中所做的评论。 - Eric Z
我认为我已经分别解决了你的(无效的)编译器假设和硬件级别的重新排序问题。你是指其他的事情吗? - Useless
正如我在帖子评论中所说的那样,我并不是说编译器优化不存在,也不是主观地忽略它们。相反,我试图通过简化问题,仅关注硬件重排序来达到重点。如果您愿意,可以使用各种方法(例如volatile)来确保编译器优化不会超出您的期望(例如优化掉for循环)。您关于硬件重排序的观点是有道理的,+1。 - Eric Z

2
你只需要一个编译器屏障。来自Linux内核文档中的“内存屏障”(link):
SMP内存屏障在单处理器编译系统上被简化为编译器屏障,因为假定CPU将表现为自洽的,并将正确地对其自身重叠的访问进行排序。
进一步解释原因为什么硬件级别上不需要同步是因为:
1. 在单处理器系统上,所有线程共享同一内存,因此不存在SMP系统中可能发生的缓存一致性问题(如传播延迟)。 2. 如果由于抢占式上下文切换而刷新了流水线,则CPU执行流水线中的任何乱序加载/存储指令都将被完全提交或回滚。

1

这段代码(在线程2中)可能永远不会完成,因为编译器可以决定将整个表达式提升出循环(这类似于使用非易失性的isRunning标志)。 也就是说,您需要担心两种类型的重新排序:编译器和CPU,两者都可以自由地移动存储。请参见此处:http://preshing.com/20120515/memory-reordering-caught-in-the-act 以获取示例。此时,上述描述的代码取决于编译器、编译器标志和特定架构。所引用的维基百科是误导性的,因为它可能暗示内部重新排序不受CPU/编译器支配,但事实并非如此。


假设编译器重排序不会发生,以便专注于硬件重排序。我刚刚编辑了我的帖子。 - Eric Z

1
就x86而言,乱序存储是从执行代码的角度与程序流程一致的。在这种情况下,“程序流程”只是处理器执行指令的流程,而不是限制于“在线程中运行的程序”。所有必要的上下文切换指令等都被视为此流程的一部分,因此跨线程维护一致性。

我不同意这种推理,因为在多处理器机器上,你关于线程间一致性的陈述显然是错误的。 - usr

0

从我的角度来看,处理器逐条获取指令。 在你的情况下,如果“f = 1”在“x = 42”之前被猜测执行,那么这两个指令都已经在处理器的流水线中了。当前线程被调度出去的唯一可能方式是中断。但处理器(至少在X86上)将在服务中断之前清除流水线指令。 因此,在单处理器中不必担心重新排序。


0

上下文切换必须存储完整的机器状态,以便在挂起的线程恢复执行之前可以还原。机器状态包括处理器寄存器但不包括处理器流水线。

如果您假定没有编译器重新排序,这意味着所有正在进行的硬件指令都必须在上下文切换(即中断)发生之前完成,否则它们会丢失,并且不会被上下文切换机制保存。这与硬件重新排序无关。

在您的示例中,即使处理器交换了两个硬件指令“x=42”和“f=1”,指令指针已经在第二个指令之后,因此在上下文切换开始之前,必须完成两个指令。如果不是这样,由于管道和缓存的内容不属于“上下文”,它们将会丢失。

换句话说,如果导致ctx切换的中断发生时IP寄存器指向“f=1”后面的指令,则该点之前的所有指令都必须完成其所有作用。


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