在这种情况下,任何合理的CPU实现都能给出foo = 2吗?

时间:2016-01-11 07:43:24

标签: assembly concurrency operating-system race-condition

阅读Dan Luu撰写的一篇非常有趣的blog post关于x86架构在过去几十年中取得的进展,他说:

  

如果我们将_foo设置为0并且有两个线程同时执行incl (_foo) 10000次,则使用单个指令递增相同位置20000次,保证不会超过20000,但它可以(理论上)低至2.如果不明显为什么理论最小值为2而不是10000,那么认为这是一个很好的练习。

其中_foo是一些内存地址。

显然这是因为(正如他所说的更远)incl被实现为一个加载,然后是一个add,后跟一个商店。所以如果你" desugar"进入:

mov reg, _foo ;; #1
inc reg       ;; #2
mov _foo, reg ;; #3

然后,以下u-ops排序产生_foo = 2

Thread A executes #1, #2
Thread B executes #1, #2
Thread A executes #3
Thread B executes #3
Thread A executes #1, #2.... etc

(我可能会在这里稍微讨论汇编程序的细节,但据我所知,这是对_foo = 2的情况的一个相当准确的描述。)

我想知道的是他的下一次练习:"

  

[M]你的奖金练习是,任何合理的CPU实现都可以得到那个结果,还是规范允许永远不会发生的一些愚蠢的事情?这篇文章中没有足够的信息来回答奖金问题...

可以吗?我的直觉是不,因为我相信当A执行#3时,则:

  • A和B在同一个CPU上。在A的时间片结束之前,B不会跑,并且不可能花费整整一倍的时间来执行一条指令,所以最终有人会写出一个值> 2,或

  • A和B位于不同的CPU上。 A的写入使B的高速缓存变为无效,A继续执行,写出值> 2。

但是,如果每个商店都导致其他所有缓存失效,或者如果A能够在此期间继续运行,并且我不确定操作系统级别的东西是否应该像时间片一样,那么我不肯定适用于有关CPU的推理。

2 个答案:

答案 0 :(得分:4)

A executes #1, #2
B executes #1, #2, #3 9999 times  (_foo == 9999)
A executes #3    (_foo == 1)
B executes #1, #2   (part of iteration 10000, and reg == 2)
A executes #1, #2, #3 9999 times  (completing its total of 10000 iterations)
B executes #3  (writing 2 to _foo)

答案 1 :(得分:1)

tl; dr summary :使用单指令inc [foo]在单个核心上无法实现。也许每个线程都可以在自己的核心上运行,但我认为只有超线程才能通过在load / inc和商店之间导致缓存驱逐来在商店上创建额外的延迟。

我认为甚至多插槽缓存一致性也不够慢,以至于B&#39的最终存储在B的最终加载后延迟50k个周期,但超线程可能能够排队多个缓存/ TLB未命中在它之前。

在单核案例中:你假设 B不会在A的时间片上升之前运行并不一定成立。中断(例如,定时器中断或NIC)可以在任何点进入,暂停在任何指令边界处执行用户空间线程。也许在中断之后,一个更高优先级的进程被唤醒并被安排到CPU上一段时间,因此调度程序没有理由更喜欢已经运行了一小部分时间片的线程。

但是,如果我们只讨论单核案例,并发只能通过上下文切换进行,inc [mem]mov reg, [mem] / inc reg /非常不同mov [mem], reg。无论CPU内部如何处理inc [mem]上下文切换仅保存/恢复架构状态。如果load和inc部分已经在内部完成,而不是存储,那么整个指令都不能退役。上下文切换不会保存/恢复该进度:当线程再次开始执行并且CPU再次看到inc [mem]指令时,必须重新运行load和inc。

如果测试使用了单独的加载/加载/存储指令,理论上即使单核机器也可以按照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的最终结果。中断/上下文切换无法进行说明但是,对于加载和存储之间的延迟,我们需要提出其他可能的原因。

  • foo
  • 加载0
  • B循环9999次(最后存储foo = 9999)。高速缓存行现在处于B&n; CPU核心
  • E状态
  • 存储_foo = 1(第一次迭代结束)。可以想象,这可以在超线程CPU上延迟这么长时间,其他逻辑线程使存储端口饱和,并且存储器在缓存和/或TLB中丢失,并且存储器被缓冲一段时间。可能会发生这种情况,如果没有超线程,有几个缓存缺失存储等待完成。请记住,在x86的强大内存模型中,商店在程序顺序中全局可见,因此稍后存储到热缓存行仍需等待。及时完成B&#39的最后一次迭代只是时间的巧合,这很好。
  • B&#39的最后一次迭代从foo加载1。 (中断或某些事情可能会延迟B执行最后一次迭代。这并不需要在单个指令的load / inc / store uops之间发生任何事情,所以我不需要弄清楚如果接收到一致性消息(来自A)使高速缓存行无效将阻止存储转发将来自前一次迭代的存储的9999值转发到该迭代的负载。我不是当然,但我认为可以。)
  • A循环9999次(最终存储_foo = 10000
  • B的最终迭代存储_foo = 2解释这个商店在A&#s循环完成之后如何延迟似乎是最大的延伸。超线程可以做到这一点:另一个逻辑核心可以驱逐_foo的TLB条目,也可能驱逐包含该值的L1 D $行。这种驱逐可能发生在最终inc指令的加载和存储uops之间。我不确定一致性协议需要多长时间才能获得对另一个核心当前拥有的缓存行的写访问权。我确定它通常远远少于50k周期,实际上比CPU上的主内存访问少,包括最后一级缓存,可作为一致性流量的后盾(例如Intel' Nehalem以后的设计)。具有多个套接字的非常多核心系统可能很慢,但我认为它们仍然使用环形总线来实现一致性流量。

    我不确定B&#39的最终商店是否有可能延迟50k周期而没有超线程,以堆积一些商店端口争用并导致缓存驱逐。负载(必须看到A的商店1,但不包括A&#39的其他商店)在OOO调度程序中的商店前面不能太远,因为它仍然需要从倒数第二次迭代后来到商店。 (核心必须在单个执行上下文中维护有序语义。)

由于只有一个内存位置可以在两个线程中读取然后写入,因此不会对存储和加载进行任何重新排序。加载将始终看到来自同一线程的先前存储,因此在存储到同一位置之后,它才能全局可见。

在x86上,只有StoreLoad reordering是可能的,但在这种情况下,唯一重要的是无序机器可以延迟存储很长一段时间,即使没有相对于任何负载重新排序

您所指的原始博文一般看起来不错,但我确实注意到至少有一个错误。那里有很多很好的联系。

  

事实证明,在现代x86 CPU上,使用锁定来实现   并发原语是often cheaper than using memory barriers

该链接只是表明在Nehalem上使用lock add [mem], 0 作为障碍更便宜,尤其是它与其他指令交错得更好。关于使用依赖于障碍的锁定与无锁算法,没有什么可说的。如果你想原子地增加一个内存位置,那么到目前为止最简单的选择是lock ed指令。仅使用MFENCE将需要在没有原子RMW操作的情况下实现某种单独的锁定,如果可能的话。

显然,他想介绍lock inc [mem]inc [mem]的主题,并且对措辞并不小心。在大多数情况下,他的概括更好。

示例代码也很奇怪,并且一如既往地使用-O0 makes quite nasty code进行编译。我修复了内联asm以向编译器请求内存操作数,而不是手动编写incl (reg),因此在优化时,它会生成incl counter(%rip)而不是将指针加载到寄存器中。更重要的是,-O3也避免了将循环计数器保留在内存中,即使使用原始源也是如此。原始源上的-O3似乎仍会生成正确的代码,即使它没有告诉编译器它写入内存。

无论如何,在实验中存在缺陷,我认为实验仍然有效,并且用-O0编译的巨大循环开销不太可能对最终计数器的范围增加了人为限制结束了。

Dan Luu的示例asm语法是英特尔和AT& T语法的奇怪组合:mov [_foo], %eax是一个负载。它应该写成mov eax, [_foo],或mov _foo, %eax,或者mov (_foo), %eax,如果你试图说明它是一个负载而不是一个mov-immediate。无论如何,如果我不知道他的意思并试图证明这一点,我认为这会令人困惑。