如果我并不总是需要获取语义,那么使用宽松负载和条件围栏是否有意义?

Hol*_*Cat 9 c++ multithreading atomic micro-optimization memory-barriers

考虑以下玩具示例,尤其是result函数:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>

class Worker
{
    std::thread th;
    std::atomic_bool done = false;

    int value = 0;

  public:
    Worker()
        : th([&]
    {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        value = 42;
        done.store(true, std::memory_order_release);
    }) {}

    int result() const
    {
        return done.load(std::memory_order_acquire) ? value : -1;
    }

    Worker(const Worker &) = delete;
    Worker &operator=(const Worker &) = delete;

    ~Worker()
    {
        th.join();
    }
};

int main()
{
    Worker w;
    while (true)
    {
        int r = w.result();
        if (r != -1)
        {
            std::cout << r << '\n';
            break;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

我认为只有在done.load()returns时我才需要获取语义true,所以我可以像这样重写它:

int result() const
{
    if (done.load(std::memory_order_relaxed))
    {
        std::atomic_thread_fence(std::memory_order_acquire);
        return value;
    }
    else
    {
        return -1;
    }
}
Run Code Online (Sandbox Code Playgroud)

这似乎是合法的事情,但我缺乏经验来判断这种变化是否有意义(是否更优化)。

我应该选择这两种形式中的哪一种?

Pet*_*des 1

如果大多数检查done发现它没有完成,并且发生在程序的吞吐量敏感部分,那么这可能是有意义的,即使在单独的屏障成本更高的 ISA 上也是如此。也许像立即退出标志这样的用例也表示线程需要的一些数据或指针。您经常检查,但大多数时候您不会退出,也不需要后续操作来等待此加载完成。


这在某些 ISA 上是一个胜利(其中加载(获取)已经是加载+屏障),但在其他 ISA 上通常更糟,特别是如果我们最关心的情况(“快速路径”)是加载value. (在 ISA 上,栅栏(获取)比加载(获取)更昂贵,尤其是具有 ARMv8 新指令的 32 位 ARM:lda只是获取加载,但栅栏仍然是一个dmb ish完整的屏障。)

如果这种!done情况很常见并且还有其他工作要做,那么也许值得考虑权衡,因为std::memory_order_consume目前无法用于其预期目的。(请参阅下面的回复:内存依赖性排序,毫无障碍地解决此特定情况。)

对于其他常见的 ISA,不,它没有意义,因为它会使“成功”情况变慢,如果最终遇到完整的障碍,可能会慢得多。如果这是该函数的正常快速路径,那显然会很糟糕。


在 x86 上没有区别:fence(acquire) 是无操作,load(acquire) 使用与 load(relaxed) 相同的 asm。这就是为什么我们说x86的硬件内存模型是“强有序”的。大多数其他主流 ISA 并不是这样的。

对于某些 ISA,这在这种情况下是纯粹的胜利。done.load(acquire)对于使用普通负载实现的 ISA ,然后fence(acquire)将使用相同的屏障指令(例如 RISC-V 或没有 ARMv8 指令的 32 位 ARM)。无论如何,它们必须分支,所以这只是我们相对于分支放置障碍的位置。(除非他们选择无条件加载value和无分支选择,例如 MIPS movn,这是允许的,因为他们已经加载了该对象的另一个成员class Worker,因此它被认为是指向完整对象的有效指针。)


AArch64 可以相当便宜地获取负载,但获取屏障会更昂贵。(并且会发生在通常是快速路径上;加速“失败”路径通常并不重要。)。

第二次加载(这次是获取)可能会更好,而不是屏障。如果标志只能从 0 变为 1,则甚至不需要重新检查其值;对同一原子对象的访问是在同一线程内排序的。

(我有一个 Godbolt 链接,其中包含许多 ISA 的一些示例,但浏览器重新启动后就失效了。)


内存依赖顺序可以无障碍地解决这个问题

不幸的std::memory_order_consume是,它暂时被弃用,否则您可以通过创建一个&value具有数据依赖性的指针来在这种情况下获得两全其美的效果done.load(consume)。因此, 的加载value(如果完成的话)将在加载 from 后按依赖顺序排序done,但其他独立的后续加载不必等待。

例如if ( (tmp = done.load(consume)) )return (&value)[tmp-1]。这在 asm 中很容易,但如果没有完全有效的consume支持,编译器将优化tmp只能通过tmp = true.

因此,唯一真正需要在 asm 中进行这种障碍权衡的 ISA 是 Alpha,但由于 C++ 的限制,我们无法轻松利用其他 ISA 提供的硬件支持。

如果您愿意使用在实践中可行的东西,尽管没有保证,请使用std::atomic<int *> done = nullptr;并执行 的发布存储,&value而不是=true. 然后在阅读器中进行加载relaxed,然后if(tmp) { return *tmp; } else { return -1; }。如果编译器无法证明唯一的非空指针值是&value,则需要保持对指针加载的数据依赖性。(为了阻止它证明这一点,也许可以包含一个set在 中存储任意指针的成员函数done,而您永远不会调用该函数。)

有关详细信息,请参阅C++11:memory_order_relaxed 和 memory_order_consume 之间的差异consume,以及 Paul E. McKenney 的 CppCon 2016 演讲的链接,他在其中解释了应该做什么,以及 Linux RCU 如何使用我建议的那种东西,有效地放松了负载并依赖于编译器来使汇编具有数据依赖性。(这需要小心,不要编写可以优化数据依赖性的内容。)