是否应该为互斥锁获取-交换循环(或队列获取-加载循环)组合内存栅栏,还是应该避免?

Posted

技术标签:

【中文标题】是否应该为互斥锁获取-交换循环(或队列获取-加载循环)组合内存栅栏,还是应该避免?【英文标题】:Should combining memory fence for mutex acquire-exchange loop (or queue acquire-load loop) be done or should it be avoided? 【发布时间】:2020-09-30 18:11:22 【问题描述】:

假设一个重复的获取操作,它尝试加载或交换一个值,直到观察到的值是所需的值。

我们以cppreference atomic flag example为起点:

void f(int n)

    for (int cnt = 0; cnt < 100; ++cnt) 
        while (lock.test_and_set(std::memory_order_acquire))  // acquire lock
             ; // spin
        std::cout << "Output from thread " << n << '\n';
        lock.clear(std::memory_order_release);               // release lock
    

现在让我们考虑对这种旋转的增强。两个著名的是:

不要永远旋转,而是在某个时候转到操作系统等待; 使用指令,例如 pauseyield,而不是无操作旋转。

我能想到第三个,我想知道它是否有意义。 我们可以使用std::atomic_thread_fence 来获取语义:

void f(int n)

    for (int cnt = 0; cnt < 100; ++cnt) 
        while (lock.test_and_set(std::memory_order_relaxed))  // acquire lock
             ; // spin
        std::atomic_thread_fence(std::memory_order_acquire);  // acquire fence
        std::cout << "Output from thread " << n << '\n';
        lock.clear(std::memory_order_release);               // release lock
    

我希望 x86 不会有任何变化。

我想知道:

在存在差异的平台 (ARM) 上进行此更改是否有利或有弊? 使用或不使用yield指令的决定是否受到干扰?

我不仅对atomic_flag::clear / atomic_flag::test_and_set 对感兴趣,我还对atomic&lt;uint32_t&gt;::store / atomic&lt;uint32_t&gt;::load 对感兴趣。


可能改为放松负载可能是有意义的:

void f(int n)

    for (int cnt = 0; cnt < 100; ++cnt) 
        while (lock.test_and_set(std::memory_order_acquire))  // acquire lock
             while (lock.test(std::memory_order_relaxed))
                 YieldProcessor(); // spin
        std::cout << "Output from thread " << n << '\n';
        lock.clear(std::memory_order_release);               // release lock
    

【问题讨论】:

instruction, such as pause or yield 虽然我看到 pause instruction in x86 这只是对 cpu 的提示,并且没有 yield 指令。如果您的意思是pause(2),那么它会等待中断,如果您的意思是shed_yield,那么您不应该使用它——sched_yield 用于实时进程,然后您的代码将只使用 100% cpu 和调度程序阻塞其他进程。 yield 是 ARM 指令。我的意思是。在 x86 上,无论如何这是一个无关紧要的问题,所以我提到了 ARM 指令。我的意思是这里的 CPU 指令,没有操作系统或运行时库函数。 是的,这是一个关于制作优化提示的问题,所以pause / yield 确实是“提示”说明。 是的,在失败重试路径中避免获取障碍的一般想法可能很有用,尽管如果你只是在旋转,失败情况下的性能几乎没有相关性。但这就是 CAS 为成功和失败分别设置 memory_order 参数的原因。宽松的失败可能会让编译器只在离开循环路径上设置障碍。不过atomic_flagtest_and_set 没有这个选项。 是的,我问的是“旋转的性能”。我怀疑它背后有一些意义,否则没有pause / yield 指令。感谢您提到带有单独排序参数的 CAS——我现在明白为什么这些单独的参数很有用了。 【参考方案1】:

是的,在失败重试路径中避免获取障碍的一般想法可能很有用,尽管如果您只是在旋转,失败情况下的性能几乎没有相关性。 pauseyield 省电。在 x86 上,pause 还提高了 SMT 友好性,并避免在另一个内核修改您正在旋转的内存位置后离开循环时内存顺序错误推测。

但这就是为什么 CAS 有单独的 memory_order 参数来表示成功和失败。宽松的失败可能会让编译器只在离开循环路径上设置障碍。

atomic_flag test_and_set 没有这个选项。 手动操作可能会损害像 AArch64 这样的 ISA,它本可以完成获取 RMW 并避免明确的围栏指令。 (例如ldarb

Godbolt:带有lock.test_and_set(std::memory_order_acquire)的原始循环:

# AArch64 gcc8.2 -O3
.L6:                            # do
    ldaxrb  w0, [x19]           # acquire load-exclusive
    stxrb   w1, w20, [x19]      # relaxed store-exclusive
    cbnz    w1, .L6            # LL/SC failure retry
    tst     w0, 255
    bne     .L6             # while(old value was != 0)
  ... no barrier after this

(是的,它只是用tst 而不是cbnz w1, .L6 测试低8 位,这看起来像是一个错过的优化)

while(放松 RMW) + std::atomic_thread_fence(std::memory_order_acquire);

.L14:                          # do 
    ldxrb   w0, [x19]             # relaxed load-exclusive
    stxrb   w1, w20, [x19]        # relaxed store-exclusive
    cbnz    w1, .L14             # LL/SC retry
    tst     w0, 255
    bne     .L14               # while(old value was != 0)
    dmb     ishld         #### Acquire fence
   ...

对于 32 位 ARMv8 更糟糕的是,dmb ishld 不可用,或者编译器不使用它。 您将获得dmb ish 完整的屏障。


-march=armv8.1-a

.L2:
    swpab   w20, w0, [x19]
    tst     w0, 255
    bne     .L2
    mov     x2, 19
  ...

对比

.L9:
    swpb    w20, w0, [x19]
    tst     w0, 255
    bne     .L9
    dmb     ishld                   # acquire barrier (load ordering)
    mov     x2, 19
...

【讨论】:

谢谢。我希望它也适用于store-release / load-acquire 循环(方法相同,但没有 LL/SC)。 我认为当单独的atomic_thread_fence 更糟时这种情况的存在是反对在通用实现中这样做的强烈论据,因为它是对 快速路径 的悲观,而潜在的优化是等待的优化 @AlexGuteniev:是的,没错。 ARMv8 是一个有趣的例子,获取 操作 比获取 fence 便宜得多。许多其他弱序 ISA 将具有等效的栅栏作为获取操作的一部分。但如今 ARM 已成为一个非常相关/重要的 ISA,因此有充分理由关注其特殊情况。 @AlexGuteniev:是的,以只读方式旋转直到看起来有机会获得锁定无论如何都是可取的。我应该提到这一点,但我在想atomic_flag 太原始了,它只是 TAS 和清晰的。但是是的,当然也有测试。 确实如此。 atomic_flag::test 是 C++20 的补充。【参考方案2】:

暂停指令只是替代 N 条 NOP 指令,其中 N 因处理器而异。此外,它对能够乱序执行的处理器中的指令重新排序有影响。 atomic_thread_fence 是否会比“暂停”提供一些好处取决于自旋等待循环等待的典型周期数。 atomic_thread_fence 具有比暂停指令更高的执行延迟。如果自旋等待周期比其他机制(例如在 x86 平台上使用 MONITOR-MWAIT 指令对)大,则可以提供更好的性能并且也更节能。否则暂停就足够了。

【讨论】:

以上是关于是否应该为互斥锁获取-交换循环(或队列获取-加载循环)组合内存栅栏,还是应该避免?的主要内容,如果未能解决你的问题,请参考以下文章

图解Go里面的互斥锁mutex了解编程语言核心实现源码

如果所有相关线程在使用资源之前都尝试获取它们应该获取的锁,互斥锁是不是才能正常工作?

数据结构栈和队列,看完这一篇就够了(万字配动图配习题)

10.22进程互斥锁,队列,堆栈,线程

《并发系列一》AbstractQueuedSynchronizer(AQS)- 互斥锁源码剖析

如何在 C++ 中正确地在循环中使用互斥锁?