std::atomic 与另一个角色联合

std::atomic in a union with another character

我最近读了一些代码,在同一个联合中有一个原子和一个字符。像这样

union U {
    std::atomic<char> atomic;
    char character;
};

我不完全确定这里的规则,但代码注释说,由于字符可以为任何东西起别名,如果我们保证不更改字节的最后几位,我们就可以安全地操作原子变量。并且该字符仅使用最后几个字节。

这是允许的吗?我们可以在一个字符上覆盖一个原子整数并让它们都处于活动状态吗?如果是这样,当一个线程试图从原子整数加载值而另一个线程写入字符(仅最后几个字节)时会发生什么,char 写入是否是原子写入?那里发生了什么?是否必须为尝试加载原子整数的线程刷新缓存?

(这段代码我也觉得很臭,不提倡用这个,只是想了解一下上面scheme的哪些部分可以定义,在什么情况下)


根据要求,代码正在做这样的事情

// thread using the atomic
while (!atomic.compare_exchange_weak(old_value, old_value | mask, ...) { ... }

// thread using the character
character |= 0b1; // set the 1st bit or something

the code comments said that since a character can alias anything, we can safely operate on the atomic variable if we promise not to change the last few bits of the byte.

这些评论是错误的。 char-can-alias-anything 并不能阻止它成为非原子变量上的数据竞争,所以理论上是不允许的,更糟糕​​的是,它实际上在被任何人编译时被破坏了适用于任何普通 CPU(如 x86)的普通编译器(如 gcc、clang 或 MSVC)。

原子性单位是内存位置,而不是内存位置中的位。 ISO C++11 标准 defines "memory location" carefully, so adjacent elements in a char[] array or a struct are separate locations (and thus it's not a race if two threads write c[0] and c[1] without synchronization)。但是结构中的相邻位字段 不是 单独的内存位置,并且在非原子 char 上使用 |= 别名到与 [=] 相同的地址18=] 绝对 相同的内存位置,无论 |=.

右侧设置了哪些位

对于一个没有数据争用 UB 的程序,如果一个内存位置被任何线程写入,所有其他(可能)同时访问该内存位置的线程必须使用原子操作来执行此操作。 (并且可能也通过完全相同的对象,即通过类型双关将 atomic<int> 的中间字节更改为 atomic<char> 也不保证 gua运行 是安全的。在大多数实现中在类似于 "normal" 现代 CPUs 的硬件上,如果 atomic<int/char> 都是无锁的,那么对不同 atomic 类型的类型双关可能恰好仍然是原子的,但是内存-排序语义实际上可能会被破坏,特别是如果它不是完全重叠的话。

此外,在 ISO C++ 中通常不允许联合类型双关。我认为您实际上需要指针转换为 char*,而不是与 char 联合。 Union 类型双关在 ISO C99 中是允许的,在 GNU C89 和 GNU C++ 中作为 GNU 扩展,在其他一些 C++ 实现中也是如此。


所以这涉及到理论,但是这些对当前的 CPUs 是否有效? 不,这在实践中也是完全不安全的

character |= 1 将(在普通计算机上)编译为加载整个 char 的 asm,修改临时值,然后将值存储回去。在 x86 上,如果编译器选择这样做,这一切都可以在一个内存目标 or 指令中发生(如果它稍后也想要该值,则不会)。但即便如此,它仍然是一个非原子 RMW,可以对其他位进行修改。

原子性对于读-修改-写操作来说是昂贵的和可选的,并且在一个字节中设置一些位而不影响其他位的唯一方法是在当前 CPUs 上进行读-修改-写。如果您特别要求,编译器只会发出以原子方式执行的 asm。 (不同于纯存储或纯加载,它们通常自然是原子的。

考虑这一系列事件:

 thread A           |     thread B
 -------------------|--------------
 read tmp=c=0000    |
                    |
                    |     c|=0b1100     # atomically, leaving c = 1100
 tmp |= 1 # tmp=1   |
 store c = tmp

留下 c = 1,而不是您希望的 1101。即高位的非原子load/store踩在线程B的修改上


我们通过编译问题 (on the Godbolt compiler explorer):

中的源代码片段,得到了可以做到这一点的 asm
void t1(U &v, unsigned mask) {
    // thread using the atomic
    char old_value = v.atomic.load(std::memory_order_relaxed);
    // with memory_order_seq_cst as the default for CAS
    while (!v.atomic.compare_exchange_weak(old_value, old_value | mask)) {}

    // v.atomic |= mask; // would have been easier and more efficient than CAS
}

t1(U&, unsigned int):
    movzx   eax, BYTE PTR [rdi]            # atomic load of the old value
.L2:
    mov     edx, eax
    or      edx, esi                       # esi = mask (register arg)
    lock cmpxchg    BYTE PTR [rdi], dl     # atomic CAS, uses AL implicitly as the expected value, same semantics as C++11 comp_exg seq_cst
    jne     .L2
    ret


void t2(U &v) {
  // thread using the character
  v.character |= 0b1;   // set the 1st bit or something
}

t2(U&):
    or      BYTE PTR [rdi], 1    # NON-ATOMIC RMW of the whole byte.
    ret

在一个线程中编写一个 运行 v.character |= 1 的程序,在另一个线程中编写一个原子 v.atomic ^= 0b1100000(或等效的 CAS 循环)的程序。

如果此代码是安全的,您总会发现偶数次仅修改高位的异或运算使它们为零。但是您不会发现,因为另一个线程中的非原子 or 可能执行了奇数次 XOR 操作。或者为了让问题更容易看出来,使用 0x10 之类的加法,这样就不会有 50% 的机会是偶然正确的,你只有 16 分之一的机会高 4 位是正确的。

当其中一个增量操作是非原子操作时,这与丢失计数的问题几乎完全相同。


Will the cache have to be flushed for the thread that is trying to load the atomic integer?

不,原子性不是这样工作的。问题不在于缓存,而是除非 CPU 做了一些特殊的事情,否则没有什么可以阻止 other CPUs 读取或写入加载时之间的位置旧值以及何时存储更新值。你在没有缓存的多核系统上也会遇到同样的问题。

当然,所有系统使用缓存,但是缓存是连贯的所以有一个硬件协议(MESI)可以停止不同的内核同时具有相互冲突的价值观。当商店提交到 L1D 缓存时,它变得全局可见。详情请参阅