tl;dr 摘要:不可能在单核上使用单指令inc [foo]。也许每个线程都在自己的核心上是可能的,但我认为只有通过超线程来通过导致加载/inc和存储之间的缓存驱逐来在存储上创建额外的延迟。
我认为即使是多套接字缓存一致性也不会慢到 B 的最终存储在 B 的最终加载后延迟 50k 周期,但超线程可能能够在它之前排队多个缓存/TLB 未命中。
在单核情况下:您对 B 在 A 的时间片结束之前不会运行 的假设不一定成立。中断(例如定时器中断或 NIC)可以在任何时候进入,在任何指令边界暂停用户空间线程的执行。也许在中断之后,更高优先级的进程会被唤醒并被调度到 CPU 上一段时间,因此调度程序没有理由更喜欢已经运行了一小部分时间片的线程。
但是,如果我们只讨论单核情况,并发只能通过上下文切换发生,inc [mem] 与mov reg, [mem] / inc reg / mov [mem], reg 有很大不同。不管 CPU 内部如何处理inc [mem],上下文切换只会保存/恢复架构状态。如果 load 和 inc 部分已经在内部完成,而不是 store,则整个指令不可能退出。上下文切换不会保存/恢复该进度:当线程再次开始执行并且 CPU 再次看到 inc [mem] 指令时,必须重新运行 load 和 inc。
如果测试使用了单独的 load/inc/store 指令,即使是单核机器理论上也可以按照 Michael Burr 指出的顺序得到2:
A loads 0 from _foo
B loops 9999 times (finally storing _foo = 9999)
A stores _foo = 1 (end of first iteration)
B's final iteration loads 1 from _foo
A loops 9999 times (eventually storing _foo = 10000)
B's final iteration stores _foo = 2
这是可能的,但需要由在特定时间到达的中断触发多次上下文切换。从导致调度程序抢占线程的中断到新线程的第一条指令实际运行时需要许多周期。可能有足够的时间让另一个中断到达。我们只是对它的可能性感兴趣,即使经过几天的试验也不太可能被观察到!
同样,对于inc [mem],这在单个内核上是不可能的,因为上下文切换只能在整个指令之后发生。 CPU 的架构状态要么执行了inc,要么没有。
在多核情况下,两个线程同时运行,情况就完全不同了。缓存一致性操作可能发生在单条指令解码成的微指令之间。所以inc [mem] 在这种情况下不是一个单一的操作。
对此我不确定,但我认为即使是单指令 inc [foo] 循环也有可能产生最终结果 2。中断/上下文切换不能解释加载和存储之间的延迟,但是,所以我们需要想出其他可能的原因。
- A 从
foo 加载 0
- B 循环 9999 次(最终存储 foo = 9999)。缓存行现在在 B 的 CPU 内核上处于
E 状态
- A 存储 _foo = 1(第一次迭代结束)。可以想象,这可以在超线程 CPU 上延迟这么长时间,因为另一个逻辑线程用大量积压的存储使存储端口饱和,这些存储在缓存和/或 TLB 中未命中,并且存储被缓冲了一段时间。这可能在没有超线程的情况下发生,并且有几个缓存未命中存储等待完成。请记住,在 x86 的强内存模型中,存储在程序顺序中变得全局可见,因此稍后存储到热缓存行仍然需要等待。及时完成 B 的最后一次迭代只是时间上的巧合,这很好。
- B 的最终迭代从 foo 加载 1。 (中断或其他事情可能会延迟 B 执行最终迭代。这不需要在单个指令的加载/增加/存储 uops 之间发生任何事情,所以我不需要弄清楚是否接收到一致性消息(来自 A) 的缓存行无效将阻止存储转发将 9999 值从上一次迭代的存储转发到本次迭代的负载。我不确定,但我认为它可以。)
- A 循环 9999 次(最终存储
_foo = 10000)
-
B 的最终迭代存储_foo = 2。 解释如何将这家商店延迟到 A 的循环完成之后似乎是最大的延伸。超线程可以做到这一点:另一个逻辑核心可以驱逐 _foo 的 TLB 条目,也可能驱逐包含该值的 L1 D$ 行。对于最终的inc 指令,这种驱逐可能发生在加载和存储微指令之间。我不确定一致性协议需要多长时间才能获得对当前由另一个核心拥有的缓存行的写访问权限。我确信它通常远少于 50k 周期,实际上少于 CPU 上的主内存访问,包括作为一致性流量支持的最后一级缓存(例如 Intel 的 Nehalem 和后来的设计)。具有多个套接字的非常多核系统可能会很慢,但我认为它们仍然使用环形总线来实现一致性流量。
我不确定 B 的最终存储是否有可能在没有超线程的情况下延迟 50k 周期来堆积一些存储端口争用并导致缓存驱逐。负载(必须看到 A 的 1 存储,但不能看到 A 的任何其他存储)在 OOO 调度程序中不能超过存储,因为它仍然必须在倒数第二次迭代的存储之后。 (核心必须在单个执行上下文中维护有序语义。)
由于只有一个内存位置在两个线程中被读取然后写入,因此不会对存储和加载进行任何重新排序。加载将始终看到来自同一线程的先前存储,因此只有在存储到同一位置之后才能全局可见。
在 x86 上,只有 StoreLoad reordering 是可能的,但在这种情况下,唯一重要的是,无序的机器可能会延迟存储很长时间,即使没有相对于任何负载对其进行重新排序。
您所指的原始博客文章总体上看起来不错,但我确实注意到至少一个错误。里面有很多不错的链接。
事实证明,在现代 x86 CPU 上,使用锁定来实现
并发原语是often cheaper than using memory
barriers
该链接仅表明使用lock add [mem], 0 作为障碍 在 Nehalem 上更便宜,尤其是。它可以更好地与其他指令交错。使用锁定与依赖于障碍的无锁算法没有什么可说的。如果您想原子地增加内存位置,那么到目前为止最简单的选择是locked 指令。如果可能的话,只使用MFENCE 将需要在没有原子 RMW 操作的情况下实现某种单独的锁。
显然他想介绍lock inc [mem] 与inc [mem] 的话题,只是对措辞不小心。在大多数情况下,他的概括效果更好。
示例代码也很奇怪,和往常一样用-O0makes quite nasty code编译。我修复了内联汇编以向编译器询问内存操作数,而不是手动编写incl (reg),因此通过优化,它会生成incl counter(%rip),而不是将指针加载到寄存器中。更重要的是,-O3 还避免将循环计数器保留在内存中,即使使用原始源也是如此。原始源代码上的-O3 似乎仍然可以生成正确的代码,即使它没有告诉编译器它写入内存。
无论如何,尽管实验存在缺陷,但我认为该实验仍然有效,而且使用 -O0 编译的巨大循环开销不太可能对最终计数器的最终范围进行人为限制。
Dan Luu 的示例 asm 语法是 Intel 和 AT&T 语法的奇怪组合:mov [_foo], %eax 是一个负担。它应该写成mov eax, [_foo],或者mov _foo, %eax,或者mov (_foo), %eax,如果你想明确它是一个负载而不是一个mov-immediate。无论如何,如果我还不知道他的意思并试图证明的话,我认为这会令人困惑。