c++ 初始化和使用atomic_flag的方法哪种更好?

dced5bon  于 2023-05-02  发布在  其他
关注(0)|答案(1)|浏览(104)

我看到了两种编写下面示例代码的方法。方法1main()中将两个标志设置为true,在true上将两个线程设置为wait()方法2做的正好相反。它不设置main中的标志,因此false上的线程wait()。一旦第一个标志从main设置为true,第一个线程就会唤醒,clear()将其唤醒并进入临界区(递增data)。这两个方法本质上做的是相同的事情(向另一个线程发送通知),但标志值相反。并且两者都从main(13)返回相同的期望值。

方法一

#include <atomic>
#include <thread>
#include <functional>

void func1( int& data,
            std::atomic_flag& input_data_flag,
            std::atomic_flag& output_data_flag )
{
    input_data_flag.wait( true );
    input_data_flag.test_and_set( std::memory_order_acquire );

    data += 1;

    // Prepare the second flag and notify the second thread
    output_data_flag.clear( std::memory_order_release );
    output_data_flag.notify_one( );
}

void func2( int& data,
            std::atomic_flag& input_data_flag,
            std::atomic_flag& output_data_flag )
{
    input_data_flag.wait( true );
    input_data_flag.test_and_set( std::memory_order_acquire );

    data += 2;

    output_data_flag.clear( std::memory_order_release );
    output_data_flag.notify_one( );

}

int main( )
{
    std::atomic_flag flag1 { };
    std::atomic_flag flag2 { };

    flag1.test_and_set( std::memory_order_acquire );
    flag2.test_and_set( std::memory_order_acquire );

    int data { 10 };

    {
    std::jthread func1_thread { func1, std::ref( data ),
                                std::ref( flag1 ), std::ref( flag2 ) };
    
    std::jthread func2_thread { func2, std::ref( data ),
                                std::ref( flag2 ), std::ref( flag1 ) };

// here the first thread is finally notified to start its task
    flag1.clear( std::memory_order_release );
    flag1.notify_one( );
    }

    return data;
}

方法二

#include <atomic>
#include <thread>
#include <functional>

void func1( int& data,
            std::atomic_flag& input_data_flag,
            std::atomic_flag& output_data_flag )
{
    input_data_flag.wait( false );
    input_data_flag.clear( std::memory_order_release );

    data += 1;

    // Prepare the second flag and notify the second thread
    output_data_flag.test_and_set( std::memory_order_acquire );
    output_data_flag.notify_one( );
}

void func2( int& data,
            std::atomic_flag& input_data_flag,
            std::atomic_flag& output_data_flag )
{
    input_data_flag.wait( false );
    input_data_flag.clear( std::memory_order_release );

    data += 2;

    output_data_flag.test_and_set( std::memory_order_acquire );
    output_data_flag.notify_one( );
}

int main( )
{
    std::atomic_flag flag1 { };
    std::atomic_flag flag2 { };

    int data { 10 };

    {
    std::jthread func1_thread { func1, std::ref( data ),
                                std::ref( flag1 ), std::ref( flag2 ) };
    
    std::jthread func2_thread { func2, std::ref( data ),
                                std::ref( flag2 ), std::ref( flag1 ) };

// here the first thread is finally notified to start its task
    flag1.test_and_set( std::memory_order_acquire );
    flag1.notify_one( );
    }

    return data;
}

哪种方法更有效?或者它们实际上是等效的(尽管方法1多了5条装配线)?
方法2有意义吗?在我看来这不是直觉。例如,每个线程等待一个标志成为true,然后使用clear( std::memory_order_release )将其清除为false,然后进入临界区,在我看来,这似乎不太好。特别是在写入共享资源之前的内存顺序std::memory_order_release对我来说似乎很尴尬(不应该总是std::memory_order_acquire吗?)。在方法2中,应该使用哪些正确的内存顺序?
如果这里有什么不对的地方,请纠正我。

643ylb08

643ylb081#

这不是一个传统的“关键部分”。如果多个线程同时调用具有相同标志的同一个函数,就会出现数据竞争。互斥仅来自.wait()操作链,并且您执行互斥的方式不需要任何昂贵的原子RMW操作,如.test_and_set()
同步仅来自flag.wait(old, seq_cst)(其中seq_cst是.wait()的默认顺序,与所有std::atomic操作一样)。这是矫枉过正;这就是你想要的acquire.wait() works * 就好像 * 它在循环中执行this->load(order),只有在比较为false后才返回(即使底层操作系统有一些虚假的唤醒,这使得以这种方式使用它是安全的,而无需循环自己并使用.test()检查标志值。)
你是对的,在存储之前的release操作对于为它创建同步没有用处,但是即使在方法1中使用TAS(acquire),也没有检查返回值,所以它没有做任何.wait(val, seq_cst)没有做过的事情。
你的方法2实际上被破坏的地方是data += 1;之后的TAS(acquire)。这与.wait(seq_cst)不同步。在实践中,它可以在x86-64上工作,其中每个原子RMW都是一个完整的屏障(与seq_cst一样强),因此只有编译时重新排序才能打破它。甚至x86-64上的普通存储也有发布语义。如果等待线程在第一次调用.wait()时实际上进入睡眠状态,那么它甚至可以在其他ISA上工作。(我认为你的方法1实际上是由ISO C++标准保证正确的。)

.wait(acquire)是合适的。(保证不会提前虚假返回。)以false或true开头并不重要,只要避免使用只能通过原子RMW设置为trueatomic_flag,而原子RMW对您没有任何用处。

如果您希望这些函数在退出之前将它们的传入标志重置回原始状态(本示例不依赖于此),那么您还不如轻松地存储您想要的任何值。
您的main在通过.join()与线程同步后读取data的最终值(隐含在其析构函数中),而不是通过自旋等待第二个函数的output_data_flag更改。这是行不通的你的第一个函数改变了它,第二个函数又把它改回来。因此,如果你要这样做,你需要第三个标志作为第二个函数的输出。
atomic_flag很烂,不支持普通存储,除了通过.clear(),只有昂贵的读取-修改-写入将其设置为true。使用atomic<bool>。它在所有现代平台上都是无锁的。

#include <atomic>

void func1( int& data,
            std::atomic_bool& input_data_flag,
            std::atomic_bool& output_data_flag )
{
    input_data_flag.wait( false, std::memory_order_acquire );   // wait for true
    // input_data_flag.store( false, std::memory_order_relaxed );  // clear it back to false for later use??  Or make this a release-store after access to data if you want the thread that set it to be able to notice when we set it back?

    data += 1;

    // Prepare the second flag and notify the second thread
    output_data_flag.store( true, std::memory_order_release );
    output_data_flag.notify_one( );
}

// func2 is the same but with += 2

output_data_flag.store(true, release)是x86-64(Godbolt)clang的asm中的mov byte ptr [rbx], 1指令。看起来它的.wait()并没有首先乐观地测试该标志,看看它是否已经与您想要的不同。它无条件地对记账变量(?))并调用库函数。因此,根据您的用例,如果您希望已经设置了标志,则可以使用此选项。(就像这里,主线程可能在func1开始运行之前就已经存储到flag1了。)

if (!input_data_flag.load(std::memory_order_acquire) ) {
        input_data_flag.wait( false, std::memory_order_acquire );
    }

绝大多数asm是对.wait().notify_one()的调用。.notify_one()内联,但系统调用除外,这可能是以没有等待者为条件的。
在一些ISA(包括AArch 64)上,有一个永久读取为零的寄存器。这使得存储0false)比任何其他值都要便宜一些。(测试零与非零并不便宜,即使在x86上,可变长度指令经常使小事情变得重要,test eax, eax/jzjnz没有区别。或者在wait()内部,它可能在两个寄存器之间执行cmp)。
一旦您将低效的atomic_flag及其test_and_set()排除在外并使用纯存储,事情就非常对称了,您可以使用任何在语义上更适合您的标志的东西。
对于data ready,从false开始并变为true是一个很好的约定,对我来说很有意义。
静态存储中的标志可以放在BSS中,而不是.data部分,如果它们被初始化为零,所以更喜欢这样。并且作为一个更大的结构的一部分,通常可以与周围的成员沿着廉价地归零。因此,最初将变量构造为false通常是好的,特别是当您同时具有多个变量时。(特别是在结构中;当多个松散标量都是零初始化的,而不是结构成员或数组元素时,GCC会错过优化。)

相关问题