正如@Mackie 所说,管道将充满cmp
s。当另一个内核写入时,英特尔将不得不刷新这些cmp
s,这是一项昂贵的操作。如果 CPU 没有刷新它,那么你有一个内存顺序违规。此类违规的示例如下:
(这从 lock1 = lock2 = lock3 = var = 1 开始)
线程 1:
spin:
cmp lock1, 0
jne spin
cmp lock3, 0 # lock3 should be zero, Thread 2 already ran.
je end # Thus I take this path
mov var, 0 # And this is never run
end:
线程 2:
mov lock3, 0
mov lock1, 0
mov ebx, var # I should know that var is 1 here.
首先,考虑线程 1:
如果cmp lock1, 0; jne spin
分支预测 lock1 不为零,它会添加cmp lock3, 0
到管道中。
在管道中,cmp lock3, 0
读取 lock3 并发现它等于 1。
现在,假设线程 1 正在度过美好的时光,线程 2 开始快速运行:
lock3 = 0
lock1 = 0
现在,让我们回到线程 1:
假设cmp lock1, 0
finally 读取 lock1,发现 lock1 为 0,并且对其分支预测能力感到满意。
该命令提交,并且没有任何内容被刷新。正确的分支预测意味着没有任何内容被刷新,即使是乱序读取,因为处理器推断没有内部依赖关系。在 CPU 的眼中,lock3 不依赖于 lock1,所以这一切都可以。
现在,cmp lock3, 0
正确读取 lock3 等于 1 的 提交。
je end
不被采取,并mov var, 0
执行。
在线程 3 中,ebx
等于 0。这应该是不可能的。这是英特尔必须补偿的内存顺序违规。
现在,英特尔为避免这种无效行为而采取的解决方案是刷新。在lock3 = 0
线程 2 上运行时,它会强制线程 1 刷新使用 lock3 的指令。在这种情况下,刷新意味着线程 1 不会向管道添加指令,直到所有使用 lock3 的指令都已提交。在线程 1cmp lock3
可以提交之前,cmp lock1
必须提交。当cmp lock1
尝试提交时,它读取到 lock1 实际上等于 1,并且分支预测失败。这会导致cmp
被抛出。现在线程 1 已刷新,lock3
线程 1 缓存中的位置设置为0
,然后线程 1 继续执行(等待上lock1
)。线程 2 现在收到通知,所有其他内核已刷新使用lock3
并更新了它们的缓存,因此线程 2 继续执行(同时它会执行独立的语句,但下一条指令是另一个写入,因此它可能必须挂起,除非其他内核有一个队列来保存挂起的lock1 = 0
写入)。
整个过程很昂贵,因此暂停。PAUSE 有助于线程 1,它现在可以立即从即将发生的分支错误预测中恢复,并且它不必在正确分支之前刷新其管道。PAUSE 同样有助于线程 2,它不必等待线程 1 的刷新(如前所述,我不确定这个实现细节,但如果线程 2 尝试写入太多其他内核使用的锁,线程 2 将最终必须等待冲洗)。
一个重要的理解是,虽然在我的示例中,刷新是必需的,但在 Mackie 的示例中,它不是。但是,CPU 无法知道(它根本不分析代码,除了检查连续语句依赖关系和分支预测缓存),因此 CPU 将刷新lockvar
在 Mackie 示例中访问的指令,就像在我的示例中一样,以保证正确性。