std::atomic 与另一个字符联合
Posted
技术标签:
【中文标题】std::atomic 与另一个字符联合【英文标题】:std::atomic in a union with another character 【发布时间】:2018-02-08 05:43:08 【问题描述】:我最近阅读了一些在同一个联合中具有原子和字符的代码。像这样的
union U
std::atomic<char> atomic;
char character;
;
我不完全确定这里的规则,但代码 cmets 表示,由于字符可以为任何东西起别名,如果我们保证不更改字节的最后几位,我们可以安全地对原子变量进行操作。并且该字符仅使用最后几个字节。
这是允许的吗?我们可以在一个字符上覆盖一个原子整数并让它们都处于活动状态吗?如果是这样,当一个线程试图从原子整数加载值而另一个线程写入字符(仅最后几个字节)时会发生什么,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
【问题讨论】:
你能举例说明这个联合是如何使用的吗? 每次修改都会有效地加载整个内存位置,修改一些位,然后将其存储回来,可能会踩到对其他位的更改。这段代码背后的整个想法是错误的。写一个更详细的答案,但请参阅***.com/questions/39393850/…,了解有关读取-修改-写入原子性如何实际适用于+
或|
等操作的详细信息。
@Curious:非原子character |= 1
与原子在同一内存位置。它存储其他位的旧副本。
CPU 不是这样工作的。去阅读***.com/questions/39393850/…。为了使线程不互相踩踏,修改相同内存位置(ISO C++ 仔细定义)的所有线程必须使用原子操作。
修复此代码有两种明显的方法: 1. 改用struct
- 使用更多内存,如果有很多副本,这可能会很糟糕。 2. 只使用std::atomic<char>
——理论上比 1 慢。但我敢打赌作者甚至没有对它进行基准测试。 (即 1. 与 2. - 很少有理由对损坏的代码进行基准测试,IMO。)(注意:1. 仍然会受到错误共享的影响,这会影响性能。)
【参考方案1】:
代码 cmets 表示,由于字符可以给任何东西起别名,如果我们保证不更改字节的最后几位,我们就可以安全地对原子变量进行操作。
那些 cmets 是错误的。 char
-can-alias-anything 并不能阻止这成为非原子变量上的数据竞争,所以理论上是不允许的,更糟糕的是,它实际上在被任何普通编译器编译时都会被破坏(比如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>
也不能保证是安全的。在类似于“普通”现代CPU 的硬件上的大多数实现中,如果atomic<int/char>
都是无锁的,则对不同atomic
类型的类型双关可能仍然是原子的,但实际上可能会破坏内存排序语义,尤其是在它不是完全重叠的情况下。
此外,在 ISO C++ 中通常不允许使用联合类型双关语。我认为您实际上需要将指针转换为char*
,而不是与char
联合。在 ISO C99 中允许使用联合类型双关语,并且在 GNU C89 和 GNU C++ 以及其他一些 C++ 实现中作为 GNU 扩展。
这样就可以解决理论问题,但是这些是否适用于当前的 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) 中的源 sn-ps 得到可以做到这一点的 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 循环的等效项)的程序会很简单。
如果此代码是安全的,您总会发现偶数个 XOR 操作仅修改高位使它们为零。但是你不会发现,因为另一个线程中的非原子or
可能已经执行了奇数个异或操作。或者为了让问题更容易看到,可以使用 0x10
或其他东西的加法,这样就不会有 50% 的机会偶然正确,而是高 4 位正确的机会只有 16 分之一。
当增量操作之一是非原子的时,这与丢失计数几乎完全相同。
是否必须为尝试加载原子整数的线程刷新缓存?
不,这不是原子性的工作原理。问题不在于缓存,而是除非 CPU 做一些特殊的事情,否则没有什么能阻止 other CPU 在加载旧值和存储更新值之间读取或写入位置。在没有缓存的多核系统上也会遇到同样的问题。
当然,所有系统都使用缓存,但缓存是一致的,因此有一个硬件协议 (MESI) 可以阻止不同的内核同时具有冲突的值。当存储提交到 L1D 缓存时,它变得全局可见。详情请见Can num++ be atomic for 'int num'?。
【讨论】:
解释得很好!谢谢!以上是关于std::atomic 与另一个字符联合的主要内容,如果未能解决你的问题,请参考以下文章