为啥内存重新排序在单核/处理器机器上不是问题?

Posted

技术标签:

【中文标题】为啥内存重新排序在单核/处理器机器上不是问题?【英文标题】:Why memory reordering is not a problem on single core/processor machines?为什么内存重新排序在单核/处理器机器上不是问题? 【发布时间】:2020-03-31 17:21:35 【问题描述】:

考虑以下取自 Wikipedia 的示例,稍作修改,其中程序的步骤对应于各个处理器指令:

x = 0;
f = 0;

Thread #1:
   while (f == 0);
   print x;

Thread #2: 
   x = 42;
   f = 1;

我知道当线程在两个不同的物理内核/处理器上运行时,由于无序执行,print 语句可能会打印不同的值(42 或 0)。

但是我不明白为什么这在单核机器上不是问题,因为这两个线程在同一个核心上运行(通过抢占)。根据Wikipedia:

当程序在单 CPU 机器上运行时,硬件会执行必要的簿记,以确保程序执行时就像所有内存操作都按照程序员指定的顺序(程序顺序)执行,因此内存屏障不会必要的。

据我所知,单核 CPU 也会重新排序内存访问(如果它们的内存模型很弱),那么如何确保程序顺序得以保留?

【问题讨论】:

“理解为什么这在单核机器上不是问题”是什么意思。你的意思是在单核中你总是一样吗?? 你能详细说明你的问题吗? @AkhilSurapuram 我已经更新了我的问题,谢谢:) 发生中断或模式更改时,推测执行停止,因此推测不会在其他线程中泄漏。 【参考方案1】:

CPU 不会意识到这是两个线程。线程是一种软件构造 (1)。

所以 CPU 按以下顺序看到这些指令:

store x = 42
store f = 1
test f == 0
jump if true ; not taken
load x

如果 CPU 将 x 的存储重新排序到最后,在加载之后,它会改变结果。虽然允许 CPU 乱序执行,但它只有在不改变结果时才会这样做。如果允许这样做,几乎每个指令序列都可能失败。不可能产生一个工作程序。

在这种情况下,不允许单个 CPU 在加载相同地址后重新排序存储。至少,就 CPU 可以看到它没有重新排序。就 L1、L2、L3 缓存和主内存(以及其他 CPU!)而言,可能尚未提交存储。

(1) 诸如 HyperThreads 之类的东西,每个核心两个线程,在现代 CPU 中很常见,不会被视为 w.r.t 的“单 CPU”。你的问题。

【讨论】:

CPU 不知道“线程”,但它肯定知道“中断”和“主管模式”。 @curiousguy:是的,但是 CPU 仍然需要保持在单个内核上顺序执行的错觉,即使执行的指令序列包括一些属于中断处理程序的指令,这些指令在跳转回用户空间之前的主管模式。它知道它们是什么,但它们并不特别。要求保持单核顺序执行的错觉。上下文切换只是将一些寄存器存储到内存并加载新值(包括堆栈指针)。【参考方案2】:

CPU 不知道也不关心“上下文切换”或软件线程。它所看到的只是一些存储和加载指令。 (例如,在操作系统的上下文切换代码中,它保存旧的寄存器状态并加载新的寄存器状态)

无序执行的基本规则是它不能破坏单个指令流。代码必须运行就像按程序顺序执行的每条指令,并且它的所有副作用都在下一条指令开始之前完成。这包括单个内核上线程之间的软件上下文切换。例如进程中的单核机器或绿色线程。

(通常我们将这条规则声明为不会破坏单线程代码,但要了解这究竟意味着什么;只有当 SMP 系统从其他内核存储的内存位置加载时才会发生怪异)。

据我所知,单核 CPU 也会重新排序内存访问(如果它们的内存模型很弱)

但请记住,其他线程并不是直接用逻辑分析器观察内存,它们只是在同一个 CPU 内核上运行加载指令并跟踪重新排序。

如果您正在编写设备驱动程序,是的,您可能必须在存储之后实际使用内存屏障以确保它实际上是可见的关闭-芯片硬件在从另一个 MMIO 位置加载之前。

或者在与 DMA 交互时,确保数据实际上是在内存中,而不是在 CPU 专用回写缓存中,这可能是个问题。此外,MMIO 通常在意味着强内存排序的不可缓存内存区域中完成。 (x86 具有缓存一致的 DMA,因此您不必实际刷新回 DRAM,只需使用 x86 mfence 之类的指令确保其全局可见,等待存储缓冲区耗尽。但是一些非 x86 操作系统从一开始就设计了缓存控制指令确实需要操作系统意识到这一点。即确保缓存在从磁盘读取新内容之前无效,并确保至少将其写回DMA之前可以读取的位置要求设备读取页面。)

顺便说一句,即使是 x86 的“强”内存模型也只是 acq/rel,而不是 seq_cst(RMW 操作除外,它们是完全障碍)。 (或者更具体地说,a store buffer with store forwarding on top of sequential consistency)。存储可以延迟到稍后加载之后。 (StoreLoad 重新排序)。见https://preshing.com/20120930/weak-vs-strong-memory-models/

那么是什么确保节目顺序得以保留?

硬件依赖跟踪; 加载窥探存储缓冲区以查找来自最近存储到的位置的加载。这可以确保加载将数据从最后一个程序顺序写入任何给定的内存位置1

没有这个,代码就像

  x = 1;
  int tmp = x;

可能会为 x 加载一个陈旧的值。如果您必须在每次 您自己的商店重新加载后设置内存屏障以可靠地查看存储的值,那将是疯狂且无法使用(并且会降低性能)。

根据 ISA 规则,我们需要所有指令在单个内核上运行,以产生按程序顺序运行的错觉。只有 DMA 或其他 CPU 内核可以观察到重新排序。


脚注 1:如果旧存储的地址尚不可用,CPU 甚至可能推测它将位于不同的地址并改为从缓存加载等待存储指令的存储数据部分执行。如果它猜错了,它将不得不回滚到一个已知的良好状态,就像分支预测错误一样。 这称为"memory disambiguation"。另请参阅Store-to-Load Forwarding and Memory Disambiguation in x86 Processors 以获取技术方面的了解,包括从更广泛存储的一部分重新加载的情况,包括未对齐和可能跨越缓存行边界...

【讨论】:

@curiousguy:在 C++11 中有一些情况,mo_acq_rel 表示 acq 表示加载,rel 表示存储。例如作为 CAS_weak 的 arg,acq_rel 表示故障的仅加载部分具有 acq 语义。这就是我写它时的想法,但公平地说,我的解释依赖于商店不读取-获取的隐含知识。将其更改为“acq/rel”并指出 RMW 操作很强大。 cppreference 没有提到 acq_rel 分解为纯加载或纯存储的单独订单,因此再次没有帮助。 @DanielNitzan:真正的细粒度SMT 硬件,如英特尔超线程/AMD/POWER/Alpha EV8,可以同时运行来自多个硬件线程的微指令,ROB(分区)/RS(在英特尔 IIRC 上竞争共享),但他们从未看到彼此的投机状态;否则,分支错误预测或其他错误推测将需要回滚整个内核,这违背了 SMT 保持硬件忙碌而一个线程从诸如分支未命中等停顿中恢复的目的。 @DanielNitzan:当然,在内核中,实际的上下文切换只是一个存储一些寄存器值并加载一些新值的函数,包括一个新的堆栈指​​针,这意味着ret 将弹出来自新线程的返回地址。新的用户空间上下文仅作为返回用户空间的一部分恢复,是的,内存屏障(隐式或显式)是必要的,以确保在一个内核上挂起线程并在另一个内核上恢复相同的线程让线程查看所有自己最近的商店!至少释放/获取同步。 @DanielNitzan:在现代操作系统下,调试器只能通过系统调用对另一个进程进行操作。单步执行或到达断点必须以某种方式与另一个线程通信,并且在多核机器上是的,这需要发布存储。 (并在 ptrace 系统调用的实现的内核端获取负载等待发生。) @DanielNitzan:但是如果一切都发生在同一个(逻辑)核心上,就像这个问答一样,那么即使单步/断点中断没有完全序列化,调试器也在运行与正在调试的代码相同的核心。所以调试器的负载会看到 all 以前的存储,因为其他任何东西都会违反 CPU 架构的基本规则,即在下一个开始,按执行顺序进入中断处理程序等。

以上是关于为啥内存重新排序在单核/处理器机器上不是问题?的主要内容,如果未能解决你的问题,请参考以下文章

海量数据处理:经典实例分析

Golang面向并发的内存模型

Golang面向并发的内存模型

Golang面向并发的内存模型

单核中的多线程与异步编程

重排序