【问题标题】:What happens to expected memory semantics (such as read after write) when a thread is scheduled on a different CPU core?当线程被调度在不同的 CPU 内核上时,预期的内存语义(例如写后读)会发生什么情况?
【发布时间】:2020-05-21 16:53:01
【问题描述】:

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

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

【问题讨论】:

  • 我想用memory-order标记这个,但是这个标记目前被认为是memory-barriers的同义词,这很混乱。

标签: multithreading operating-system cpu-architecture cpu-cache memory-barriers


【解决方案1】:

在这种情况下,所需要的只是在进程开始在第二个处理器上执行之前,在第一个处理器上执行的写入变得全局可见。在 Intel 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 月)。

【讨论】:

    【解决方案2】:

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


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

    • 内存模型使得内存存储可能无法按程序顺序全局观察。 This Wikipedia article 有一个不够准确但足够好的表格,其中显示了具有此属性的架构示例(请参阅“商店可以在商店之后重新排序”行)。

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

    在完全假设的架构上,可以在不进行单个内存存储的情况下迁移线程(例如,通过直接将线程的上下文转移到另一个内核),即使所有存储在具有以下属性:

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

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

    请注意,在按顺序退休的机器上,漏洞窗口是支持可能不按顺序存储的内存模型的必要条件但不充分条件。

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

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

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

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

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

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

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

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


    Linux 内核中的线程迁移

    @JohnDMcCalpin 的答案中的代码 sn-p 落在发送处理器间中断的路径上,这是使用 WRMSR 指令到 APIC 寄存器来实现的。可能出于多种原因发送 IPI。例如,执行 TLB 击落操作。在这种情况下,重要的是要确保更新的分页结构在使其他内核上的 TLB 条目无效之前是全局可观察的。这就是为什么可能需要x2apic_wrmsr_fence 的原因,它会在发送 IPI 之前被调用。

    也就是说,我认为线程迁移不需要发送 IPI。本质上,通过将线程从与一个核心相关联的某个数据结构中移除并将其添加到与目标核心相关联的数据结构中来迁移线程。迁移线程可能有多种原因,例如亲缘关系发生变化或调度程序决定重新平衡负载时。正如Linux source code中提到的,源码中所有线程迁移的路径最终都会执行如下:

    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 之前使所有以前的存储全局可观察。正如this 线程中所讨论的,SFENCE 在这里是不够的。要了解原因,请考虑以下顺序:

    store
    sfence
    wrmsr
    

    这里的 store fence 可以命令前面的 store 操作,但不能命令 MSR write。在 x2APIC 模式下写入 APIC 寄存器时,WRMSR 指令没有任何序列化属性。这在英特尔 SDM 第 3 卷第 10.12.3 节中有所提及:

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

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

    【讨论】:

    • @HadiBrais 查看我的回答。如果一个线程保证读取会看到以前的存储,那么任何迁移线程的东西都必须保留这个保证。在抢占式多任务操作系统中将这种负担放在用户空间代码上是荒谬的,因为该代码无法知道它可能会在哪里切换。不能保证在调度程序(或操作系统的其他地方)中是一个完整的非启动程序。 (这也是荒谬的低效。CPU 为提供这种保证付出了巨大的代价。对于操作系统来说,如果没有获得很大的收益而将其删除,那将是完全弄巧成拙。)
    • 中断 触发的上下文切换肯定必须尊重 NT 存储的重新加载,因为这可能会异步发生。例如movnt / migrate / sfence 在旧 => 灾难中离开 NT 商店。 @DavidSchwartz:我也不相信 Hadi 的论点,即 NT 存储和在同一线程中重新加载之间的 syscall 可以允许在单个线程中破坏程序顺序,但 东西一个线程可以避免。上下文切换,即使由系统调用触发,也不得破坏该线程对它自己的操作的程序顺序可见性。那就是疯狂。
    • 我看不出 x86 规范的哪一部分保证 movntps [mem], xmm0 在任何给定时间都可以从另一个内核观察到。 但它 保证进行 NT 存储的线程可以立即看到它,就像任何其他存储一样。缺乏可见性保证正是问题所在;即使重新加载自己的 NT 存储,也不能允许迁移破坏单个线程的程序顺序。我的示例是一个 single 线程(愚蠢地)进行 NT 存储并立即重新加载。 (在 x86 上,只有 NT 存储是一个问题,假设内核中其他状态的普通 mov acq/rel。)
    • @PeterCordes 我最初认为线程如果想要获得该保证必须使用存储围栏,但经过仔细考虑,大多数操作系统应该提供程序顺序保证,尽管线程迁移。我认为这就是我错的地方,与你和大卫的讨论帮助我更仔细地思考它。我已经编辑了我的答案以改进这部分。如果还有什么我遗漏的,请告诉我。
    • @PeterCordes 哦,我认为我的其他答案的一部分(引用了您的一个答案)是错误的。英特尔手册 V3 的第 11.10 节说,当发生中断时,存储缓冲区会被耗尽。这同样适用于 WC 缓冲区和 AMD。嗯,但它们是否完全序列化?我得去买点吃的,待会再考虑:)
    【解决方案3】:

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

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

    CPU 付出了巨大的代价来确保写入始终会在同一指令流中看到先前的读取。对于一个操作系统来说,取消这个保证并要求所有用户空间代码在没有它的情况下工作将是一个完整的非首发,因为用户空间代码无法知道它可能会在其代码中迁移到哪里。

    【讨论】:

    • 预取或后写如何使缓存部分一致?我不确定你所说的部分连贯是什么意思。
    • @HadiBrais:David 似乎在使用“预取”来描述加载的 OoO exec,在程序顺序之前从 L1d 缓存中读取。这不是技术术语“预取”的正常用法;相反,它被称为加载加载重新排序或命中未命中。而“posted writes”是他描述存储缓冲区的方式。这些都不会使 cache 与其他内核不一致,但它会使 execution 与缓存分离,并在一致的缓存之上引入内存重新排序。 (“不连贯”有特定的含义,我不认为这真的是正确的。)
    • 很好的尝试回答一般情况,包括非缓存一致的多处理器。没有人 (AFAIK) 透明地跨内核运行同一进程的多个线程,具有非一致的缓存,但将进程迁移到另一个一致性域当然是可能的。
    • re:刷新存储缓冲区:内核大概希望在内核之间获取/释放同步以重新加载架构状态。当您对某些不遵守正常 acq/rel 机制的存储(如 x86 的 NT 存储)有不同的内存排序规则时,事情只会变得复杂。因此,mfence,或者只是在正常发布存储之前的 sfence,即任务不再在此核心上“运行”,因此可以由其他核心上的调度程序获取。 (调度是一种分布式算法:您通常不会从字面上将任务“发送”到另一个核心。)
    • @HadiBrais “部分一致”是指虽然硬件提供了缓存一致性,但由于其他硬件优化,缓存不一定从线程的角度来看是一致的,例如乱序加载和存储。从指令流的角度来看,我们不关心硬件问题是什么,无论是缓冲、缓存还是其他什么,我们关心的只是我们观察到的。即使在硬件中保证了缓存一致性,我们仍然可以看到在硬件中不连贯时会看到的相同效果。
    【解决方案4】:

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

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

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

    【讨论】:

    • 缓存本身是始终一致的。在收到对其无效或 RFO(读取所有权)行的确认之前,核心无法提交新值。这就是 MESI 保持连贯性的方式。 en.wikipedia.org/wiki/MESI_protocol。问题是存储缓冲区:如果存储仍然位于存储缓冲区中,核心可能甚至还没有执行 RFO 来获得该行的独占所有权,因此其他核心仍然可以将其缓存在其他状态。这就是迁移没有完全屏障的线程可能无法尊重程序顺序 RAW 依赖关系的原因。
    • (如果没有迁移,该挂起的存储将通过存储转发“看到”。核心可以在它们变得全局可见之前看到它自己的存储。)
    • 使用拆分事务总线时,总线控制器会发出无效命令,但实际上不会使高速缓存行无效。因此,如果 P1 发出写入,它将接收所有无效,但 P2 仍有可能从其缓存中读取旧副本,因为无效(来自总线控制器)尚未应用。这没关系,因为允许线程滑动和滑动(就好像 P2 在发出无效之前很久就读取了它的值)
    • 我没有明白你在答案的第一段中想说什么。无论如何,缓存一致性的细节在这里并不重要,因为这些细节只会影响使存储全局可观察所需的时间。我已经更新了我的答案,以讨论可能发生此类 RAW 危害的必要条件。
    • 如果连贯转换立即发生,我们就不需要障碍。例如,在具有原子总线且没有存储缓冲区的系统中,当 P1 想要写入缓存线时,所有其他内核必须使它们的缓存线无效。因此,当您将线程重新调度到不同的内核时,新内核中的 L1 缓存必须从旧内核获取缓存线。在实践中,相干转换不会立即注册,因此需要一个屏障。
    猜你喜欢
    • 2018-07-26
    • 2021-08-31
    • 2011-09-03
    • 1970-01-01
    • 1970-01-01
    • 2012-09-06
    • 2012-05-30
    • 2019-04-08
    • 2020-01-30
    相关资源
    最近更新 更多