是 x86-64 多核机器上 C++ Atomic 中 int 的读写

Are Reads and Writes of an int in C++ Atomic on x86-64 multi-core machine

我读过 this,我的问题很相似但又有些不同。

注意,我知道 C++0x 不能保证这一点,但我特别要求像 x86-64 这样的多核机器。

假设我们有 2 个线程(固定到 2 个物理内核)运行 以下代码:

// I know people may delcare volatile useless, but here I do NOT care memory reordering nor synchronization/
// I just want to suppress complier optimization of using register.
volatile int n; 

void thread1() {
    for (;;)
        n = 0xABCD1234;
        // NOTE, I know ++n is not atomic,
        // but I do NOT care here.
        // what I cares is whether n can be 0x00001234, i.e. in the middle of the update from core-1's cache lines to main memory,
        // will core-2 see an incomplete value(like the first 2 bytes lost)?
        ++n; 
    }
}

void thread2() {
    while (true) {
        printf('%d', n);
    }
}

线程 2 是否有可能看到 <b>n</b> 类似于 0x00001234,即在核心更新的中间1 到主内存的高速缓存行,核心 2 会看到不完整的值吗?

我知道一个 4 字节的 <b>int</b> 绝对适合典型的 128 字节长的缓存行,如果 <b>int</b> 确实存储在一个缓存行中,那么我相信这里没有问题......但是如果它跨越缓存行边界怎么办?即是否有可能某些 <strong>char</strong> 已经位于构成 <b> 的第一部分的缓存行内n</b> 在一个缓存行中,另一部分在下一行?如果是这样,那么 core-2 可能有机会看到一个不完整的值,对吧?

此外,我认为除非每个 <strong>char</strong><strong>short</strong> 或其他 <b> 小于 4 字节的 </b> 类型填充为 4 字节长,永远不能保证单个<b>int</b>没有通过cache line边界,是吧?

如果是这样,那会建议通常即使设置单个 <strong>int</strong> 也不能保证在 x86-64 多系统上是原子的核心机?

我得到这个问题是因为当我研究这个主题时,不同帖子中的不同人似乎都同意,只要机器架构是正确的(例如 x86-64)设置 <b>int</b> 应该是原子的。但是正如我上面所说的那样不成立,对吧?

更新

我想介绍一下我的问题的背景。我正在处理一个实时系统,它正在对一些信号进行采样并将结果放入一个全局 int 中,这当然是在一个线程中完成的。在另一个线程中,我读取了这个值并对其进行了处理。 我不关心 set 和 get 的顺序,我只需要一个完整的(相对于损坏的整数值)值。

如果您正在寻找原子性保证,std::atomic<> 是您的朋友。不要依赖 volatile 限定符。

为什么这么担心?

取决于您的实施。如果 int 在您的平台上是原子的(在 x86-64 中,如果正确对齐,它们是原子的),std::atomic<int> 将减少到 int

如果我是你,我也会担心 int 溢出你的代码的可能性(这是未定义的行为)。

换句话说,std::atomic<unsigned> 是这里合适的类型。

x86 保证了这一点。 C++ 没有。如果你编写 x86 程序集,你会没事的。如果您编写 C++,则它是未定义的行为。由于您无法推断未定义的行为(毕竟它是未定义的),您必须降低并查看生成的汇编程序指令。如果他们按照您的意愿行事,那很好。但是请注意,当您更改编译器、编译器版本、编译器标志或任何可能更改优化器行为的代码时,编译器往往会更改生成的程序集,因此您将不得不不断检查汇编代码以确保它仍然正确。

更简单的方法是使用 std::atomic<int>,这将保证生成正确的汇编程序指令,因此您不必经常检查。

另一个问题讲的是变量"properly aligned"。如果它穿过缓存行,则变量 not 正确对齐。例如,除非您特别要求编译器打包结构,否则 int 不会这样做。

您还假设使用 volatile int 优于 atomic<int>。如果 volatile int 是在您的平台上同步变量的完美方式,那么库实现者肯定也会知道这一点并将 volatile x 存储在 atomic<x> 中。

没有要求 atomic<int> 必须特别慢,因为它是标准的。 :-)

这个问题几乎与 重复。那里的答案确实回答了你问的所有问题,但这个问题更侧重于 whether an int (or other type?) whether the ABI / compiler question ,而不是当它发生时发生的事情。这个问题中还有其他内容也值得专门回答。


是的,它们几乎总是会在 int 适合单个寄存器的机器上(例如不是 AVR:8 位 RISC),因为编译器通常选择不使用多个存储指令,当他们可以使用 1.

正常的 x86 ABI 会将 int 对齐到 4B 边界,即使在结构内部也是如此(除非您使用 GNU C __attribute__((packed)) 或其他方言的等效项)。但请注意 i386 System V ABI 仅将 double 对齐到 4 个字节;它只是外部结构,现代编译器可以超越它并赋予它自然对齐,.

但是你在 C++ 中合法做的任何事情都不能依赖于这个事实(因为根据定义它会涉及非 atomic 类型的数据竞争所以这是未定义的行为)。幸运的是,有一些有效的方法可以获得相同的结果(即大约相同的编译器生成的 asm,没有 mfence 指令或其他缓慢的东西)不会导致未定义的行为。

您应该使用 atomic 而不是 volatile 或者希望编译器不会优化非易失性 int 上的存储或加载,因为异步修改的假设是 volatileatomic 重叠的方式之一。

I'm dealing with a real-time system, which is sampling some signal and putting the result into one global int, this is of course done in one thread. And in yet another thread I read this value and process it.

std::atomic.store(val, std::memory_order_relaxed).load(std::memory_order_relaxed) 将为您提供您想要的。 HW-access 线程自由运行并将普通 x86 存储指令存储到共享变量中,而 reader 线程执行普通 x86 加载指令。

这是 C++11 表达你想要的方式,你应该期望它编译成与 volatile 相同的 asm . (如果你使用 clang,可能会有一些指令差异,但并不重要。)如果有任何 volatile int 没有足够对齐的情况,或任何其他极端情况, atomic<int> 将起作用(除非编译器错误)。除了可能在一个打包的结构中; IDK 如果编译器阻止你通过在结构中打包原子类型来破坏原子性。

理论上,您可能希望使用 volatile std::atomic<int> 来确保编译器不会将多个存储优化到同一个变量。参见 。但是现在,编译器不做那种优化。 (volatile std::atomic<int> 仍应编译为相同的轻量级 asm。)


I know a single 4-byte int definitely fits into a typically 128-byte-long cache line, and if that int does store inside one cache line then I believe no issues here...

自 PentiumIII 以来,所有主流 x86 CPUs 上的缓存行都是 64B;在此之前,32B 线是典型的。 (好吧 AMD Geode still uses 32B lines...) Pentium4 uses 64B lines, although it prefers to transfer them in pairs or something? Still, I think it's accurate to say that it really does use 64B lines, not 128B. This page 将其列为每行 64B。

据我所知,在任何级别的缓存中都没有使用 128B 行的 x86 微体系结构。

此外,只有英特尔 CPU 保证缓存的未对齐存储/加载在不跨越缓存行边界时是原子的。通常 x86 (AMD/Intel/other) 的基线原子性保证是不跨越 8 字节边界。请参阅 以获取 Intel/AMD 手册中的引述。

自然对齐几乎适用于最大保证原子宽度的任何 ISA(不仅仅是 x86)。


您问题中的代码需要非原子读取-修改写入,其中加载和存储分别是原子的,并且不对周围 loads/stores. 强加任何顺序

正如每个人所说,正确的方法是使用 atomic<int>,但没有人确切指出 如何。如果你只是在 atomic_int nn++,你会得到(对于 x86-64)lock add [n], 1,这将比你用 volatile 得到的要慢得多,因为它使整个 RMW 操作是原子的。 (也许这就是你回避 std::atomic<> 的原因?)

#include <atomic>
volatile int vcount;
std::atomic <int> acount;
static_assert(alignof(vcount) == sizeof(vcount), "under-aligned volatile counter");

void inc_volatile() {
    while(1) vcount++;
}
void inc_separately_atomic() {
    while(1) {
        int t = acount.load(std::memory_order_relaxed);
        t++;
        acount.store(t, std::memory_order_relaxed);
    }
}

来自 the Godbolt compiler explorer with gcc7.2 and clang5.0

的 asm 输出

毫不奇怪,对于 x86-32 和 x86-64,它们都编译为具有 gcc/clang 的等效 asm。 gcc 为两者制作相同的 asm,除了要递增的地址:

# x86-64 gcc -O3
inc_volatile:
.L2:
    mov     eax, DWORD PTR vcount[rip]
    add     eax, 1
    mov     DWORD PTR vcount[rip], eax
    jmp     .L2
inc_separately_atomic():
.L5:
    mov     eax, DWORD PTR acount[rip]
    add     eax, 1
    mov     DWORD PTR acount[rip], eax
    jmp     .L5

clang 优化得更好,使用

inc_separately_atomic():
.LBB1_1:
        add     dword ptr [rip + acount], 1
        jmp     .LBB1_1

注意缺少 lock 前缀,因此在 CPU 中解码为分离加载、ALU 添加和存储微指令。 (参见 )。

除了更小的代码大小外,其中一些微指令在来自同一指令时可以微融合,从而减少前端瓶颈。 (这里完全不相关;store/reload 的 5 或 6 个周期延迟的循环瓶颈。但如果用作更大循环的一部分,它将是相关的。)与寄存器操作数不同,add [mem], 1比 Intel CPUs 上的 inc [mem] 更好,因为它的微熔丝更多:.

有趣的是,clang 使用效率较低的 inc dword ptr [rip + vcount] 作为 inc_volatile()


实际的原子 RMW 是如何编译的?

void inc_atomic_rmw() {
    while(1) acount++;
}

# both gcc and clang do this:
.L7:
    lock add        DWORD PTR acount[rip], 1
    jmp     .L7

结构内部对齐:

#include <stdint.h>
struct foo {
    int a;
    volatile double vdouble;
};

// will fail with -m32, in the SysV ABI.
static_assert(alignof(foo) == sizeof(double), "under-aligned volatile counter");

但是atomic<double>atomic<unsigned long long>会保证原子性。

对于 32 位机器上的 64 位整数 load/store,gcc 使用 SSE2 指令。不幸的是,其他一些编译器使用 lock cmpxchg8b,这对于单独的存储或加载来说效率要低得多。 volatile long long 不会给你的。

volatile double 通常在正确对齐时对 load/store 是原子的,因为正常的方法已经是使用单个 8B load/store 指令。