在x86-64多核机器上读取和写入C ++ Atomic中的int

时间:2017-09-07 09:10:35

标签: c++ multithreading atomic cpu-cache

我读过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是否有可能看到n类似0x00001234,即在从core-1的缓存行更新到主内存的过程中,core-2会看到一个不完整的值?

我知道单个4字节int绝对适合通常长度为128字节的缓存行,如果int确实存储在一个缓存行中,那么我相信这里没有问题。但是,如果它超越缓存线边界呢?也就是说,某些char是否已经位于该缓存行内,这使得n的第一部分位于一个缓存行而另一部分位于下一行?如果是这种情况,那么core-2可能有机会看到一个不完整的值,对吗?

另外,我认为除非将每个charshort或其他less-than-4-bytes类型填充为4字节长,否则永远无法保证单个int没有通过缓存行边界,不是吗?

如果是这样,那么在x86-64多核机器上,即使设置单个int通常也不保证是原子的吗?

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

更新

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

5 个答案:

答案 0 :(得分:6)

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

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

答案 1 :(得分:5)

为什么这么担心?

依靠您的实施。如果std::atomic<int>在您的平台上是原子的,那么int将减少为int(如果正确对齐,则在x86-64中为{}}。

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

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

答案 2 :(得分:4)

另一个问题涉及变量&#34;正确对齐&#34;。如果它跨越缓存行,则变量正确对齐。除非您特别要求编译器打包结构,否则int将不会这样做。

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

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

答案 3 :(得分:3)

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

答案 4 :(得分:2)

问题几乎与Why is integer assignment on a naturally aligned variable atomic on x86?重复。那里的答案确实回答了你所要求的一切,但这个问题更侧重于是否 int(或其他类型?)的ABI /编译器问题是否足够对齐,而不是会发生什么事。此问题还有其他一些内容值得回答。

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

正常的x86 ABI将int与4B边界对齐,甚至在结构内部(除非你使用GNU C __attribute__((packed))或其他方言的等价物)。但请注意,i386 System V ABI仅将double与4个字节对齐;现代编译器可以超越它并使其自然对齐,making load/store atomic

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

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

  

我正在处理一个实时系统,它正在对一些信号进行采样并将结果放入一个全局int中,这当然是在一个线程中完成的。在另一个线程中,我读取了这个值并进行处理。

<{> std::atomic .store(val, std::memory_order_relaxed).load(std::memory_order_relaxed)将在此提供您想要的内容。 HW-access线程可以自由运行,并将普通的x86存储指令放入共享变量中,而读取器线程则执行普通的x86加载指令。

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

理论上,您可能希望使用volatile std::atomic<int>来确保编译器不会将多个存储优化为同一个变量。见Why don't compilers merge redundant std::atomic writes?。但就目前而言,编译器并没有做那种优化。 (volatile std::atomic<int>仍应编译为相同的轻量级asm。)

  

我知道一个4字节的int肯定适合一个典型的128字节长的缓存行,如果那个int存储在一个缓存行中,那么我相信这里没有问题......

自PentiumIII以来,所有主流x86 CPU的缓存行均为64B;在那之前32B线是典型的。 (好AMD Geode still uses 32B lines ...)Pentium4使​​用64B线,虽然它更喜欢成对传输它们?尽管如此,我认为它确实使用64B线而不是128B是准确的。 This page将其列为每行64B。

AFAIK,没有x86微体系结构在任何级别的缓存中使用128B线。

此外,只有英特尔CPU保证缓存的未对齐存储/加载是非原子的,如果它们不跨越缓存行边界。 x86(AMD / Intel / other)的基线原子性保证不会超过8字节边界。有关Intel / AMD手册中的引用,请参阅Why is integer assignment on a naturally aligned variable atomic on x86?

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

您的问题中的代码需要非原子读取 - 修改写入,其中加载和存储是单独原子的,并且不对周围的加载/存储施加任何排序。

正如大家所说的那样,正确的方法是使用atomic<int>,但没有人指出如何。如果您只是n++ atomic_int n,您将获得(对于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输出

不出所料,他们都使用gcc / clang为x86-32和x86-64编译为等效的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添加和存储uops。 (见Can num++ be atomic for 'int num'?)。

除了较小的代码大小外,其中一些uop在来自同一指令时可以进行微融合,从而减少前端瓶颈。 (这里完全不相关;存储/重新加载的5或6周期延迟的循环瓶颈。但如果用作更大循环的一部分,它将是相关的。)与寄存器操作数不同,add [mem], 1更好比英特尔CPU上的inc [mem]要好,因为它更加微融合:INC instruction vs ADD 1: Does it matter?

有趣的是,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位整数加载/存储,gcc使用SSE2指令。不幸的是,其他一些编译器使用lock cmpxchg8b,这对于单独的存储或加载来说效率要低得多。 volatile long long不会给你那个。

正确对齐时,

volatile double通常通常是原子加载/存储,因为正常的方法是使用单个8B加载/存储指令。