【问题标题】:Making data reads/writes atomic in C11 GCC using <stdatomic.h>?使用 <stdatomic.h> 在 C11 GCC 中使数据读取/写入原子?
【发布时间】:2025-12-19 02:00:11
【问题描述】:

我从 SO 线程 herehere 中了解到,假设多线程应用程序中的数据读取/写入在操作系统/硬件级别是原子的并不安全,并且数据损坏可能结果。我想知道使用&lt;stdatomic.h&gt; C11 库和Linux 上的GCC 编译器使int 变量原子读写的最简单方法。

如果我目前在一个线程中有一个int 分配:messageBox[i] = 2,我如何使这个分配原子化?同样适用于阅读测试,例如if (messageBox[i] == 2)

【问题讨论】:

  • 也许像this one 这样的参考资料会有所帮助?
  • 我已经看到了,但由于它仅供参考,我希望这里有人可能有一些我可以理解的代码。引用太简洁了,不知从何说起。
  • 要设置一个原子值你必须store它,要读取一个原子值你必须load它。对于您展示的用例,这基本上是您需要的两个操作(除了初始化之外)。
  • 任何答案都将特定于您使用的任何线程标准或线程库。如果它提供了某种方式来获得原子访问,那么你就使用它。如果没有,那你就不走运了。 (假设你想编写可移植的代码。)

标签: c multithreading c11 atomic


【解决方案1】:

对于 C11 原子,您甚至不必使用函数。如果您的实现(= 编译器)支持原子,您只需将原子说明符添加到变量声明中,然后对其进行的所有操作都是原子的:

_Atomic(int) toto = 65;
...
toto += 2;  // is an atomic read-modify-write operation
...
if (toto == 67) // is an atomic read of toto

原子有其代价(它们需要更多的计算资源),但只要您很少使用它们,它们就是同步线程的完美工具。

【讨论】:

  • 优雅。简洁但充分的解释!
  • 谢谢,这正是我开始所需要的。我在 CPPreference 网站上看到使用宏变得更加简单,我现在使用 `volatile atomic_int x;" 来声明原子变量。(我很欣赏围绕“volatile”的争议,但将它与原子类型一起使用并没有害处,而且可能会有所帮助。)
【解决方案2】:

如果我当前在线程中有一个 int 赋值:messageBox[i] = 2,我如何使这个赋值原子化?同样适用于阅读测试,例如 if (messageBox[i] == 2)。

您几乎不需要做任何事情。在几乎所有情况下,您的线程共享(或与之通信)的数据都受到保护,不会通过互斥锁、信号量等进行并发访问。基础操作的实现保证了内存的同步。

这些原子的原因是为了帮助您在代码中构建更安全的竞争条件。它们有许多危险;包括:

ai += 7;

如果适当地定义了ai,将使用原子协议。试图破译竞争条件并不能通过模糊实现来帮助。

它们还有一个高度依赖机器的部分。例如,上面的行在某些平台上可能 fail [1],但是该失败是如何传达回程序的呢?它不是 [2]。

只有一个操作可以选择处理失败; atomic_compare_exchange_(弱|强)。弱只尝试一次,并让程序选择如何以及是否重试。强无休止地重试。仅尝试一次是不够的——可能会发生由于中断而导致的虚假故障——但对非虚假故障进行无休止的重试也是不好的。

可以说,对于健壮的程序或广泛适用的库,您应该使用的唯一一点是 atomic_compare_exchange_weak()。

[1] 加载链接、条件存储 (ll-sc) 是在异步总线架构上进行原子事务的常用方法。加载链接在缓存行上设置一个小标志,如果任何其他总线代理尝试修改该缓存行,该标志将被清除。如果在缓存中设置了小标志,则存储条件存储一个值,并清除该标志;如果标志被清除,Store-conditional 会发出错误信号,因此可以尝试适当的重试操作。通过这两个操作,您可以在完全异步的总线架构上构建任何您喜欢的原子操作。

ll-sc 可以对位置的缓存属性有微妙的依赖关系。允许的缓存属性取决于平台,在 ll 和 sc 之间可以执行哪些操作。

如果你对缓存不佳的访问进行 ll-sc 操作,然后盲目地重试,你的程序将被锁定。这不仅仅是猜测。我必须在基于 ARMv7 的“安全”系统上调试其中一个。

[2]:

#include <stdatomic.h>
int f(atomic_int *x) {
    return (*x)++;
}
f:
        dmb     ish
.L2:
        ldrex   r3, [r0]
        adds    r2, r3, #1
        strex   r1, r2, [r0]
        cmp     r1, #0
        bne     .L2       /* note the retry loop */
        dmb     ish
        mov     r0, r3
        bx      lr

【讨论】:

    【解决方案3】:

    假设读/写数据是不安全的 多线程应用程序在操作系统/硬件级别是原子的,并且 可能导致数据损坏

    实际上非复合 对像int 这样的类型的操作在所有合理的架构上都是原子的。你读到的只是一个骗局。

    (增量是一个复合操作:它有一个读、一个计算和一个写组件。每个组件都是原子的,但整个复合操作是不是。)

    但硬件级别的原子性不是问题。您使用的高级语言根本不支持对常规类型的那种操作。您需要使用原子类型甚至有权以与原子性问题相关的方式操作对象:当您可能修改另一个线程中正在使用的对象时。

    (或 volatile 类型。但不要使用 volatile。使用原子。)

    【讨论】:

    • “实际上,像 int 这样的类型的操作在所有合理的架构上都是原子的。你读到的只是一个骗局。”您是在建议人们依靠他们使用可移植代码吗?还是您建议人们不要编写可移植的多线程代码?
    • @DavidSchwartz 假设是 100% 可移植的。
    • @curiousguy:假设一个目标是原始的 80386,不必与 DMA 共存,但不允许禁用中断。并不是说它在当前使用中,而是没有委员会担心的其他人那么晦涩难懂,而且它是我熟悉的架构。如果想要一个函数来减少地址处的uint16_t 并报告它是否变为零,那么可以在机器代码中轻松实现pop edx / pop ebx / xor eax,eax / dec word [ebx] / jz wasZero / inc eax / wasZero: jmp [edx]。请注意,所涉及的uint16_t 没有什么特别之处。
    • @curiousguy:但是,如果想要一个可以自动递减计数器并报告新值(而不仅仅是它是否变为零)的函数,则不能再简单地使用简单的uint16_t ,但必须将uint16_t 与某种形式的互斥锁配对以防止同时访问,并且对该对象的所有操作都必须通过互斥锁。如果需要在由不同实现(例如程序和设备驱动程序)处理的两段代码之间共享第一种样式的计数器,则每个都可以实现“dec and report if become zero”...
    • ...独立,不必知道彼此的存在。然而,互斥锁的变化只有在所有要访问计数器的东西都知道互斥锁并以兼容的方式管理它的情况下才会起作用——这不太可能发生。然而,C11 原子要求 16 位原子值是一个可怕的无用互斥体怪物,而不是与宇宙中其他一切兼容的简单 16 位无符号整数。