使用std::memory_order和三个线程的同步谜语

Synchronization riddle with std::memory_order and three threads

本文关键字:三个 线程 同步 谜语 memory std order 使用      更新时间:2023-10-16

这里有一个关于c++ 11中std::memory_order规则的问题,当涉及到三个线程时。比如,一个线程生产者保存一个值并设置一个标志。然后,另一个线程中继在设置另一个标志之前等待这个标志。最后,第三个线程消费者等待来自中继的标志,这应该表明data已准备好接收消费者

下面是一个最小程序,采用c++参考(http://en.cppreference.com/w/cpp/atomic/memory_order):

)中的示例风格。
#include <thread>
#include <atomic>
#include <cassert>
std::atomic<bool> flag1 = ATOMIC_VAR_INIT(false);
std::atomic<bool> flag2 = ATOMIC_VAR_INIT(false);
int data;
void producer()
{
  data = 42;
  flag1.store(true, std::memory_order_release);
}
void relay_1()
{
  while (!flag1.load(std::memory_order_acquire))
    ;
  flag2.store(true, std::memory_order_release);
}
void relay_2()
{
  while (!flag1.load(std::memory_order_seq_cst))
    ;
  flag2.store(true, std::memory_order_seq_cst);
}
void relay_3()
{
  while (!flag1.load(std::memory_order_acquire))
    ;
  // Does the following line make a difference?
  data = data;
  flag2.store(true, std::memory_order_release);
}
void consumer()
{
  while (!flag2.load(std::memory_order_acquire))
    ;
  assert(data==42);
}
int main()
{
  std::thread a(producer);
  std::thread b(relay_1);
  std::thread c(consumer);
  a.join(); b.join(); c.join();
}

评论:

  1. 第一个功能relay_1()不足,可以触发消费者中的assert。根据上面引用的c++参考,memory_order_acquire关键字"确保在释放相同原子变量的其他线程中的所有写操作在当前线程中都是可见的"。因此,当继电器设置flag2时,data=42可见。它用memory_order_release设置它,这"确保当前线程中的所有写操作在获得相同原子变量的其他线程中可见"。然而,data没有被中继接触,所以消费者可能会以不同的顺序看到内存访问,并且当消费者看到flag2==True时,data可能未初始化。

  2. 同样的参数也适用于relay_2()中更严格的内存排序。顺序一致的排序意味着"在所有标记为std::memory_order_seq_cst的原子操作之间建立同步"。但是,这并没有说明变量data

    还是我理解错了,relay_2()就足够了?

  3. 让我们通过访问data来解决relay_3()中的情况。这里,data = data行意味着在flag1转到true之后读取data,并且在设置flag2之前,中继线程写入data。因此,消费者线程必须看到正确的值。

    然而,这个解决方案似乎有点奇怪。data = data行似乎是编译器(在顺序代码中)会立即优化出来的。

    假行在这里起作用了吗?用c++ 11 std::memory_order特性实现跨三个线程的同步还有更好的方法吗?

顺便说一下,这不是一个学术问题。假设data是一个大的数据块而不是单个整数,并且线程i需要将信息传递给(i+1)-该线程,该线程的数据已被索引≤i的所有线程处理。

编辑:

在阅读了Michael Burr的回答后,很明显relay_1()是足够的。请阅读他的帖子,以获得对这个问题的完全满意的解决方案。c++ 11标准提供了比单独从cppreference.com网站推断出的更严格的保证。因此,考虑迈克尔·伯尔的帖子中的论点是权威的,而不是我上面的评论。方法是在相关事件之间建立"线程间的happens-before"关系(这是可传递的)。

我认为relay_1()足以通过data将值42从生产者传递给消费者。

为了说明这一点,首先我将为感兴趣的操作指定单个字母的名称:
void producer()
{
    /* P */ data = 42;
    /* Q */ flag1.store(true, std::memory_order_release);
}
void relay_1()
{
  while (/* R */ !flag1.load(std::memory_order_acquire))
    ;
  /* S */ flag2.store(true, std::memory_order_release);
}

void consumer()
{
  while (/* T */ !flag2.load(std::memory_order_acquire))
    ;
  /* U */ assert(data==42);
}

我将使用符号A -> B来表示"线程间发生在B之前"(c++ 11 1.10/11)。

我认为PU的明显副作用,因为:

  • P排在Q之前,R排在S之前,T排在U之前(1.9/14)
  • Q同步R, S同步T (29.3/2)

下面的所有点都被"线程间发生在"(1.10/11)的定义所支持:

  • Q -> S因为标准说"线程间发生在评估B之前,如果…对于某些求值X, A与X同步,X在B之前排序"(QR同步,RS之前排序,因此Q -> S)

  • S -> U遵循类似的逻辑(ST同步,TU之前排序,因此S -> U)

  • Q -> U,因为Q -> SS -> U("一个线程间发生在评估B之前,如果…A线程间发生在X之前,X线程间发生在B之前")

最后,

  • P -> U,因为PQQ -> U之前排序("线程间发生在评估B之前,如果…A在X之前排序,X线程间发生在B之前")

由于P线程间发生在U之前,P线程间发生在U(1.10/12)之前,PU(1.10/13)的"可见副作用"。

relay_3()也是足够的,因为data=data表达式是不相关的。

对于这个生产者/消费者问题,relay_2()至少和relay_1()一样好,因为在存储操作中memory_order_seq_cst是释放操作,而在加载操作中memory_order_seq_cst是获取操作(见29.3/1)。所以可以遵循完全相同的逻辑。使用memory_order_seq_cst的操作有一些额外的属性,这些属性与所有memory_order_seq_cst在其他memory_order_seq_cst操作中的排序有关,但这些属性在本例中没有发挥作用。

我认为如果没有这样的传递行为,memory_order_acquirememory_order_release对于实现更高级别的同步对象将不是很有用。