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

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

本文关键字:线程 安全 变量 int std 我的 atomic lt 为什么 gt      更新时间:2023-10-16

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

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 %dn", 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;
}

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

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

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

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

正如@Sopel和@Shawn在注释中建议的那样,您可以使用适当的函数之一(如fetch_add)或运算符重载(如operator ++operator +=)原子地递增a中的值。 有关详细信息,请参阅 std::atomic 文档

更新

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

在步骤 3. 的存储和步骤 5 的调用 tpa.load()之间。 其他线程可以修改a的内容。 在我们的线程在步骤 3 中将 51 存储在a中后,它可能会发现a.load()在步骤 5 返回一些不同的数字。因此,将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. 线程 Yprintf()向控制台发送 52 执行
  4. 线程 Xprintf()将 51 发送到控制台来执行

我们现在在控制台上打印了 52 个,其次是 51 个。

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

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

注意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将按顺序进行,因此您可以从修改顺序中分辨出哪个线程按哪个顺序进行。


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

如果对顺序一致的存储使用默认a = ...,则不妨在 x86 上执行原子 RMW。 一种很好的编译方法是使用原子xchg,或mov+mfence同样昂贵(或更多)。


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

我在自己的机器上尝试了一下,确实丢失了一些计数。 但是在删除睡眠后,我只是重新排序。(我将大约 1000 行输出复制粘贴到一个文件中,sort -u单化输出并没有改变行数。 不过,它确实移动了一些后期打印;大概有一个线程停滞了一段时间。 我的测试没有检查丢失计数的可能性,而是跳过了不保存存储在a中的值,而是重新加载它。 我不确定是否有一种合理的方法可以在这里发生这种情况,而不会有多个线程读取相同的计数,这会被检测到。

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

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

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

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

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

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

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


有了sleep,多个线程几乎同时唤醒并在真正的 x86 硬件上相互竞争并非不可能。它是如此之长,以至于计时器粒度成为一个因素,我认为。 或类似的东西。


将输出重定向到文件

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

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

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

1228589独特的线路(sort -u | wc),但总输出
1291035总线路。 所以~5%的输出线是重复的。

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