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

Cur*_*ous 7 c++ multithreading strict-aliasing language-lawyer stdatomic

我最近阅读了一些在同一个联合中具有原子和字符的代码.像这样的东西

union U {
    std::atomic<char> atomic;
    char character;
};
Run Code Online (Sandbox Code Playgroud)

我不完全确定这里的规则,但代码注释说,因为一个字符可以别名,所以如果我们保证不改变字节的最后几位,我们就可以安全地对原子变量进行操作.并且该字符仅使用最后几个字节.

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

(这段代码对我来说也很臭,我并不主张使用它.只是想了解上述方案的哪些部分可以定义,在什么情况下)


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

// 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
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 9

代码注释说,因为一个字符可以别名,所以如果我们保证不改变字节的最后几位,我们就可以安全地对原子变量进行操作.

这些评论是错误的. char-can-alias-任何东西都不会阻止它成为非原子变量上的数据竞争,因此理论上不允许这样做,更糟糕的是,它在任何普通编译器(如gcc,clang或MSVC)编译时实际上都被破坏了)对于任何普通的CPU(如x86).

原子性单位是存储器位置,而不是存储器位置内的位.ISO C++ 11标准仔细定义了"内存位置",因此char[]数组或结构中的相邻元素是不同的位置(因此,如果两个线程写入c[0]c[1]没有同步,则不是竞争).但是,在一个结构体相邻的位字段都没有单独的存储器位置,并且使用|=上的非原子char混叠到相同的地址作为atomic<char>绝对相同的内存位置,而不管哪些位在的右手侧设置的|=.

对于没有数据竞争UB的程序,如果内存位置由任何线程写入,则同时访问该内存位置的所有其他线程(可能)必须使用原子操作.(也可能也是通过完全相同的对象,即改变atomic<int>by-punning 的中间字节atomic<char>也不保证是安全的.在大多数类似于"普通"现代CPU的硬件实现中,类型惩罚到不同的atomic如果atomic<int/char>类型都是无锁的,那么类型可能仍然是原子的,但实际上可能会破坏内存排序语义,特别是如果它不是完全重叠的话.

此外,ISO C++中不允许使用union type-punning.我认为你实际上需要指针转换char*,而不是使用工会char.在ISO C99中允许联合类型 - 双关语,在GNU C89和GNU C++中以及在其他一些C++实现中作为GNU扩展.


这样可以解决这个问题,但是在当前的CPU上是否可以解决这个问题呢? 不,它在实践中也完全不安全.

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

原子性对于读 - 修改 - 写操作来说是昂贵且可选的,并且在一个字节中设置一些位而不影响其他位的唯一方法是在当前CPU上进行读 - 修改 - 写.编译器只会发出asm,如果您特别请求它,则会以原子方式执行.(与纯粹的商店或纯粹的负载不同,后者通常是自然原子的. 但总是std::atomic用来获得你想要的其他语义...)

考虑这一系列事件:

 thread A           |     thread B
 -------------------|--------------
 read tmp=c=0000    |
                    |
                    |     c|=0b1100     # atomically, leaving c = 1100
 tmp |= 1 # tmp=1   |
 store c = tmp
Run Code Online (Sandbox Code Playgroud)

离开c= 1,而不是1101你希望的.即高位的非原子加载/存储通过线程B进行修改.


我们得到的asm可以做到这一点,从编译问题的源代码片段(在Godbolt编译器资源管理器上):

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
Run Code Online (Sandbox Code Playgroud)

编写一个v.character |= 1在一个线程中运行的程序,在另一个线程中编写一个原子v.atomic ^= 0b1100000(或带有CAS循环的等价物)将是直截了当的.

如果这段代码是安全的,你总会发现只修改高位的偶数个XOR操作会使它们为零.但你不会发现,因为or另一个线程中的非原子可能已经踩到了奇数个XOR运算.或者为了使问题更容易看到,使用添加0x10或其他东西,所以不是偶然发生50%的偶然机会,你只有1/16的机会,高4位是正确的.

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


是否必须为尝试加载原子整数的线程刷新缓存?

不,那不是原子性如何运作的.问题不在于缓存,除非CPU执行特殊操作,否则在加载旧值和存储更新值之间不会阻止其他 CPU读取或写入位置.在没有缓存的多核系统上你会遇到同样的问题.

当然,所有系统使用缓存,但缓存是连贯的,因此有一个硬件协议(MESI)可以阻止不同的内核同时具有冲突的值.当商店提交到L1D缓存时,它变得全局可见.请参阅'num num'可以将num ++设为原子?细节.