在64位计算机中,哪些类型在gnu C和gnu C++中是自然原子的?-- 意思是它们具有原子读取和原子写入。

9
注意:本问题中,我并不是在谈论 C 或 C++ 语言标准。相反,我是在谈论特定架构下的 gcc 编译器实现,因为语言标准所作出的关于原子性的唯一保证是在 C11 或更高版本使用 _Atomic 类型或在 C++11 或更高版本使用 std::atomic<> 类型。请参阅本问题底部的更新。
在任何架构上,有些数据类型可以被原子性读取和写入,而其他类型将需要多个时钟周期,并且在操作过程中可能会被中断,如果这些数据被在线程间共享,则可能导致损坏。
在 8 位单核 AVR 微控制器上(例如 Arduino Uno、Nano 或 Mini 使用的 ATmega328 MCU),仅具有 8 位数据类型的原子读取和写入(在 gcc 编译器和 gnu C 或 gnu C++ 语言下)。我经历了超过 25 小时的调试马拉松,然后在不到 2 天内编写了此答案。有关 AVR 8 位微控制器在使用 AVR-libc 库编译的 gcc 编译器下具有自然原子写入和自然原子读取的更多信息和文档,请参见本问题底部。
在(32 位)STM32 单核微控制器上,任何 32 位或更小的数据类型都是确定自动原子性的(在使用 gcc 编译器和 gnu C 或 gnu C++ 语言编译时,在 ISO C 和 C++ 中直到 2011 版本才有 _Atomic 类型和 std::atomic<> 类型的保证)。这包括 bool/_Bool、int8_t/uint8_t、int16_t/uint16_t、int32_t/uint32_t、float 和所有指针。唯一的非原子类型是 int64_t/uint64_t、double(8 字节)和 long double(也是 8 字节)。我在此写了相关内容:
  1. 在STM32微控制器上,哪些变量类型/大小是原子的?
  2. 读取由ISR更新的64位变量
  3. 为了实现原子访问保护,在STM32微控制器中禁用和重新启用中断的各种方法是什么?

现在我需要知道在我的64位Linux计算机上哪些类型是确定自动原子的?

我的计算机有一个x86-64处理器和Linux Ubuntu操作系统。

我可以使用Linux头文件和gcc扩展程序。

我在gcc源代码中看到了一些有趣的东西,表明至少32位的int类型是原子的。例如,Gnu++头文件<bits/atomic_word.h>存储在我的计算机上的/usr/include/x86_64-linux-gnu/c++/8/bits/atomic_word.h,并且在这里在线,其中包含以下内容:

typedef int _Atomic_word;

所以,int 显然是原子性的。

而 Gnu++ 头文件 <bits/types.h>,被 <ext/atomicity.h> 包含,在我的计算机上存储在 /usr/include/x86_64-linux-gnu/bits/types.h 中,其包含了以下内容:

/* C99: An integer type that can be accessed as an atomic entity,
   even in the presence of asynchronous interrupts.
   It is not currently necessary for this to be machine-specific.  */
typedef int __sig_atomic_t;

因此,int 显然是原子性的。
以下是一些示例代码,以展示我所说的内容...
当我说我想知道哪些类型具有自然的原子读取和自然的原子写入,但不具有原子增量、减量或复合赋值时,就是这个意思。
volatile bool shared_bool;
volatile uint8_t shared u8;
volatile uint16_t shared_u16;
volatile uint32_t shared_u32;
volatile uint64_t shared_u64;
volatile float shared_f; // 32-bits
volatile double shared_d; // 64-bits

// Task (thread) 1
while (true)
{
    // Write to the values in this thread.
    //
    // What I write to each variable will vary. Since other threads are reading
    // these values, I need to ensure my *writes* are atomic, or else I must
    // use a mutex to prevent another thread from reading a variable in the
    // middle of this thread's writing.
    shared_bool = true;
    shared_u8 = 129;
    shared_u16 = 10108;
    shared_u32 = 130890;
    shared_f = 1083.108;
    shared_d = 382.10830;
}

// Task (thread) 2
while (true)
{
    // Read from the values in this thread.
    //
    // What thread 1 writes into these values can change at any time, so I need
    // to ensure my *reads* are atomic, or else I'll need to use a mutex to
    // prevent the other thread from writing to a variable in the midst of
    // reading it in this thread.
    if (shared_bool == whatever)
    {
        // do something
    }
    if (shared_u8 == whatever)
    {
        // do something
    }
    if (shared_u16 == whatever)
    {
        // do something
    }
    if (shared_u32 == whatever)
    {
        // do something
    }
    if (shared_u64 == whatever)
    {
        // do something
    }
    if (shared_f == whatever)
    {
        // do something
    }
    if (shared_d == whatever)
    {
        // do something
    }
}

C _Atomic 类型和 C++ std::atomic<> 类型

我知道 C11 及之后的版本提供了 _Atomic 类型,例如下面这个:

const _Atomic int32_t i;
// or (same thing)
const atomic_int_least32_t i;

请看这里:

  1. https://zh.cppreference.com/w/c/thread
  2. https://zh.cppreference.com/w/c/language/atomic

C++11及更高版本提供了像下面这样的std::atomic<>类型:

const std::atomic<int32_t> i;
// or (same thing)
const atomic_int32_t i;

请见以下内容:

请参见:

  1. https://en.cppreference.com/w/cpp/atomic/atomic

C11和C++11中的“原子”类型提供原子读取和原子写入,以及原子递增运算符、递减运算符和复合赋值...

... 但那并不是我要说的。

我想知道哪些类型只具有自然原子读取和写入。对于我所说的内容,递增、递减和复合赋值将会是非自然原子的。


2022年4月14日更新

我与ST公司的一位人员进行了一些交流,似乎STM32微控制器仅在以下情况下保证特定大小变量的原子读写:

  1. 使用汇编语言。
  2. 使用C11的_Atomic类型或C++11的std::atomic<>类型。
  3. 使用带gnu语言和gcc扩展的gcc编译器。
    1. 我最感兴趣的是这个,因为我过去的10年里一直以此为前提,没有意识到这一点。我希望能找到gcc编译器手册以及其中解释这些原子访问保证的地方。我们应该检查:
      1. 8位AVR ATmega微控制器的AVR gcc编译器手册。
      2. 32位ST微控制器的STM32 gcc编译器手册。
      3. 我的64位Ubuntu计算机上是否存在x86-64 gcc编译器手册?

到目前为止我的研究:

  1. AVR gcc: 没有 AVR gcc 编译器手册。相反,在这里使用 AVR-libc 手册:https://www.nongnu.org/avr-libc/ --> "Users Manual" 链接。

    1. AVR-libc用户手册中的 <util/atomic> 部分 支持了我的观点,即对于 AVR 上编译时使用 gcc 编译的 8 位类型,已经具有 自然的原子读取自然的原子写入,它通过下面的语句来表明 8 位读写在已经是原子操作 (强调已添加):

    Typical example that requires atomic access is a 16 (or more) bit variable that is shared between the main execution path and an ISR.

    1. 它正在讨论 C 代码,而不是汇编代码,因为在该页面提供的所有示例都是以 C 语言形式给出的,包括紧随该引用之后的 volatile uint16_t ctr 变量的示例。

3
这取决于处理器和编译器。看起来你只关心x86-64和gcc的情况,因为你在查看内部头文件。但我不确定。如果你想要一个可移植的答案,请使用is_always_lock_free来检测哪些类型是原子读取/更新的。(并且你必须使用atomic<>才能获得原子行为。) - Raymond Chen
7
问题是,编程语言中的原子操作概念与硬件并不完全对应。语言规定只有显式声明为原子的操作才是原子操作。更糟糕的是,C++ 规定任何类型都可以用于 std::atomic。因此问题可能是,哪些原子类型是无锁的?但这还不是全部,即使是无锁的原子类型也存在不是单个指令的原子操作。 - Passer By
4
据我理解,std::atomic<>::is_always_lock_free()函数返回true当且仅当编译器可以保证该std::atomic类型永远不需要隐式锁定/解锁互斥锁来实现其原子性保证。这可能是您想要的。 - Jeremy Friesner
7
一个极其普遍的误解是,只因为编译器可以在一条指令中读取某个特定大小的数据,使用该大小或更小的变量的代码就会神奇地变成原子操作。这种假设只适用于汇编语言,而不适用于C语言。请参阅此处:在嵌入式C开发中使用volatile关键字 该答案还包含了一种比您提供的答案更简单更好的保护MCU系统中的变量免受竞争条件的方法,只需使用一个布尔标志变量即可。 - Lundin
4
存在两个问题:(1) CPU可以原子性地执行什么操作?A:查看CPU数据手册。(2) 我如何说服编译器执行这些操作?A:使用语言定义的原子数据类型。在C++中,您可以使用static_assert(std::atomic<int32_t>::is_always_lock_free())来验证编译器是否支持底层CPU操作,然后使用value.load(std::memory_order_relaxed)来执行无序读取或value.store(newvalue, std::memory_order_relaxed) 执行无序写入。无序读取/写入几乎总是编译为单个加载或存储指令。 - Raymond Chen
显示剩余16条评论
2个回答

19
从语言标准的角度来看,答案非常简单:它们都不是“绝对自动”原子性的
首先,重要的是要区分“原子性”的两个意义。
  • 一个是在信号方面原子化。这确保了,例如,在volatile sig_atomic_t上执行x = 5时,当前线程中调用的信号处理程序将看到旧值或新值。这通常通过在一条指令中进行访问来实现,因为信号只能由硬件中断触发,而硬件中断只能在指令之间到达。例如,x86 add dword ptr [var], 12345,即使没有lock前缀,在这个意义上也是原子的。

  • 另一个是在线程方面原子化,以便同时访问对象的另一个线程会看到正确的值。这更难以正确地实现。特别是,类型为volatile sig_atomic_t的普通变量是对线程原子的。你需要使用_Atomicstd::atomic才能获得这个功能。

请注意,您的实现选择其类型的内部名称并不意味着任何东西。从“typedef int _Atomic_word;”中,我肯定不会推断出“int明显是原子性的”。我不知道实现者使用“原子”一词的含义,或者它是否准确(例如可能被遗留代码使用)。如果他们想要做出这样的承诺,那么它将在文档中而不是在一个未经解释的“typedef”中,在一个“bits”头文件中,该文件永远不会被应用程序员看到。

你的硬件可能会使某些类型的访问“自动原子化”,但这并不告诉你在C/C++级别上任何信息。例如,在x86上,对自然对齐变量的普通全尺寸加载和存储是原子的。但在没有std::atomic的情况下,编译器没有义务发出普通的全尺寸加载和存储指令;它有权巧妙地访问这些变量。它“知道”这不会有问题,因为并发访问将导致数据竞争,当然程序员永远不会写有数据竞争的代码,对吗?

作为一个具体的例子,考虑以下代码:

unsigned x;

unsigned foo(void) {
    return (x >> 8) & 0xffff;
}

一个漂亮的32位整数变量的负载,接着进行一些算术运算。有什么比这更无辜的呢?然而检查一下GCC 11.2编译器生成的汇编代码,使用-O2参数在godbolt上试一下
foo:
        movzx   eax, WORD PTR x[rip+1]
        ret

哦,亲爱的。部分加载,而且还不对齐。

幸运的是,x86确保在P5 Pentium或更高版本上,即使不对齐,16位加载或存储也是原子性的,只要它包含在对齐的双字内。实际上,在x86-64上,任何适合于对齐的8字节内的1、2或4字节加载或存储都是原子性的,因此即使xstd::atomic<int>,这仍然是一个有效的优化。但在这种情况下,GCC将会错过这个优化。

Intel和AMD分别保证了这一点。Intel适用于P5 Pentium及更高版本,包括所有其x86-64 CPU。没有单独列出原子性保证的单一“x86”文档。一个stack overflow answer结合了这两个厂商的保证;据推测,这在其他厂商如Via / Zhaoxin上也是原子性的。

希望在任何模拟器或二进制转换器中都能保证这个x86指令被转换成AArch64机器码,但如果主机机器上没有匹配的原子性保证,那肯定是需要担心的事情。


这里是另一个有趣的例子,这次是关于 ARM64 的。根据 ARMv8-A 架构参考手册 B2.2.1 章节,对齐的 64 位存储是原子的。所以看起来很好:

unsigned long x;

void bar(void) {
    x = 0xdeadbeefdeadbeef;
}

然而,GCC 11.2 -O2 给出的结果是 (godbolt):

bar:
        adrp    x1, .LANCHOR0
        add     x2, x1, :lo12:.LANCHOR0
        mov     w0, 48879
        movk    w0, 0xdead, lsl 16
        str     w0, [x1, #:lo12:.LANCHOR0]
        str     w0, [x2, 4]
        ret

这是两个32位的str,没有任何原子性。读者很可能会读取0x00000000deadbeef

为什么要这样做?在ARM64上,将64位常量实现到寄存器中需要几条指令,由于其固定的指令大小。但是值的两个半部分相等,那么为什么不将32位值实现并将其存储到每个半部分中呢?

(如果您执行unsigned long *p; *p = 0xdeadbeefdeadbeef,则会得到stp w1, w1, [x0]godbolt)。它看起来更有前途,因为它是单个指令,但在基本的ARMv8-A中,实际上仍然是两个独立的写操作,用于线程之间的原子性。LSE2功能,在ARMv8.2-A中是可选的,在ARMv8.4-A中是强制性的,可以在合理的对齐条件下使ldp/stp成为原子操作。)


用户supercat对并发无序写入共享内存是否未定义行为?的回答,提供了ARM32 Thumb的另一个很好的例子,其中C源代码要求加载unsigned short一次,但生成的代码却加载了两次。在存在并发写入的情况下,您可能会得到一个“不可能”的结果。

在x86-64上也可以引发相同的问题(godbolt):

_Bool x, y, z;

void foo(void) {
    _Bool tmp = x;
    y = tmp;
    // imagine elaborate computation here that needs lots of registers
    z = tmp;
}

GCC会重新加载x而不是溢出tmp。在x86上,您可以只用一条指令加载全局变量,但是将其溢出到堆栈需要至少两条指令。因此,如果x正在被并发修改,无论是通过线程还是通过信号/中断,之后的assert(y == z)可能会失败。

除非你使用std::atomic,否则不能安全地假设编程语言会保证任何事情,实际上它们什么都不保证。现代编译器非常了解语言规则的确切限制,并且会积极进行优化。如果代码假设编译器会按照“自然”的方式执行操作,但这超出了语言所承诺的范围,编译器可能会打破这种假设,并且通常以意想不到的方式执行。


3
@GabrielStaples 说:除非在编译器手册中看到明确的承诺,否则我不会感觉安全。C/C++ 的本质是,“我从未遇到过问题”并不足以证明“它是正确的”。在我的 ARM64 示例中,你可能一生都在将常量存储到 64 位变量中并发现它们是原子的,直到有一天有人将 0xdeadbeefdeadbeee 更改为 0xdeadbeefdeadbeef,然后你就可以享受到难以理解的错误报告了。 - Nate Eldredge
AVR和STM32都是单核的。这可能会有所不同。 - Gabriel Staples
2
是的,在单核CPU和单处理器系统中,通常只需要考虑中断相关的原子性,这大致相当于我第一个要点中的“与信号相关的原子性”。值得注意的是,数据竞争规则不适用,而volatile sig_atomic_t被定义为足够使用。仅仅使用volatile本身通常就足够满足您的需求;编译器更有可能正式或非正式地承诺这一点。 - Nate Eldredge
1
资源:对于x86,有很多资源可以在这里找到。对于ARM64,正式的参考资料是架构参考手册;我发现Cortex-A程序员指南更适合作为学习文本。 - Nate Eldredge
2
据我所知,x86不提供关于未对齐加载的原子性承诺。实际上,AMD和Intel的共同保证子集确保在P5 Pentium或更新版本中从不可缓存内存中原子地加载对齐的dword的中间16位。为什么x86上自然对齐变量的整数赋值是原子的?通常情况下,未对齐的字加载不是原子性的,因为它们可能跨越更宽的边界。但在32位块内总是安全的,在可缓存内存中的qword内也是如此。 - Peter Cordes
显示剩余17条评论

9
在8位AVR微控制器上(例如Arduino Uno或Mini使用的ATmega328 mcu),只有8位数据类型具有原子读写。
只有在您使用汇编语言而不是C时才能实现。
在(32位)STM32微控制器上,任何32位或更小的数据类型都是确定自动原子的。
只有在您使用汇编语言而不是C时才能实现。此外,仅当ISA保证生成的指令是原子的时,我不记得这对所有ARM指令都成立。
这包括bool/_Bool、int8_t/uint8_t、int16_t/uint16_t、int32_t/uint32_t、float和所有指针。
不,那绝对是错误的。
现在我需要知道我的64位Linux计算机。哪些类型肯定是自动原子的?
与AVR和STM32相同的类型:没有。
这一切归结为,在C中无法保证变量访问是原子的,因为它可能会在多个指令中执行。或者在某些情况下,ISA不能保证原子性的指令中执行。

在C(和C ++)中,唯一可以被视为原子的类型是具有C11 / C ++ 11中的_Atomic限定符的类型。就是这样。

我在EE here上的答案是一个重复的答案。它明确解决了微控制器情况,竞争条件,使用volatile,危险的优化等问题。它还包含一种简单的方法来保护中断中的竞争条件,适用于所有中断不能被中断的MCU。该答案的引用:

在编写C时,ISR和后台程序之间的所有通信都必须受到竞争条件的保护。每次都要这样做,没有例外。 MCU数据总线的大小并不重要,因为即使您在C中进行单个8位复制,语言也无法保证操作的原子性。除非您使用C11功能_Atomic。如果没有此功能,则必须使用某种信号量或在读取期间禁用中断等方式。内联汇编是另一种选择。 volatile不能保证原子性。


1
我在那里留了一些评论。当你说“即使在C语言中进行单个8位复制,语言也无法保证操作的原子性”时,似乎存在循环矛盾,因为为了“保护”变量,您随后进行了一个8位写入,这必须是原子的才能正确,但您刚刚说过它不是原子的。我有什么遗漏吗? - Gabriel Staples
1
@GabrielStaples 我回复了。bool 技巧并不依赖于 bool 是原子的,而是必须先访问 bool 并_完全评估_之后,受保护的代码可能会或可能不会被执行。如果在此之前 bool 检查被中断 x 次,则无关紧要。这是因为没有指令重新排序,并且由于中断不能被再次中断(除非您从 ISR 内部操纵全局中断掩码)。 - Lundin
@GabrielStaples 多重嵌套中断并不是问题,只要它们不访问相同的变量。只有在多个中断由同一ISR处理时才会出现问题。解决方法可能是让每个这样的ISR访问一个唯一的变量/某个数组中的唯一索引。就像在托管系统上进行多线程操作一样。 - Lundin
1
@tilz0R,你的理解是错误的:你如何证明当你编写C代码时,你的编译器总是将你认为应该是原子访问的访问转换为实际实现读取的指令。只需阅读另一个答案中的示例即可。 - Andrew Henle
@AndrewHenle 是否有一种方法可以使读取指令不是原子性的?如果有,例如在32位系统中对于32位变量,如何实现?我不知道 - 因此提出了这个问题。 - ringbuffer_peek
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接