为什么我的std :: atomic <int>变量不是线程安全的?

时间:2019-10-20 22:07:10

标签: c++ thread-safety atomic race-condition stdatomic

我不知道为什么我的代码不是线程安全的,因为它输出了一些不一致的结果。

value 48
value 49
value 50
value 54
value 51
value 52
value 53

我对原子对象的理解是,它防止其中间状态暴露出来,因此当一个线程正在读取它而另一个线程正在写入它时,它应该解决该问题。

我曾经认为我可以使用没有互斥量的std :: atomic来解决多线程计数器增量问题,但情况并非如此。

我可能误解了原子对象是什么,有人可以解释吗?

void
inc(std::atomic<int>& a)
{
  while (true) {
    a = a + 1;
    printf("value %d\n", a.load());
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
  }
}

int
main()
{
  std::atomic<int> a(0);
  std::thread t1(inc, std::ref(a));
  std::thread t2(inc, std::ref(a));
  std::thread t3(inc, std::ref(a));
  std::thread t4(inc, std::ref(a));
  std::thread t5(inc, std::ref(a));
  std::thread t6(inc, std::ref(a));

  t1.join();
  t2.join();
  t3.join();
  t4.join();
  t5.join();
  t6.join();
  return 0;
}

3 个答案:

答案 0 :(得分:6)

  

我曾经认为我可以使用没有互斥量的std :: atomic来解决多线程计数器增量问题,而且情况并非如此。

您可以,但不是您编码的方式。您必须考虑原子访问发生的位置。考虑这一行代码……

a = a + 1;
  1. 首先自动获取a的值。假设获取的值为50。
  2. 我们为该值加1,得到51。
  3. 最后,我们使用a运算符将值自动存储到=
  4. a最终是51
  5. 我们通过调用a来自动加载a.load()的值
  6. 我们通过调用printf()打印刚刚加载的值

到目前为止,一切都很好。 但是,在步骤1和3之间,其他一些线程可能已将a的值更改为例如值54。因此,当步骤3将51存储到a时,它将覆盖值54为您提供了所看到的输出。

正如@Sopel和@Shawn在评论中所建议的那样,您可以使用适当的函数之一(如a)或运算符重载(如fetch_add来自动增加operator ++中的值或operator +=。有关详细信息,请参见std::atomic documentation

更新

我在上面添加了步骤5和6。这些步骤也可能导致看起来不正确的结果。

在步骤3的商店和步骤5的调用tp a.load()之间,其他线程可以修改a的内容。在我们的线程在步骤3将51存储在a中之后,它可能会在步骤5发现a.load()返回一些不同的数字。因此,将a设置为值51的线程可能不会传递该值51到printf()

另一个问题的根源是,没有什么东西协调两个线程之间的步骤5.和6.的执行。因此,例如,假设在单个处理器上运行两个线程X和Y。一种可能的执行顺序可能是……

  1. 线程X执行上述步骤1至5,将a从50递增到51,并从a.load()返回值51。
  2. 线程Y执行上述步骤1至5,将a从51递增到52,并从a.load()返回值52。
  3. 线程Y执行printf(),将52发送到控制台
  4. 线程X执行printf(),将51发送到控制台

我们现在在控制台上打印了52,然后是51。

最后,在步骤6中还存在另一个问题。因为printf()对于两个线程同时调用printf()会发生什么没有做出任何保证(至少我不会认为可以)。

在多处理器系统上,上面的线程X和Y可能在​​两个不同的处理器上的完全相同的时刻(或在完全相同的瞬间的几个滴答之内)调用printf()。我们无法预测哪个printf()输出将首先出现在控制台上。

注意 documentation for printf提到了C ++ 17中引入的一个锁“…,该锁用于防止多个线程读取,写入,定位或查询流的位置时发生数据争用。 ”对于两个线程同时争夺该锁的情况,我们仍然无法确定哪个线程将获胜。

答案 1 :(得分:3)

除了a的增量是非原子的之外,相对于增量,获取要在增量之后显示的值也是非原子的。在当前线程将a递增之后,但要获取要显示的值之前,其他线程之一可能递增。可能会导致两次显示相同的值,而跳过先前的值。

这里的另一个问题是线程不一定按创建顺序运行。线程7可以在线程4、5和6之前但在所有四个线程都增加a之后执行其输出。由于执行最后一个增量的线程会较早显示其输出,因此最终输出将不连续。在少于六个可运行的硬件线程的系统上更可能发生这种情况。

在创建的各个线程之间添加较小的睡眠(例如sleep_for(10))可以减少这种情况的发生,但仍不能消除这种可能性。保证输出有序的唯一肯定方法是使用某种排除(例如互斥锁),以确保只有一个线程可以访问增量和输出代码,并将增量和输出代码都视为必须运行的单个事务。另一个线程尝试执行增量操作之前。

答案 2 :(得分:2)

其他答案指出了非原子增量和各种问题。我主要想指出一些有趣的实用细节,以了解我们在实际系统上运行此代码时所看到的内容。 (x86-64 Arch Linux,gcc9.1 -O3,i7-6700k 4c8t Skylake)。

对于故障排除/调试,了解为什么某些错误或设计选择会导致某些行为可能很有用。


使用int tmp = ++a;将fetch_add结果捕获到本地变量中,而不是从共享变量中重新加载它。 (正如1202ProgramAlarm所说,如果您坚持要计数并正确执行计数,则可能需要将整个增量视为原子事务进行打印。)

或者您可能希望让每个线程在私有数据结构中记录其看到的值以供以后打印,而不是在递增过程中也使用printf来序列化线程。 (实际上,所有尝试增加同一原子变量的尝试都会序列化它们,以等待对缓存行的访问; ++a会按顺序进行,因此您可以从修改顺序中知道哪个线程按顺序进行。)


有趣的事实:a.store(1 + a.load(std:memory_order_relaxed), std::memory_order_release)是您可能对仅由1个线程写入但由多个线程读取的变量可能要执行的操作。您不需要原子RMW,因为没有其他线程修改过它。您只需要一种线程安全的方式来发布更新。 (或者更好的是,在循环中保留一个本地计数器,而只是.store(),而无需从共享变量中加载。)

如果对顺序一致的存储使用默认的a = ...,则可能还需要在x86上执行原子RMW。用原子xchgmov + mfence进行编译的一种好方法是昂贵(或更多)。


有趣的是,尽管您的代码存在大量问题,但没有丢失或增加计数(没有重复计数),只是打印重新排序。因此在实践中,由于发生了其他影响,因此没有遇到危险。

我在自己的计算机上尝试过,但确实丢失了一些计数。但是消除睡眠后,我才开始重新排序。(我将输出的约1000行复制粘贴到文件中,并且sort -u用于统一输出不会改变行数。它确实但是要移动一些较晚的打印内容;大概是一个线程停滞了一段时间。)我的测试没有检查丢失计数的可能性,没有通过将未保存的值保存到{{1} },然后重新加载它。我不确定在没有多个线程读取相同计数的情况下是否有可能发生这种情况。

存储+重新加载,即使是seq-cst存储也必须先刷新存储缓冲区才能重新加载,这与a进行printf系统调用相比非常快。

(格式字符串包括换行符,我没有将输出重定向到文件,因此stdout是行缓冲的,不能只是将字符串附加到缓冲区。)

write()对同一文件描述符的系统调用正在POSIX中进行序列化:write(2)是原子的。此外,printf(3)本身在GNU / Linux上是线程安全的,如C所要求的那样++ 17,并且可能早于POSIX。)

write()中锁定Stdio几乎可以在所有情况下实现足够的序列化:刚刚将stdout解锁并保留printf的线程可以进行原子增量,然后尝试再次获取stdout锁定。

其他线程都被阻塞,试图在stdout上锁定。一个(另一个?)线程可以唤醒并锁定stdout,但是为了使其增量与另一个线程竞争,必须在另一个线程首次提交其内容之前,先进入并离开printf并加载printf a seq-cst存储。

这并不意味着它实际上是安全的

仅仅测试该特定版本的程序(至少在x86上)并不能轻易显示出安全性的缺乏。中断或调度变体,包括与同一台计算机上运行的其他程序的竞争,当然可以在错误的时间阻塞线程。

我的桌面有8个逻辑核心,因此每个线程都有足够的空间来获得一个,而不必进行调度。 (尽管通常这通常会在I / O或反正等待锁定时发生)。


有了a = ...,实际上在真实的x86硬件上,几乎不可能同时唤醒多个线程并相互竞争。我认为计时器粒度成为一个因素。或类似的东西。


将输出重定向到文件

在非TTY文件上打开sleep时,它是全缓冲的,而不是行缓冲的,并且在按住stdout锁时并不总是进行系统调用。 / p>

(在运行stdout之后,我通过按Control-C几分之一秒在/ tmp中获得了一个17MiB文件。)

这使线程在实际中相互竞争的速度足够快,显示了重复值的预期错误。 (一个线程读取./a.out > output,但在存储a之前失去了缓存行的所有权,导致两个或多个线程执行相同的增量。和/或多个线程在重新加载{{ 1}}后刷新其存储缓冲区。)

(tmp)+1唯一的行(a),但总输出为
1228589行。因此,约5%的输出线是重复的。

我没有检查通常是一个值重复多次还是通常只是一个重复值。或价值跳了多远。如果线程在加载之后但在存储sort -u | wc之前碰巧被中断处理程序暂停,则可能很远。或者,如果由于某种原因它实际上睡着了或被挡住了,它可能会无限期地倒带。