我看到了两种编写下面示例代码的方法。方法1在main()
中将两个标志设置为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中,应该使用哪些正确的内存顺序?
如果这里有什么不对的地方,请纠正我。
1条答案
按热度按时间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设置为true
的atomic_flag
,而原子RMW对您没有任何用处。如果您希望这些函数在退出之前将它们的传入标志重置回原始状态(本示例不依赖于此),那么您还不如轻松地存储您想要的任何值。
您的
main
在通过.join()
与线程同步后读取data
的最终值(隐含在其析构函数中),而不是通过自旋等待第二个函数的output_data_flag
更改。这是行不通的你的第一个函数改变了它,第二个函数又把它改回来。因此,如果你要这样做,你需要第三个标志作为第二个函数的输出。atomic_flag
很烂,不支持普通存储,除了通过.clear()
,只有昂贵的读取-修改-写入将其设置为true。使用atomic<bool>
。它在所有现代平台上都是无锁的。output_data_flag.store(true, release)
是x86-64(Godbolt)clang的asm中的mov byte ptr [rbx], 1
指令。看起来它的.wait()
并没有首先乐观地测试该标志,看看它是否已经与您想要的不同。它无条件地对记账变量(?))并调用库函数。因此,根据您的用例,如果您希望已经设置了标志,则可以使用此选项。(就像这里,主线程可能在func1
开始运行之前就已经存储到flag1
了。)绝大多数asm是对
.wait()
和.notify_one()
的调用。.notify_one()
内联,但系统调用除外,这可能是以没有等待者为条件的。在一些ISA(包括AArch 64)上,有一个永久读取为零的寄存器。这使得存储
0
(false
)比任何其他值都要便宜一些。(测试零与非零并不便宜,即使在x86上,可变长度指令经常使小事情变得重要,test eax, eax
/jz
与jnz
没有区别。或者在wait()
内部,它可能在两个寄存器之间执行cmp
)。一旦您将低效的
atomic_flag
及其test_and_set()
排除在外并使用纯存储,事情就非常对称了,您可以使用任何在语义上更适合您的标志的东西。对于data ready,从false开始并变为true是一个很好的约定,对我来说很有意义。
静态存储中的标志可以放在BSS中,而不是
.data
部分,如果它们被初始化为零,所以更喜欢这样。并且作为一个更大的结构的一部分,通常可以与周围的成员沿着廉价地归零。因此,最初将变量构造为false
通常是好的,特别是当您同时具有多个变量时。(特别是在结构中;当多个松散标量都是零初始化的,而不是结构成员或数组元素时,GCC会错过优化。)