可以/应该使用 SeqLock 实现非无锁原子吗?
Posted
技术标签:
【中文标题】可以/应该使用 SeqLock 实现非无锁原子吗?【英文标题】:Can/should non-lock-free atomics be implemented with a SeqLock? 【发布时间】:2021-11-24 11:41:10 【问题描述】:在 MSVC STL 和 LLVM libc++ 实现中,std::atomic
的非原子大小是使用自旋锁实现的。
libc++ (Github):
_LIBCPP_INLINE_VISIBILITY void __lock() const volatile
while(1 == __cxx_atomic_exchange(&__a_lock, _LIBCPP_ATOMIC_FLAG_TYPE(true), memory_order_acquire))
/*spin*/;
_LIBCPP_INLINE_VISIBILITY void __lock() const
while(1 == __cxx_atomic_exchange(&__a_lock, _LIBCPP_ATOMIC_FLAG_TYPE(true), memory_order_acquire))
/*spin*/;
MSVC (Github)(最近在this Q&A讨论):
inline void _Atomic_lock_acquire(long& _Spinlock) noexcept
#if defined(_M_IX86) || (defined(_M_X64) && !defined(_M_ARM64EC))
// Algorithm from Intel(R) 64 and IA-32 Architectures Optimization Reference Manual, May 2020
// Example 2-4. Contended Locks with Increasing Back-off Example - Improved Version, page 2-22
// The code in mentioned manual is covered by the 0BSD license.
int _Current_backoff = 1;
const int _Max_backoff = 64;
while (_InterlockedExchange(&_Spinlock, 1) != 0)
while (__iso_volatile_load32(&reinterpret_cast<int&>(_Spinlock)) != 0)
for (int _Count_down = _Current_backoff; _Count_down != 0; --_Count_down)
_mm_pause();
_Current_backoff = _Current_backoff < _Max_backoff ? _Current_backoff << 1 : _Max_backoff;
#elif
/* ... */
#endif
在考虑一个更好的可能实现的同时,我想知道用SeqLock 替换它是否可行?如果读取不与写入竞争,优势将是读取成本低。
我要质疑的另一件事是是否可以改进 SeqLock 以使用 OS 等待。在我看来,如果 reader 观察到奇数,它可以使用原子等待底层机制(Linux futex
/Windows WaitOnAddress
)等待,从而避免自旋锁的饥饿问题。
在我看来,这似乎是可能的。虽然 C++ 内存模型目前不包括 Seqlock,但 std::atomic
中的类型必须是可简单复制的,因此 seqlock 中的 memcpy
读取/写入将起作用,并且如果使用足够的障碍来获得 volatile 等效项而不失败,则将处理竞争优化太糟糕了。这将是特定 C++ 实现的头文件的一部分,因此它不必是可移植的。
关于在 C++ 中实现 SeqLock 的现有 SO Q&As(可能使用其他 std::atomic 操作)
Implementing 64 bit atomic counter with 32 bit atomics how to implement a seqlock lock using c++11 atomic library【问题讨论】:
【参考方案1】:是的,如果您在写入器之间提供互斥,您可以使用 SeqLock 作为读取器/写入器锁。您仍然可以获得读取端的可扩展性,而写入和 RMW 将保持不变。
这不是一个坏主意,尽管如果您的写入频率很高,它可能会给读者带来公平问题。对于主流标准库来说可能不是一个好主意,至少如果没有在一系列硬件上对一些不同的工作负载/用例进行一些测试,因为在某些机器上工作得很好,但在其他机器上工作并不是你想要的标准库东西. (不幸的是,希望在特殊情况下获得出色性能的代码通常必须使用针对它进行调整的实现,而不是标准实现。)
使用单独的自旋锁或仅使用序列号的低位可以实现互斥。事实上,我已经看到了 SeqLock 的其他描述,假设您将它与多个写入器一起使用,甚至没有提到单写入器的情况,它允许序列号的纯加载和纯存储以避免原子 RMW 的成本。
如何将序列号用作自旋锁
编写器或 RMWe 尝试以原子方式对要递增的序列号进行 CAS 处理(如果它还不是奇数的话)。如果序列号已经是奇数,写入器会旋转直到看到偶数。
这意味着写入者必须先读取序列号,然后再尝试写入,这可能导致extra coherency traffic(MESI 共享请求,然后是 RFO)。在硬件中实际上有 fetch_or
的机器上,您可以使用它自动地使计数变为奇数,看看您是否赢得了将其从偶数变为奇数的比赛。
在 x86-64 上,您可以使用lock bts
设置低位并找出旧的低位是什么,然后加载整个序列号(如果之前是偶数)(因为您赢得了比赛,没有其他作家将对其进行修改)。所以你可以做一个加 1 的发布存储来“解锁”,而不是需要一个 lock add
。
但是,让其他编写者更快地回收锁实际上可能是一件坏事:您想为读者提供一个完成的窗口。也许只是在写端自旋循环中使用多个 pause
指令(或非 x86 上的等效指令),而不是在读端自旋中。如果争用率低,读者可能有时间在作者看到它之前看到它,否则作者会经常看到它被锁定并进入较慢的自旋循环。也许对作家来说也有更快增加的退避。
LL/SC 机器可以(至少在 asm 中)像 CAS 或 TAS 一样轻松地进行测试和增量。我不知道如何编写可以编译为的纯 C++。 fetch_or 可以有效地为 LL/SC 编译,但即使它已经很奇怪,仍然可以存储到商店。 (如果非要和SC分开LL,还不如充分利用,没用的就不要存了,希望硬件能做到物尽其用。)
(不要无条件地递增,这一点很重要;你不能解锁另一个写入者对锁的所有权。但是保持值不变的 atomic-RMW 总是可以保证正确性,如果不是性能的话。)
默认情况下这可能不是一个好主意,因为繁重的写入活动会导致糟糕的结果,这使得读者可能很难成功完成读取。正如***指出的那样:
阅读器永远不会阻塞,但如果正在进行写入,它可能必须重试;这在数据未被修改的情况下加快了读取器的速度,因为他们不必像使用传统的读写锁那样获取锁。此外,写入者不等待读取者,而使用传统的读写锁他们这样做,在有许多读取者的情况下导致潜在的资源匮乏(因为写入者必须等待没有读取者)。由于这两个因素,seqlocks 在读者多而写者少的情况下比传统的读写锁更有效。 缺点是如果写入活动过多或阅读器速度太慢,它们可能会活锁(阅读器可能会饿死)。
“阅读器太慢”的问题不太可能发生,只是一个小的 memcpy。对于非常大的T
,代码不应期望来自std::atomic<T>
的良好结果;一般的假设是,您只会为在某些实现上可以无锁的 T 烦恼 std::atomic 。 (通常不包括事务内存,因为主流实现不这样做。)
但“写太多”的问题仍然存在:SeqLock 最适合以读取为主的数据。读取器可能会在大量写入混合时遇到麻烦,重试次数甚至比使用简单的自旋锁或读取器-写入器锁还要多。
如果有办法让它成为实现的选项,那就太好了,例如可选的模板参数,例如std::atomic<T, true>
,或#pragma
,或#define
之前包括<atomic>
。或命令行选项。
一个可选的模板参数会影响该类型的每次使用,但可能比单独的类名(如gnu::atomic_seqlock<T>
)稍微不那么笨重。一个可选的模板参数仍然会使 std::atomic
类型成为该类名,例如为std::atomic
匹配其他事物的专业化。但可能会破坏其他东西,IDK。
破解一些东西来做实验可能会很有趣。
【讨论】:
对于SeqLock
,锁定计数器是否需要与正在读取的数据位于同一缓存行上?否则,如果对有效负载的写入更改在计数器之前传播,我不明白为什么您无法获取陈旧数据。
@Noah:作者和读者使用内存屏障来确保不会发生这种情况。在对有效负载进行任何更改之前,需要使计数器变为奇数的初始写入可见。这是在 C++ 中表达的一个挑战,因为您不能在非原子变量上进行发布存储,并且atomic_thread_fence(mo_release)
不正式保证订购 wrt。后来的非原子存储,仅在同步方面。当然,整个 SeqLock 的想法是 UB,进行 UB 非原子读取并在可能发生撕裂时丢弃,但这也取决于现实世界的 ISA 内存模型,即实现细节
我明白了,在 x86 上,在第一次 CAS 递增计数器(使其变得奇怪)和写入有效负载之后,您可以仅使用 sfence
来摆脱编写器吗?那么读者应该不可能看到改变的有效负载没有奇数计数器或写入器完成(偶数+递增)没有改变有效负载。
@Noah:嗯?我们没有使用 NT 商店,所以sfence
无关紧要; x86-TSO 已经强制执行 StoreStore 排序,因此这在 x86 上是微不足道的,只需编译时排序就足够了。即使使用仅使用 mov
存储的单写入器 SeqLock,也可以保证以后的 mov
存储在第一个存储之前不可见。或者在多写者的情况下,lock bts
已经是 x86 上的一个完整障碍。读取端需要在第一次加载(所以 mo_acquire 就足够了)之间以及在有效负载和最终加载之间(thread_fence(acquire)
这是 x86 上的无操作,或一些 dsb
在 ARM 上)之间的 LoadLoad 排序以上是关于可以/应该使用 SeqLock 实现非无锁原子吗?的主要内容,如果未能解决你的问题,请参考以下文章