1

在 MSVC STL 和 LLVMstd::atomic中,非原子大小的 libc++ 实现都是使用自旋锁实现的。

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)(最近在这个问答中讨论过):

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 等待。在我看来,如果读者观察到奇数,它可以使用原子等待底层机制(Linux futex/Windows WaitOnAddress)等待,从而避免自旋锁的饥饿问题。


在我看来,这似乎是可能的。尽管 C++ 内存模型目前不包括 Seqlock,但类型输入std::atomic必须是可简单复制的,因此memcpy如果使用足够的障碍来获得 volatile 等效项而不会严重破坏优化,则 seqlock 中的读/写将起作用并且将处理竞争。这将是特定 C++ 实现的头文件的一部分,因此它不必是可移植的。

关于在 C++ 中实现 SeqLock 的现有 SO Q&As(可能使用其他 std::atomic 操作)

4

1 回答 1

1

是的,如果您提供写入者之间的互斥,您可以使用 SeqLock 作为读取者/写入者锁。您仍然可以获得读取端的可扩展性,而写入和 RMW 将保持大致相同。

这不是一个坏主意,尽管如果你有非常频繁的写入,它会给读者带来潜在的公平问题。对于主流标准库来说可能不是一个好主意,至少如果没有在一系列硬件上对一些不同的工作负载/用例进行一些测试,因为在某些机器上工作得很好,但在其他机器上工作并不是你想要的标准库东西. (不幸的是,希望为其特殊情况提供出色性能的代码通常必须使用针对它进行调整的实现,而不是标准实现。)

使用单独的自旋锁或仅使用序列号的低位可以实现互斥。事实上,我已经看到了 SeqLock 的其他描述,假设您将它与多个写入器一起使用,甚至没有提到单写入器的情况,它允许序列号的纯加载和纯存储以避免原子 RMW 的成本。


如何使用序列号作为自旋锁

编写器或 RMWe 尝试以原子方式对要递增的序列号进行 CAS 处理(如果它还不是奇数)。如果序列号已经是奇数,那么写入器只会旋转直到看到偶数。

这意味着写入者必须在尝试写入之前先读取序列号,这可能会导致额外的一致性流量(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。代码不应该期望从std::atomic<T>非常大的结果中获得好的结果T;一般的假设是,您只会为在某些实现上可以无锁的 T 烦恼 std::atomic 。(通常不包括事务内存,因为主流实现不这样做。)

但是“写太多”的问题仍然存在:SeqLock 最适合以读取为主的数据。读者可能会在大量写入混合时遇到麻烦,重试次数甚至比使用简单的自旋锁或读写器锁还要多。

如果有一种方法可以使它成为实现的选项,例如可选的模板参数,例如std::atomic<T, true>, 或 a #pragma,或者#define在 include 之前,那就太好了<atomic>。或命令行选项。

一个可选的模板参数会影响该类型的每次使用,但可能比单独的类名(如gnu::atomic_seqlock<T>. 一个可选的模板参数仍然会使std::atomic类型成为该类名,因此例如匹配其他事物的特化std::atomic。但可能会破坏其他东西,IDK。


破解一些东西来做实验可能会很有趣。

于 2021-11-24T12:39:10.303 回答