为什么我的 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 %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;
- 首先,以原子方式获取
a
的值。 假设获取的值为 50。 - 我们在该值上加 1,得到 51。
- 最后,我们使用
=
运算符将该值原子地存储到a
中 a
最终是 51- 我们通过调用
a.load()
原子加载a
的值 - 我们通过调用 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 在单个处理器上运行。 一种可能的执行顺序可能是这个...
- 线程 X 执行上述步骤 1 到 5,将
a
从 50 递增到 51,并从a.load()
中获取值 51 - 线程 Y 执行上述步骤 1 到 5,将
a
从 51 递增到 52,并从a.load()
中获取值 52 - 线程 Y
printf()
向控制台发送 52 执行 - 线程 X
printf()
将 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
之前被中断处理程序停滞,则可能会很远。 或者,如果它实际上由于某种原因而睡着或阻塞,它可能会无限期地倒回很远。
- 如何将元素添加到数组的线程安全函数?
- C++中的线程安全删除
- 在std::thread中,joinable()然后join()线程安全吗
- 在c++队列中使用pop和visit实现线程安全
- 以线程安全的方式调用"QQuickPaintedItem::updateImage(const QImage&image)"(no QThread)
- 全局变量 多读取器 一个写入器多线程安全?
- 共享队列的线程安全
- boost::文件系统::recursive_directory_iterator多线程安全
- 以线程安全的方式转换 C/C++ 中时区名称字符串的时区偏移量
- 线程安全运算符<<
- 如何使缓存线程安全
- C++线程安全:如果只有一个线程可以写入非原子变量,但多个线程从中读取. 会遇到问题吗?
- 提升精神 V2 Qi 语法线程安全吗?
- asio 链对象线程安全吗?
- 线程安全队列 c++
- 提供对不同类型的数据(建议、代码审查)的线程安全访问的类
- 如何以线程安全的方式更改目录?
- 线程安全的引用计数队列C++
- 析构函数和线程安全
- 适用于大型数组的无复制线程安全环形缓冲区