在没有 XCHG 的情况下实现自旋锁?

Posted

技术标签:

【中文标题】在没有 XCHG 的情况下实现自旋锁?【英文标题】:Implementing spin-lock without XCHG? 【发布时间】:2021-12-27 04:29:15 【问题描述】:

使用std::atomic_flag可以很容易地实现C++自旋锁,它可以大致编码(没有特殊功能)如下:

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

可以see online汇编,说明获取是通过XCHG指令原子实现的。

还可以看到on uops.info(屏幕here)XCHG 在非常流行的Skylake 上可能会占用30 CPU 周期。这很慢。

自旋锁的整体速度可以通过such program来衡量。

是否可以在没有 XCHG 的情况下实现自旋锁定?主要关心的是速度,而不仅仅是使用另一条指令。

最快的自旋锁是什么?是否有可能使其 10 个周期而不是 30 个周期? 5个周期?也许是一些平均运行速度很快的概率自旋锁?

应该以严格的方式实施,这意味着在 100% 的情况下,它可以正确保护一段代码和数据。如果它是概率性的,那么它应该运行可能的时间,但在每次运行后仍能 100% 正确保护。

对我来说,这种自旋锁的主要目的是保护多个线程中非常小的操作,这些操作运行十几个或两个周期,因此 30 个周期的延迟开销太大。当然可以说我可以使用原子或其他无锁技术来实现所有操作。但是这种技术并非适用于所有情况,并且还需要大量工作才能在大量类和方法的庞大代码库中严格实现。因此,还需要一些通用的东西,比如常规自旋锁。

【问题讨论】:

当然,还有其他方法,例如lock bts,但它们也需要很多周期。锁定操作本质上是昂贵的。 确实存在仅使用加载和存储来执行自旋锁的方法,因此不需要锁定 RMW,例如Peterson's algorithm。但是它需要足够严格的内存排序,以至于你需要在某个地方设置一个明确的栅栏(StoreLoad 重新排序会破坏它),这也是有代价的。 (事实上,在许多 CPU 上,可用的最快的顺序一致性围栏是 xchglock add;参见 ***.com/questions/4232660/…) 很难想象。从根本上说,每次加锁时,都必须执行加载(查看锁是否可用)、条件跳转(如果不可用则避免临界区),通常还要进行存储(实际获取锁)。如果其中任何一个被跳过,则无法正确获取锁,因此它们必须以 100% 的概率执行。仅这三个就已经占用了几乎所有预算周期。 不,据我所知没有免费的午餐。当这样的“侧通道”存在时,它们通常是必须堵塞的安全漏洞。除了内存和外部设备,我知道的唯一其他形式的内核间通信是处理器间中断 (IPI),而且非常昂贵。 【参考方案1】:

是否可以在没有 XCHG 的情况下实现自旋锁定?

是的。对于 80x86,您可以 lock btslock cmpxchglock 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”。

除此之外很难说 - 对于一个极端情况,程序可能将大部分时间都花在不需要锁的工作上,并且锁定的成本可能对整体性能几乎没有影响(即使获取/释放更多比微小的临界区贵);而对于另一个极端,程序可能花费大部分时间来获取和释放锁。换句话说,获取/释放锁的成本介于“无关紧要”和“重大设计缺陷(使用太多锁并需要重新设计整个程序)”之间。

【讨论】:

感谢扩展答案!赞成。您提出了一个好主意,即在获取锁定时最好仅使用读取标志进行循环,而不是交换,这可以节省大量时间,因为不需要在内核之间切换缓存线。您是否有任何机会使用您描述的所有想法现成的自旋锁实现?也许一些 GitHub 链接? @Arty:在其中添加了一个示例自旋锁以及一些实现说明(作为示例 - 可能不是您想要“剪切和粘贴”的代码)。 @Arty:这种悲观策略的一个缺点是,如果锁可用但缓存行尚未独占,它会导致更多的一致性流量。 (即最近另一个线程解锁了它,所以它在他们的私人缓存中仍然很热)。读取将生成仅获取 MESI S 状态的共享请求,然后 xchglock cmpxchg(或 lock bts,如果您愿意)也将丢失,然后才会生成 RFO 以获取 Exclusive 或 Modified 状态。见this Q&A,也许还有this。 我的印象是,通常的最佳做法是首先尝试 RMW,并且只有 spin-wait 只读以避免所有核心都在生产线上(即使在pause/cmp 循环中有一些退避。)但是直到一两年前,我还没有意识到将第一个检查设置为只读的缺点,这会延迟理想情况下的快速路径. 所以最后得出的结论是,除了XCHGlock ...指令外,没有什么可以用来实现自旋锁的了?实际上,我一直在寻找一些快速且不需要锁定内存的算法。您是否知道是否存在任何无需锁定即可工作的特殊算法?

以上是关于在没有 XCHG 的情况下实现自旋锁?的主要内容,如果未能解决你的问题,请参考以下文章

自旋锁所需的最小 X86 组件是多少

Linux设备驱动程序 之 自旋锁

聊聊高并发(十三)实现几种自旋锁

CAS自旋锁

悲观锁和乐观锁

悲观锁和乐观锁