当线程调度在不同的 CPU 内核上时,预期的内存语义(例如写入后读取)会发生什么情况



单个线程中的代码具有一定的内存保证,例如写入后读取(即将某些值写入内存位置,然后读回它应该给出您写入的值)。

如果将线程重新调度为在不同的 CPU 内核上执行,此类内存保证会发生什么情况?假设一个线程将 10 写入内存位置 X,然后重新调度到另一个内核。该内核的 L1 缓存可能具有不同的 X 值(与之前在该内核上执行的另一个线程的值不同),因此现在读取 X 不会像线程预期的那样返回 10。当线程调度在不同的内核上时,是否会发生一些 L1 缓存同步?

在这种情况下,所需要的只是在第一个处理器上执行的写入在进程开始在第二个处理器上执行之前变得全局可见。 在英特尔 64 位架构中,这是通过在操作系统用于将进程从一个内核传输到另一个内核的代码中包含一个或多个带有内存围栏语义的指令来实现的。Linux 内核中的一个例子:

/*
* Make previous memory operations globally visible before
* sending the IPI through x2apic wrmsr. We need a serializing instruction or
* mfence for this.
*/
static inline void x2apic_wrmsr_fence(void)
{
asm volatile("mfence" : : : "memory");
}

这可确保在执行将启动在新内核上运行的线程的处理器间中断之前,原始内核中的存储是全局可见的。

参考:英特尔架构软件开发人员手册第 3 卷第 8.2 和 8.3 节(文档 325384-071,2019 年 10 月)。

TL;DR:这取决于架构和操作系统。在 x86 上,这种类型的写后读危险大多不是必须在软件级别考虑的问题,除了弱序 WC 存储,它们要求在迁移线程之前在同一逻辑内核上的软件中执行存储围栏。


通常,线程迁移操作至少包括一个内存存储。考虑具有以下属性的体系结构:

内存
  • 模型使得内存存储可能无法按程序顺序全局观察。这篇维基百科文章有一个不准确但足够好的表格,显示了具有此属性的体系结构的示例(请参阅"商店可以在商店后重新排序"行)。

您提到的排序危险在此类体系结构上可能是可能的,因为即使线程迁移操作完成,也不一定意味着线程执行的所有存储都是全局可观察的。在具有严格顺序存储顺序的体系结构上,不会发生此危险。

在完全假设的架构中,可以在不执行单个内存存储的情况下迁移线程(例如,通过将线程的上下文直接传输到另一个内核),即使所有存储都是顺序的,也可能发生具有以下属性的体系结构:

  • 在商店退休和它变得可在全球范围内观察之间有一个"漏洞窗口"。例如,由于存在存储缓冲区和/或 MSHR,可能会发生这种情况。大多数现代处理器都具有此属性。

因此,即使使用顺序存储排序,在新核心上运行的线程也可能看不到最后 N 个存储。

请注意,在按顺序停用的计算机上,漏洞窗口是支持可能不连续存储的内存模型的必要条件,但不充分条件。

通常,使用以下两种方法之一将线程重新调度为在不同的内核上运行:

  • 发生硬件中断(如计时器中断)最终导致线程在不同的逻辑内核上重新调度。
  • 线程本身执行系统调用,例如sched_setaffinity,最终导致它在不同的内核上运行。

问题是,系统在什么时候保证退役商店在全球范围内可观察?在 Intel 和 AMD x86 处理器上,硬件中断是完全序列化事件,因此在执行中断处理程序之前,保证所有用户模式存储(包括可缓存和不可缓存)都是全局可观察的,其中线程可能会重新调度以运行不同的逻辑内核。

在英特尔和AMD x86处理器上,有多种方法可以执行系统调用(即更改权限级别),包括INTSYSCALLSYSENTER和远CALL。它们都不保证所有以前的商店都可在全球范围内观察。因此,操作系统在通过执行存储围栏操作在不同内核上调度线程时应显式执行此操作。这是将线程上下文(体系结构用户模式寄存器)保存到内存并将线程添加到与其他内核关联的队列的一部分。这些操作涉及至少一个受顺序订购保证约束的商店。当调度程序在目标内核上运行时,它将看到线程的完整寄存器和内存体系结构状态(在最后一个停用指令点)在该内核上可用。

在 x86 上,如果线程使用 WC 类型的存储,这不保证顺序排序,则在这种情况下,OS 可能无法保证它将使这些存储全局可观察。x86 规范明确指出,为了使 WC 存储全局可观察,必须使用存储围栏(在同一内核上的线程中,或者更简单的操作系统中)。操作系统通常应该这样做,如@JohnDMcCalpin的回答中所述。否则,如果操作系统不向软件线程提供程序顺序保证,则用户模式程序员可能需要考虑到这一点。一种方法如下:

  1. 保存当前 CPU 掩码的副本,并将线程固定到当前内核(或任何单个内核)。
  2. 执行弱序存储。
  3. 执行商店围栏。
  4. 恢复 CPU 掩码。

这会暂时禁用迁移,以确保存储围栏与弱序存储在同一核心上执行。执行存储围栏后,线程可以安全地迁移,而不会违反程序顺序。

请注意,用户模式睡眠指令(如UMWAIT)不会导致线程在不同的内核上重新调度,因为在这种情况下操作系统无法控制。


Linux 内核中的线程迁移

@JohnDMcCalpin答案中的代码片段落在发送处理器间中断的路径上,这是使用WRMSR指令向 APIC 寄存器实现的。发送库存绩效指标可能有多种原因。例如,执行 TLB 击落操作。在这种情况下,请务必确保更新的分页结构在其他内核上的 TLB 条目失效之前是全局可观察的。这就是为什么可能需要x2apic_wrmsr_fence,这是在发送 IPI 之前调用的。

也就是说,我认为线程迁移不需要发送 IPI。实质上,线程是通过从与一个内核关联的某个数据结构中删除线程并将其添加到与目标内核关联的数据结构来迁移的。迁移线程可能有多种原因,例如当相关性更改或调度程序决定重新平衡负载时。如 Linux 源代码中所述,源代码中线程迁移的所有路径最终都会执行以下操作:

stop_one_cpu(cpu_of(rq), migration_cpu_stop, &arg)

其中arg保存要迁移的任务和目标核心标识符。migration_cpu_stop是执行实际迁移的函数。但是,要迁移的任务可能当前正在运行或在某个运行队列中等待在源核心(即当前调度任务的核心)上运行。在迁移任务之前,需要停止任务。这是通过将函数migration_cpu_stop的调用添加到与源核心关联的阻止器任务的队列来实现的。 然后,stop_one_cpu将阻止器任务设置为准备执行。塞子任务具有最高优先级。因此,在源内核上的下一个计时器中断(可能与当前内核相同)时,将选择优先级最高的任务之一来运行。最终,stopper任务将运行并执行migration_cpu_stop,而 又执行迁移。由于此过程涉及硬件中断,因此保证目标任务的所有存储都是全局可观察的。


x2apic_wrmsr_fence中似乎存在错误

x2apic_wrmsr_fence的目的是在发送 IPI 之前使所有以前的商店在全球范围内可观察。如本线程中所述,SFENCE在这里是不够的。若要了解原因,请考虑以下顺序:

store
sfence
wrmsr

此处的存储围栏可以对前面的存储操作进行排序,但不能对 MSR 写入进行排序。在 x2APIC 模式下写入 APIC 寄存器时,WRMSR 指令没有任何序列化属性。英特尔 SDM 第 3 卷第 10.12.3 节中提到了这一点:

为了允许在x2APIC模式下高效访问APIC寄存器, WRMSR 的序列化语义在写入 APIC寄存器。

这里的问题是,MFENCE也不能保证订购与以前的商店相关的后期WRMSR。在英特尔处理器上,它被记录为仅订购内存操作。只有在AMD处理器上,它才能保证完全序列化。因此,要使其在英特尔处理器上运行,需要在MFENCE之后有一个LFENCE(SFENCE不是与LFENCE一起订购的,因此即使我们不需要订购负载,也必须使用MFENCE)。实际上,第 10.12.3 节提到了这一点。

如果一个平台要支持将线程从一个内核移动到另一个内核,那么无论移动什么代码都必须尊重线程可以依赖的任何保证。如果允许线程依赖于写入后的读取将看到更新值的保证,则无论将线程从一个内核迁移到另一个内核的任何代码都必须确保保留该保证。

其他一切都是特定于平台的。如果平台具有 L1 缓存,则硬件必须使该缓存完全一致,否则将需要某种形式的失效或刷新。在大多数典型的现代处理器上,硬件使缓存仅部分一致,因为还可以预取读取并发布写入。在 x86 CPU 上,特殊的硬件魔法解决了预取问题(如果 L1 缓存行失效,预取将失效)。我相信操作系统和/或调度程序必须专门刷新已发布的写入,但我不完全确定,它可能会根据确切的 CPU 而有所不同。

CPU付出了巨大的代价,以确保写入始终在同一指令流中看到以前的读取。对于操作系统来说,删除此保证并要求所有用户空间代码在没有它的情况下工作将是一个完全不可行的方法,因为用户空间代码无法知道它可能在其代码中的位置被迁移。

在这里添加我的两个位。乍一看,障碍似乎是矫枉过正(上面的答案)

考虑以下逻辑:当线程想要写入缓存行时,硬件缓存一致性启动,我们需要使系统中其他内核存在的缓存行的所有其他副本无效;如果没有失效,写入就不会继续。当线程被重新调度到不同的内核时,它必须从具有写入权限的 L1 缓存中获取缓存行,从而保持写后读取的顺序行为。

此逻辑的问题在于,来自内核的失效不会立即应用,因此可以在重新调度后读取过时的值(对新 L1 缓存的读取以某种方式击败了该核心队列中存在的待处理失效)。这对于不同的螺纹是可以的,因为它们可以滑动和滑动,但是对于相同的螺纹,屏障变得必不可少。

相关内容

最新更新