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

Why my std::atomic<int> variable isn't thread-safe?

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

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;
}

I used to think I could use std::atomic without a mutex to solve the multi-threading counter increment problem, and it didn't look like the case.

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

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

到目前为止一切顺利。 但是 在步骤 1 和步骤 3 之间,一些其他线程可能更改了 a 的值 - 例如更改为值 54。因此,当步骤 3 将 51 存储到 a 它覆盖值 54 给你你看到的输出。

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

更新

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

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

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

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

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

最后,还有一个问题潜伏在第 6 步。因为 printf() 没有对如果两个线程同时调用 printf() 会发生什么做出任何承诺(至少我没有认为确实如此)。

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

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

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

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

在不同的线程创建之间添加一个小的睡眠(例如,sleep_for(10))会降低这种情况发生的可能性,但仍然不会消除这种可能性。保持输出有序的唯一可靠方法是使用某种排除(如互斥锁)来确保只有一个线程可以访问增量和输出代码,并将增量和输出代码视为必须 运行 在另一个线程尝试执行增量之前一起。

其他答案指出了非原子增量和各种问题。我主要想指出一些有趣的实用细节,关于我们在真实系统上 运行 这段代码时所看到的确切内容。 (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 来统一输出并没有改变行数。它虽然确实移动了一些迟到的打印件;大概有一个线程停滞了一段时间。)我的测试没有检查 lost 计数的可能性,通过不保存存储的值来跳过a,而不是重新加载它。我不确定在没有多个线程读取相同计数的情况下是否有一种合理的方式可以在这里发生这种情况,这将被检测到。

存储+重新加载,即使是在重新加载之前必须刷新存储缓冲区的seq-cst存储,与printf制作write()系统相比也非常快call.(格式字符串包含换行符,我没有将输出重定向到文件,因此 stdout 是行缓冲的,不能只将字符串附加到缓冲区。)

(write() 对同一文件描述符的系统调用在 POSIX 中序列化:write(2) is atomic. Also, printf(3) 本身在 GNU/Linux 上是线程安全的,正如 C++ 所要求的17,可能 POSIX 很久以前。)

printf 中的 Stdio 锁定恰好在几乎所有情况下都足够序列化:刚刚解锁 stdout 并离开 printf 的线程可以执行原子增量,然后尝试再次获取 stdout 锁。

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

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

只是测试这个特定版本的程序(至少在 x86 上)并不能轻易揭示缺乏安全性。 中断或调度变化,包括来自其他事物的竞争运行 在同一台机器上,肯定会在错误的时间阻塞线程。

我的桌面有 8 个逻辑核心,因此每个线程都足够获得一个,而不必取消调度。 (尽管通常这种情况往往会发生在 I/O 或等待锁定时)。


有了 sleep,多个线程几乎同时唤醒并在实际 x86 硬件上相互竞争的可能性不大。 我认为,定时器粒度成为一个因素的时间太长了。或者类似的东西。


将输出重定向到文件

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

(在 运行 ./a.out > output 之后的几分之一秒内,我在 /tmp 中得到了一个 17MiB 的文件。)

这使得线程在实践中实际相互竞争的速度足够快,显示了重复值的预期错误。 (一个线程读取 a 但在存储 (tmp)+1 之前失去了缓存行的所有权,导致两个或多个线程执行相同的增量。 And/or 多个线程在重新加载时读取相同的值a 刷新他们的存储缓冲区后。)

1228589 个唯一行 (sort -u | wc) 但总输出
1291035 总行数。所以大约 5% 的输出行是重复的。

我没有检查它通常是一个值重复多次还是通常只有一个重复。或者价值曾经跳跃了多远。如果一个线程在加载之后但在存储 val+1 之前碰巧被中断处理程序停止,它可能会很远。或者,如果它实际上由于某种原因休眠或阻塞,它可以无限倒回很远。