是否可以在没有 XCHG 的情况下实现自旋锁定?
是的。对于 80x86,您可以 lock bts 或 lock cmpxchg 或 lock xadd 或 ...
最快的自旋锁是什么?
“快速”的可能解释包括:
a) 在无竞争的情况下快速。在这种情况下,你做什么并不重要,因为大多数可能的操作(交换、添加、测试......)都很便宜,真正的成本是缓存一致性(将包含锁的缓存行放入“独占" 当前 CPU 缓存中的状态,可能包括从 RAM 或其他 CPU 的缓存中获取它)和序列化。
b) 在有争议的情况下快速。在这种情况下,您确实需要“无锁测试;然后使用锁进行测试和设置”方法。简单自旋循环的主要问题(对于有争议的情况)是,当多个 CPU 旋转时,高速缓存行将从一个 CPU 的高速缓存快速弹跳到下一个 CPU 的高速缓存,并且白白消耗大量的互连带宽。为防止这种情况发生,您将有一个循环来测试锁定状态而不修改它,以便缓存行可以在所有 CPU 缓存中同时保持为“共享”,而这些 CPU 正在旋转。
但请注意,以只读方式开始测试可能会损害非竞争情况,从而导致更多的一致性流量:首先是缓存行的共享请求,如果另一个内核最近解锁,则只会获得 MESI S 状态,然后在您尝试获取锁定时发出 RFO(读取所有权)。因此,最佳实践可能是从 RMW 开始,如果失败,则使用 pause 以只读方式旋转,直到您看到可用的锁,除非在您关心的系统上分析您的代码显示不同的选择更好。
c) 获取锁时快速退出自旋循环(争用后)。在这种情况下,CPU 可以推测性地执行循环的多次迭代,并且当获得锁时,所有 CPU 都必须耗尽那些“推测性地执行循环的多次迭代”,这会花费一些时间。为防止您需要 pause 指令来防止循环的多次迭代被推测执行。
d) 其他不接触锁的 CPU 速度快。在某些情况下(超线程),核心资源在逻辑处理器之间共享;并且当一个逻辑进程正在旋转时,它会消耗另一个逻辑处理器本可以用来完成有用工作的资源(尤其是对于“自旋锁推测性地执行循环的许多迭代”的情况)。为了最大限度地减少这种情况,您需要在 spinloop/s 中添加一个pause(这样旋转的逻辑处理器不会消耗太多的内核资源,并且内核中的其他逻辑处理器可以完成更多有用的工作)。
e) 最短“最坏情况下的获取时间”。使用简单的锁,在争用情况下,一些 CPU 或线程可能很幸运并且总是获得锁,而其他 CPU/线程则非常不幸并且需要很长时间才能获得锁;并且“最坏情况下的获取时间”理论上是无限的(CPU可以永远旋转)。要解决这个问题,您需要一个公平的锁 - 确保只有等待/旋转最长时间的线程才能在释放锁时获取锁。请注意,可以设计一个公平的锁,使每个线程在不同的缓存行上旋转;这是解决我在“b) 争用情况下的快速”中提到的“CPU 之间的缓存线弹跳”问题的另一种方法。
f) 最小的“锁定释放前的最坏情况”。这必须涉及最差关键部分的长度;但在某些情况下,还可能包括任意数量的 IRQ 的成本、任意数量的任务切换的成本以及代码不使用任何 CPU 的时间。完全有可能出现线程获取锁然后调度程序进行线程切换的情况;然后许多CPU都在无法释放的锁上旋转(浪费大量时间)(因为锁持有者是唯一可以释放锁的人,它甚至不使用任何CPU)。修复/改进此问题的方法是禁用调度程序和 IRQ;这在内核代码中很好,但在普通用户空间代码中“出于安全原因可能是不可能的”。这也是为什么自旋锁可能永远不应该在用户空间中使用的原因(以及为什么用户空间应该使用互斥锁,其中线程处于“阻塞等待锁定”状态而不是由调度程序给定 CPU 时间,直到/除非线程实际上可以获取锁)。
请注意,将“快速”的一种可能解释设置为快速可能会使“快速”的其他解释变得更慢/更差。例如;其他一切都使无争议案件的速度变得更糟。
自旋锁示例
此示例未经测试,并使用(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 位),这意味着这些其他位可用于检测编程错误(例如,存储 31 位“当前持有锁的线程 ID”)在获得锁时在其中检查它们,并在释放锁时检查它们以自动检测“错误的线程释放锁”和“锁在从未获得时被释放”错误)和/或用于收集性能信息(例如设置位1 当存在争用时,以便其他代码可以定期扫描以确定哪些锁很少争用以及哪些锁争用严重)。使用锁的错误通常非常隐蔽且难以找到(不可预测且不可重现的“海森错误”,一旦您尝试找到它们就会消失);所以我更喜欢“自动错误检测更慢”。
b) 这不是一个公平的锁,这意味着它不太适合可能发生争用的情况。
c) 用于记忆;在内存消耗/缓存未命中和错误共享之间存在折衷。对于很少争用的锁,我喜欢将锁放在与锁保护的数据相同的缓存行中,这样获取锁就意味着锁持有者想要的数据已经在缓存中(并且不会发生后续的缓存未命中)。对于竞争激烈的锁,这会导致错误共享,应该通过为锁保留整个缓存行而不是其他任何东西来避免(例如,在实际锁使用的 4 个字节之后添加 60 个未使用的填充字节,就像在 C++ alignas(64) struct { std::atomic<int> lock; }; 中一样) .当然,像这样的自旋锁不应该用于竞争激烈的锁,因此可以合理地假设最小化内存消耗(并且没有任何填充,并且不关心虚假共享)是有道理的。
对我来说,这种自旋锁的主要目的是保护多个线程中非常小的操作,这些操作运行十几个或两个周期,因此 30 个周期的延迟开销太大
在这种情况下,我建议尝试用原子、无块算法和无锁算法替换锁。一个简单的示例是跟踪统计信息,您可能希望在其中执行 lock inc dword [number_of_chickens] 而不是获取锁以增加“number_of_chickens”。
除此之外很难说 - 对于一个极端情况,程序可能将大部分时间都花在不需要锁的工作上,并且锁定的成本可能对整体性能几乎没有影响(即使获取/释放更多比微小的临界区贵);而对于另一个极端,程序可能花费大部分时间来获取和释放锁。换句话说,获取/释放锁的成本介于“无关紧要”和“重大设计缺陷(使用太多锁并需要重新设计整个程序)”之间。