C++旋转锁可以使用std::atomic_flag轻松实现,它可以大致编码(没有特殊功能)如下:
std::atomic_flag f = ATOMIC_FLAG_INIT;
while (f.test_and_set(std::memory_order_acquire)); // Acquire lock
// Here do some lock-protected work .....
f.clear(std::memory_order_release); // Release lock
可以看到在线汇编,它表明获取是通过XCHG指令原子化实现的。
此外,在uops.info(此处屏幕)上可以看到,XCHG在相当流行的Skylake上可能需要高达30
的CPU周期。这相当慢。
旋转锁的整体速度可以通过这样的程序来测量。
是否可以在没有XCHG的情况下实现旋转锁定?主要关注的是速度,而不仅仅是使用其他指令。
最快的旋转锁定是什么?有可能把它从30个循环变成10个循环吗?还有5个周期?也许是一些平均运行速度很快的概率旋转锁?
它应该以严格的方式实现,这意味着在100%的情况下,它可以正确地保护代码和数据。如果它是概率性的,那么它应该在可能的时间内运行,但在每次运行后都能100%正确地保护。
对我来说,这种旋转锁的主要目的是保护多个线程中运行十几个或两个周期的非常小的操作,因此30个周期的延迟太大了。当然,可以说我可以使用原子或其他无锁技术来实现所有操作。但这种技术不可能适用于所有情况,而且还需要大量的工作才能在许多类和方法的庞大代码库中严格实现。因此,还需要一些通用的东西,比如常规的旋转锁。
是否可以在没有XCHG的情况下实现旋转锁定?
是。对于80x86,您可以使用lock bts
、lock cmpxchg
、lock xadd
或。。。
什么是最快的旋转锁定?
对";快速";包括:
a) 在未受控制的情况下速度很快。在这种情况下,你做什么并不重要,因为大多数可能的操作(交换、添加、测试…)都很便宜,而真正的成本是缓存一致性(将包含锁的缓存行放入当前CPU缓存中的"独占"状态,可能包括从RAM或其他CPU缓存中获取)和序列化。
b) 在有争议的案件中速度很快。在这种情况下,你真的需要一个";无锁测试;然后测试&设置有锁";方法简单的spinloop(对于争用的情况)的主要问题是,当多个CPU正在旋转时,缓存线将从一个CPU的缓存快速反弹到下一个CPU,并白白消耗大量的互连带宽。为了防止这种情况,您将有一个循环来测试锁定状态,而不修改它,以便缓存行可以作为"保留在所有CPU缓存中;共享的";同时这些CPU正在旋转。
但请注意,一开始测试只读可能会损害未争用的情况,从而导致更高的一致性流量:首先是对缓存行的共享请求,如果另一个核心最近解锁,它只会让你进入MESI S状态,然后是当你试图取锁时的RFO(read for Ownership)。因此,最佳实践可能是从RMW开始,如果失败,则使用pause
以只读方式旋转,直到您看到可用的锁,除非在您关心的系统上分析代码显示有不同的选择更好。
c) 获取锁时快速退出自旋循环(争用后)。在这种情况下,CPU可以推测地执行循环的多次迭代,并且当锁定被获取时,所有CPU都必须消耗掉那些"锁";推测性地执行循环的许多迭代";这需要花费一点时间。为了防止这种情况,您需要pause
指令来防止循环/s的多次迭代被推测性执行。
d) 对于其他不接触锁的CPU来说速度很快。在某些情况下(超线程),核心的资源在逻辑处理器之间共享;当一个逻辑进程正在旋转时,它会消耗另一个逻辑处理器本可以用来完成有用工作的资源(尤其是对于"旋转锁推测性地执行循环的多次迭代"的情况)。为了最大限度地减少这种情况,您需要在spinloop/s中使用pause
(这样旋转的逻辑处理器就不会消耗内核的那么多资源,并且内核中的其他逻辑处理器可以完成更多有用的工作)。
e) 最小值";最坏情况获取时间";。对于一个简单的锁,在争用的情况下,一些CPU或线程可能很幸运,总是能得到锁,而其他CPU/线程则很不幸运,需要很长时间才能得到锁;以及";获取"最坏情况时间";理论上是无限的(CPU可以永远旋转)。为了解决这个问题,你需要一个公平的锁——这可以确保只有等待/旋转时间最长的线程才能在释放锁时获得锁。注意,可以设计一个公平锁,使每个线程在不同的缓存线上旋转;这是解决"问题"的不同方式;CPU之间的高速缓存线跳动";我在";b) 在有争议的情况下快速";。
f) 最小值";最坏情况直到锁定释放";。这必须包括最坏临界段的长度;但在某些情况下也可能包括任何数量的IRQ的成本、任何数量的任务切换的成本以及代码不使用任何CPU的时间。完全有可能出现这样的情况:线程获取锁,然后调度程序进行线程切换;那么许多CPU都在一个无法释放的锁上旋转(浪费了大量时间)(因为锁持有者是唯一可以释放锁的人,而且它甚至不使用任何CPU)。解决/改进此问题的方法是禁用调度器和IRQ;这在内核代码中是好的;出于安全原因可能不可能";在正常的用户空间代码中。这也是为什么自旋锁可能永远不应该在用户空间中使用的原因(以及为什么用户空间可能应该使用互斥锁,其中线程被置于"阻塞等待锁定"状态,并且调度程序不给CPU时间,直到/除非线程真的可以获取锁)。
注意,使其对于";快速";可以使其对于";快速";。例如未受控制的案件的速度因其他因素而变得更糟。
Spinlock示例
此示例未经测试,是用(NASM语法)程序集编写的。
;Input
; ebx = address of lock
;Initial optimism in the hope the lock isn't contended
spinlock_acquire:
lock bts dword [ebx],0 ;Set the lowest bit and get its previous value in carry flag
;Did we actually acquire it, i.e. was it previously 0 = unlocked?
jnc .acquired ; Yes, done!
;Waiting (without modifying) to avoid "cache line bouncing"
.spin:
pause ;Reduce resource consumption
; and avoid memory order mis-speculation when the lock becomes available.
test dword [ebx],1 ;Has the lock been released?
jne .spin ; no, wait until it was released
;Try to acquire again
lock bts dword [ebx],0 ;Set the lowest bit and get its previous value in carry flag
;Did we actually acquire it?
jc .spin ; No, go back to waiting
.acquired:
自旋解锁可以只是mov dword [ebx], 0
,而不是lock btr
,因为您知道您拥有锁,并且它在x86上具有发布语义。你可以先阅读它来捕捉双重解锁错误。
注:
a)lock bts
比其他可能性慢一点;但它不干扰或依赖于锁的其他31位(或63位),这意味着那些其他比特可以用于检测编程错误(例如,当获取锁时将"当前持有锁的线程ID"的31比特存储在其中,并在释放锁时对其进行检查,以自动检测"错误线程释放锁"one_answers"从未获取时释放锁"的错误)和/或用于收集性能信息(例如,当存在争用时设置比特1,以便其他代码可以周期性地扫描,以确定哪些锁很少争用,哪些锁争用严重)。使用锁的Bug通常非常隐蔽,很难找到(不可预测且不可复制的"Heisenbugs",一旦你试图找到它们就会消失);所以我偏爱";自动错误检测较慢";。
b) 这不是一个公平的锁,这意味着它不太适合可能发生争用的情况。
c) 为了记忆;内存消耗/缓存未命中和错误共享之间存在折衷。对于很少争用的锁,我喜欢将锁放在与锁保护的数据相同的缓存行/秒中,因此获取锁意味着锁持有者想要的数据已经在缓存中(并且不会发生后续的缓存丢失)。对于严重争用的锁,这会导致错误共享,应该通过为锁保留整个缓存行而不保留其他内容来避免(例如,在实际锁使用的4个字节之后添加60个未使用的填充,如C++alignas(64) struct { std::atomic<int> lock; };
)。当然,像这样的旋转锁不应该用于争用严重的锁,因此可以合理地假设最小化内存消耗(并且没有任何填充,并且不关心错误共享)是有意义的。
对我来说,这种旋转锁的主要目的是保护多个线程内运行十几个或两个周期的非常小的操作,因此30个周期的延迟是开销太大
在这种情况下,我建议尝试用原子、无块算法和无锁算法替换锁。一个微不足道的例子是跟踪统计信息,其中您可能希望执行lock inc dword [number_of_chickens]
,而不是获取一个锁来增加";CCD_ 13";。
除此之外,很难说太多——在一个极端情况下,该程序可能会在不需要锁的情况下花费大部分时间来完成工作,并且锁的成本可能对整体性能几乎没有影响(尽管获取/发布比微小的关键部分更贵);而对于另一个极端,该程序可能会花费大部分时间来获取和释放锁。换言之,获取/释放锁的成本介于"0"one_answers"0"之间;"无关";以及";主要设计缺陷(使用太多锁并且需要重新设计整个程序)";。