我最近阅读了一些在同一个联合中有原子和字符的代码。像这样的东西
union U {
std::atomic<char> atomic;
char character;
};
我不完全确定这里的规则,但是代码注释说由于一个字符可以别名,我们可以安全地操作原子变量,如果我们保证不改变字节的最后几位。并且该字符仅使用最后几个字节。
这是允许的吗?我们可以在一个字符上覆盖原子整数并使它们都处于活动状态吗?如果是这样,当一个线程试图从原子整数加载值并且另一个线程对该字符进行写入(仅最后几个字节)时会发生什么情况,那么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
答案 0 :(得分:9)
代码注释表示,因为一个字符可以别名,所以如果我们保证不改变字节的最后几位,我们就可以安全地对原子变量进行操作。
这些评论是错误的。 char
- can-alias-任何东西都不会阻止它成为非原子变量的数据竞争,因此理论上不允许这样做,更糟糕的是,在任何普通编译器编译时实际上都被破坏了(如gcc,clang或MSVC)适用于任何普通CPU(如x86)。
原子性单位是内存位置,而不是内存位置中的位。 ISO C ++ 11标准defines "memory location" carefully,因此char[]
数组或结构中的相邻元素是不同的位置(因此it's not a race if two threads write c[0]
and c[1]
without synchronization)。但是结构中的相邻位域是不单独的内存位置,并且使用非原子|=
上的char
别名与atomic<char>
相同的地址肯定是相同的内存位置,无论在|=
的右侧设置了哪些位。
对于没有数据竞争UB的程序,如果任何线程写入内存位置,则同时访问该内存位置的所有其他线程必须使用原子操作。 (也可能也是通过完全相同的对象,即通过类型 - 惩罚将atomic<int>
的中间字节更改为atomic<char>
也不能保证是安全的。大多数硬件上的实现类似于“正常” “如果atomic
都是无锁的,那么现代CPU,类型 - 不同atomic<int/char>
类型可能仍然是原子的,但内存排序语义实际上可能会被破坏,特别是如果它不是完全重叠。
此外,ISO C ++中不允许使用union type-punning。我认为你实际上需要指针转换为char*
,而不是与char
建立联盟。 ISO C99允许使用联合类型,GNU C89和GNU C ++中的GNU扩展,以及其他一些C ++实现。
这样可以解决理论问题,但是这些工作是否适用于当前的CPU? 不,这在实践中完全不安全。
character |= 1
将(在普通计算机上)编译为加载整个char
的asm,修改临时值,然后将值存储回来。在x86上,如果编译器选择这样做,那么所有这些都可以在一个内存目标or
指令内发生(如果它以后也想要该值,则不会这样做)。但即便如此,它仍然是一个非原子RMW,它可以对其他位进行修改。
原子性是昂贵的,并且对于读 - 修改 - 写操作是可选的,并且在一个字节中设置一些位而不影响其他位的唯一方法是在当前CPU上进行读 - 修改 - 写。编译器只会发出asm,如果您特别请求它,则会以原子方式执行。 (与纯粹的商店或纯粹的负载不同,后者通常是天然的原子。But always use std::atomic
to get the other semantics you want...)
考虑以下事件序列:
thread A | thread B
-------------------|--------------
read tmp=c=0000 |
|
| c|=0b1100 # atomically, leaving c = 1100
tmp |= 1 # tmp=1 |
store c = tmp
离开c
= 1,而不是您希望的1101
。即高位的非原子加载/存储由线程B进行修改。
我们可以通过编译问题(on the Godbolt compiler explorer)中的源代码段来获得完全相同的:
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循环的等价物),这将是直截了当的。
如果这段代码是安全的,你总会发现只修改高位的偶数个XOR操作会使它们为零。但你不会发现这一点,因为另一个线程中的非原子or
可能已经踩到了奇数个XOR运算。或者为了使问题更容易看到,请使用0x10
或其他内容添加,因此不会偶然发生50%的偶然事件,只有1/16的概率才能使高4位正确。< / p>
当其中一个增量操作是非原子的时,这与丢失计数完全相同。
是否必须为尝试加载原子整数的线程刷新缓存?
不,这不是原子性的工作方式。问题不在于缓存,除非CPU执行特殊操作,否则在加载旧值和存储更新值之间没有任何内容阻止其他 CPU读取或写入位置。在没有缓存的多核系统上你会遇到同样的问题。
当然,所有系统做都使用缓存,但缓存是一致的,因此有一个硬件协议(MESI)可以阻止不同的内核同时具有冲突的值。当商店提交到L1D缓存时,它变得全局可见。有关详细信息,请参阅Can num++ be atomic for 'int num'?。