下面的代码显示了两种通过原子标记获取共享状态的方法。读取器线程调用poll1()poll2()来检查写入器是否已发出标志信号。

投票选项1:

bool poll1() {
    return (flag.load(std::memory_order_acquire) == 1);
}

投票选项2:
bool poll2() {
    int snapshot = flag.load(std::memory_order_relaxed);
    if (snapshot == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        return true;
    }
    return false;
}

请注意,选项#1是presented in an earlier question,而选项#2与example code at cppreference.com相似。

假设读者同意只在poll函数返回true时检查共享状态,那么这两个poll函数既正确又等效吗?

选项#2是否具有标准名称?

每种选择的优点和缺点是什么?

在实践中,选项2可能更有效吗?效率可能降低吗?

这是一个完整的工作示例:
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>

int x; // regular variable, could be a complex data structure

std::atomic<int> flag { 0 };

void writer_thread() {
    x = 42;
    // release value x to reader thread
    flag.store(1, std::memory_order_release);
}

bool poll1() {
    return (flag.load(std::memory_order_acquire) == 1);
}

bool poll2() {
    int snapshot = flag.load(std::memory_order_relaxed);
    if (snapshot == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        return true;
    }
    return false;
}

int main() {
    x = 0;

    std::thread t(writer_thread);

    // "reader thread" ...
    // sleep-wait is just for the test.
    // production code calls poll() at specific points

    while (!poll2()) // poll1() or poll2() here
      std::this_thread::sleep_for(std::chrono::milliseconds(50));

    std::cout << x << std::endl;

    t.join();
}

最佳答案

我想我可以回答您的大多数问题。

两种选择当然都是正确的,但由于独立式围栏的适用性稍广(它们在您要完成的工作上是等效的,因此它们在技术上可能适用,但在技术上也适用)好-想象一下是否内联这段代码)。 this post by Jeff Preshing中说明了一个独立的篱笆与存储/获取篱笆有何不同的示例。

据我所知,选项#2中的“检查-然后-围栏”模式没有名称。不过,这并不少见。

在性能方面,在x64(Linux)上使用我的g++ 4.8.1时,两个选项生成的程序集都归结为一条加载指令。鉴于x86(-64)的加载和存储无论如何都具有在硬件级别的获取和释放语义,这不足为奇(x86以其相当强大的内存模型而闻名)。

但是,对于ARM而言,在内存障碍编译成实际的单个指令的情况下,会产生以下输出(将gcc.godbolt.com-O3 -DNDEBUG一起使用):

对于while (!poll1());:

.L25:
    ldr     r0, [r2]
    movw    r3, #:lower16:.LANCHOR0
    dmb     sy
    movt    r3, #:upper16:.LANCHOR0
    cmp     r0, #1
    bne     .L25

对于while (!poll2());:
.L29:
    ldr     r0, [r2]
    movw    r3, #:lower16:.LANCHOR0
    movt    r3, #:upper16:.LANCHOR0
    cmp     r0, #1
    bne     .L29
    dmb     sy

您可以看到唯一的区别是同步指令(dmb)的放置位置– poll1的循环内,以及poll2的循环后。因此,在这种实际情况下,poll2确实更有效:-)(但请继续阅读以了解为什么如果在循环中调用它们以阻塞直到标志更改,这可能无关紧要。)

对于ARM64,输出是不同的,因为存在内置了屏障的特殊加载/存储指令(ldar-> load-acquire)。

对于while (!poll1());:
.L16:
    ldar    w0, [x1]
    cmp     w0, 1
    bne     .L16

对于while (!poll2());:
.L24:
    ldr     w0, [x1]
    cmp     w0, 1
    bne     .L24
    dmb     ishld

同样,poll2导致一个循环,该循环内部没有障碍,外部没有障碍,而poll1每次穿过都会产生障碍。

现在,实际上哪一个性能更高才需要运行基准测试,但是不幸的是我没有相应的设置。违反直觉,poll1poll2在这种情况下可能同样有效,因为如果标志变量是无论如何都需要传播的那些影响之一,那么花费额外的时间等待内存效应在循环内传播实际上并不会浪费时间。 (即,即使单独(内联的)对poll1的调用比对poll2的调用花费的时间更长,直到循环退出为止所花费的总时间可能是相同的)。当然,这是假设循环等待标志更改-与对poll1的单独调用相比,对poll2的单个调用确实需要更多的工作。

因此,我认为总体上可以肯定地说,poll2的效率永远不会比poll1显着降低,并且通常可以更快,只要编译器可以在内联时消除该分支即可(至少这三个情况是如此)。流行的架构)。

我的(略有不同)测试代码供引用:
#include <atomic>
#include <thread>
#include <cstdio>

int sharedState;
std::atomic<int> flag(0);

bool poll1() {
    return (flag.load(std::memory_order_acquire) == 1);
}

bool poll2() {
    int snapshot = flag.load(std::memory_order_relaxed);
    if (snapshot == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        return true;
    }
    return false;
}

void __attribute__((noinline)) threadFunc()
{
    while (!poll2());
    std::printf("%d\n", sharedState);
}

int main(int argc, char** argv)
{
    std::thread t(threadFunc);
    sharedState = argc;
    flag.store(1, std::memory_order_release);
    t.join();
    return 0;
}

09-10 05:18