qbo*_*lec 13 c++ atomic memory-barriers language-lawyer stdatomic
我想编写可移植代码(Intel、ARM、PowerPC...)来解决一个经典问题的变体:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
Run Code Online (Sandbox Code Playgroud)
其中目标是避免两个线程都在做的情况something
。(如果两者都没有运行也没关系;这不是只运行一次的机制。)如果您发现我下面的推理中有一些缺陷,请纠正我。
我知道,我可以通过memory_order_seq_cst
atomic store
s 和load
s实现目标,如下所示:
std::atomic<int> x{0},y{0};
void thread_a(){
x.store(1);
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!x.load()) bar();
}
Run Code Online (Sandbox Code Playgroud)
这实现了目标,因为
{x.store(1), y.store(1), y.load(), x.load()}
事件必须有一些单一的总顺序,它必须与程序顺序“边缘”一致:
x.store(1)
“在TO之前” y.load()
y.store(1)
“在TO之前” x.load()
如果foo()
被调用,那么我们有额外的优势:
y.load()
“之前读取值” y.store(1)
如果bar()
被调用,那么我们有额外的优势:
x.load()
“之前读取值” x.store(1)
所有这些边组合在一起将形成一个循环:
x.store(1)
“在TO之前”“在TO之前y.load()
读取值” y.store(1)
“在TO之前” x.load()
“读取之前值”x.store(true)
这违反了订单没有周期的事实。
我故意使用非标准术语“在 TO 之前”和“读取值之前”,而不是像 那样的标准术语happens-before
,因为我想征求关于我假设这些边确实暗示happens-before
关系的正确性的反馈,可以组合在一起图,并且这种组合图中的循环是被禁止的。我不确定。我所知道的是这段代码在 Intel gcc & clang 和 ARM gcc 上产生了正确的障碍
现在,我真正的问题有点复杂,因为我无法控制“X”——它隐藏在一些宏、模板等后面,可能比 seq_cst
我什至不知道“X”是单个变量还是其他概念(例如轻量级信号量或互斥量)。我所知道的是,我有两个宏set()
和check()
这样check()
的回报true
“后,”另一个线程呼吁set()
。(这是也知道,set
和check
是线程安全的,也不能创建数据竞争UB)。
所以概念set()
上有点像“X=1”,check()
也像“X”,但我无法直接访问所涉及的原子(如果有的话)。
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Run Code Online (Sandbox Code Playgroud)
我很担心,这set()
可能会在内部实现为x.store(1,std::memory_order_release)
和/或check()
可能是x.load(std::memory_order_acquire)
. 或者假设std::mutex
一个线程正在解锁而另一个线程正在运行try_lock
;在 ISO 标准std::mutex
中只保证有获取和发布顺序,而不是 seq_cst。
如果是这种情况,则check()
if body 可以在之前“重新排序” y.store(true)
(请参阅Alex 的回答,他们证明这种情况发生在 PowerPC 上)。
这真的很糟糕,因为现在这一系列事件是可能的:
thread_b()
首先加载x
( 0
)的旧值thread_a()
执行一切,包括 foo()
thread_b()
执行一切,包括 bar()
所以,无论是foo()
和bar()
接到电话,我必须避免。我有什么选择可以防止这种情况发生?
选项 A
尝试强制存储加载屏障。在实践中,这可以通过std::atomic_thread_fence(std::memory_order_seq_cst);
- 正如Alex 在不同答案中解释的那样,所有经过测试的编译器都发出了完整的围栏:
- x86_64:MFENCE
- PowerPC:hwsync
- Itanuim: mf
- ARMv7 / ARMv8:dmb ish
- MIPS64:同步
这种方法的问题是,我在 C++ 规则中找不到任何保证,std::atomic_thread_fence(std::memory_order_seq_cst)
必须转换为完整的内存屏障。实际上,atomic_thread_fence
C++中s的概念似乎与内存屏障的汇编概念处于不同的抽象级别,并且更多地处理诸如“什么原子操作与什么同步”之类的东西。是否有任何理论证据表明以下实现实现了目标?
void thread_a(){
set();
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!y.load()) foo();
}
void thread_b(){
y.store(true);
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!check()) bar();
}
Run Code Online (Sandbox Code Playgroud)
选项 B
使用我们对 Y 的控制来实现同步,通过对 Y 使用 read-modify-write memory_order_acq_rel 操作:
void thread_a(){
set();
if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
y.exchange(1,std::memory_order_acq_rel);
if(!check()) bar();
}
Run Code Online (Sandbox Code Playgroud)
这里的想法是对单个原子 ( y
) 的访问必须形成所有观察者都同意的单一顺序,因此要么fetch_add
在之前exchange
,反之亦然。
如果fetch_add
在此之前,exchange
则“释放”部分fetch_add
与“获取”部分同步,exchange
因此所有副作用set()
都必须对代码执行可见check()
,因此bar()
不会被调用。
否则,exchange
是 before fetch_add
,那么fetch_add
将看到1
而不是调用foo()
。因此,不可能同时调用foo()
和bar()
。这个推理正确吗?
选项 C
使用虚拟原子,引入防止灾难的“边缘”。考虑以下方法:
void thread_a(){
std::atomic<int> dummy1{};
set();
dummy1.store(13);
if(!y.load()) foo();
}
void thread_b(){
std::atomic<int> dummy2{};
y.store(1);
dummy2.load();
if(!check()) bar();
}
Run Code Online (Sandbox Code Playgroud)
如果你认为这里的问题是atomic
s 是局部的,那么想象一下将它们移动到全局范围,在下面的推理中,这对我来说似乎并不重要,我故意以这种方式编写代码来暴露 dummy1 是多么有趣和 dummy2 是完全分开的。
为什么这可能有效?好吧,必须有一些单一的总顺序{dummy1.store(13), y.load(), y.store(1), dummy2.load()}
必须与程序顺序“边缘”一致:
dummy1.store(13)
“在TO之前” y.load()
y.store(1)
“在TO之前” dummy2.load()
(一个 seq_cst 存储 + 加载有望形成包括 StoreLoad 在内的完整内存屏障的 C++ 等价物,就像它们在真实 ISA 上的 asm 中所做的那样,甚至包括不需要单独屏障指令的 AArch64。)
现在,我们有两种情况需要考虑:在总顺序y.store(1)
之前y.load()
或之后。
如果y.store(1)
是之前,y.load()
则foo()
不会被调用,我们是安全的。
如果y.load()
是 before y.store(1)
,那么将它与我们已经按照程序顺序已经拥有的两条边结合起来,我们推断出:
dummy1.store(13)
“在TO之前” dummy2.load()
现在,dummy1.store(13)
是一个释放操作,它释放 的效果set()
,并且dummy2.load()
是一个获取操作,所以check()
应该看到 和 的效果,set()
因此bar()
不会被调用,我们是安全的。
这里认为check()
会看到结果是否正确set()
?我可以像这样组合各种“边缘”(“程序顺序”又名“Sequenced Before”、“总顺序”、“发布前”、“获取后”)吗?我对此表示严重怀疑:C++ 规则似乎在谈论同一位置上存储和加载之间的“同步”关系——这里没有这种情况。
请注意,我们只担心在那里的情况dumm1.store
是已知的(通过其他推理)是之前dummy2.load
在seq_cst总订单。因此,如果他们一直在访问同一个变量,负载就会看到存储的值并与之同步。
(对于原子加载和存储编译为至少 1 路内存屏障(并且 seq_cst 操作不能重新排序:例如 seq_cst 存储不能通过 seq_cst 加载)的实现的内存屏障/重新排序推理是任何加载/店后dummy2.load
绝对会成为其他线程可见后 y.store
。而类似的其他线程,...之前y.load
。)
您可以在https://godbolt.org/z/u3dTa8 上玩我对选项 A、B、C 的实现
选项 A 和 B 是有效的解决方案。
然而,选项C是不合法!同步关系只能通过对同一对象的获取/释放操作来建立。在您的情况下,您有两个完全不同且独立的对象dummy1
和dummy2
. 但是这些不能用于建立一个发生在之前的关系。事实上,由于原子变量是纯粹的局部变量(即,它们只被一个线程触及),编译器可以根据 as-if 规则自由地删除它们。
更新
选项 A:
我假设set()
并check()
确实对某个原子值进行操作。然后我们有以下情况(-> 表示sequenced-before):
set()
-> fence1(seq_cst)
->y.load()
y.store(true)
-> fence2(seq_cst)
->check()
所以我们可以应用以下规则:
对于原子对象M上的原子操作A和B,其中A修改M并且B取其值,如果存在栅栏X和Y使得A在X之前排序,Y在B之前排序,并且X在S 中位于Y之前,然后B观察A的影响或M在其修改顺序中的后续修改。
memory_order_seq_cst
也就是说,无论是check()
看到存储在该值set
,或y.load()
认为价值书面方式y.store()
(操作上y
甚至可以使用memory_order_relaxed
)。
方案C:
该C ++ 17个标准状态[32.4.3,P1347]:
所有操作都应有一个总订单S
memory_order_seq_cst
,与所有受影响位置的“发生之前”订单和修改订单一致 [...]
这里的重要词是“一致”。这意味着如果操作A发生在操作B之前,则A必须在S 中的B之前。然而,逻辑蕴涵是单向街,所以我们不能推断出逆:仅仅因为某些操作C在S中的操作D之前并不意味着C在D之前发生。
特别是,两个单独对象上的两个 seq-cst 操作不能用于建立发生之前的关系,即使这些操作在 S 中是完全有序的。如果要对单独对象上的操作进行排序,则必须参考 seq-cst -围栏(见选项 A)。
归档时间: |
|
查看次数: |
1003 次 |
最近记录: |