我想编写可移植的代码(适用于Intel、ARM、PowerPC等平台),解决一个经典问题的变体:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
其目标是避免两个线程同时执行something
的情况(如果两件事都不运行也没关系;这不是一种完全只运行一次的机制)。
我知道,我可以通过以下方式使用memory_order_seq_cst
原子store
和load
来实现此目标:
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();
}
由于在{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中早于”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();
}
我很担心,set()
可能会被内部实现为 x.store(1,std::memory_order_release)
或者 check()
可能会是 x.load(std::memory_order_acquire)
。或者假设一种情况下一个线程正在解锁 std::mutex
,而另一个线程正尝试获取锁,但在 ISO 标准中,std::mutex
仅保证具有获取和释放排序,而不是 seq_cst。
如果是这种情况,那么 check()
的 if 体可能会在 y.store(true)
之前被“重新排序”(请参见 Alex 的答案,他们演示了在 PowerPC 上发生这种情况)。
这将非常糟糕,因为现在可能发生以下事件序列:
thread_b()
首先加载x
的旧值(0
)thread_a()
执行包括foo()
在内的所有内容thread_b()
执行包括bar()
在内的所有内容
所以,既调用了 foo()
,也调用了 bar()
,我必须避免这种情况。我有什么选项可以防止这种情况发生?
选项 A
尝试强制执行 Store-Load 屏障。实际上,这可以通过 std::atomic_thread_fence(std::memory_order_seq_cst);
实现 - 如Alex 在另一个答案中解释的那样,所有经过测试的编译器都会生成一个完整的屏障:
- x86_64: MFENCE
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: sync
但是,这种方法的问题在于,我找不到 C++ 规则中任何保证 std::atomic_thread_fence(std::memory_order_seq_cst)
必须转换为完整存储器屏障的保证。实际上,在 C++ 中,atomic_thread_fence
的概念似乎处于比汇编内存屏障更高的抽象级别,处理的更多是像“哪个原子操作与哪个原子操作同步”之类的东西。有没有一些理论证明表明下面的实现可以达到目标?
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();
}
方案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();
}
这里的想法是,对于单个原子变量(y
),所有观察者必须达成一致的单一顺序。因此,要么fetch_add
在exchange
之前,要么在之后。
如果fetch_add
在exchange
之前,则fetch_add
的“释放”部分与exchange
的“获取”部分同步,因此set()
的所有副作用都必须对执行check()
的代码可见,所以不会调用bar()
。
否则,exchange
在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();
}
如果你认为问题在于atomic
是本地的,那么想象一下将它们移动到全局范围内,在下面的推理中,我觉得这对我没有任何影响,我有意这样编写代码,以暴露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 store + load希望形成包括StoreLoad的完整内存屏障的C++等效物,就像它们在真实ISAs上的asm中所做的那样,甚至包括无需单独的屏障指令的AArch64。)
现在,我们有两种情况要考虑:要么y.store(1)
在总序中在y.load()
之前,要么在之后。
如果y.store(1)
在y.load()
之前,那么foo()
将不会被调用,我们是安全的。
如果y.load()
在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
在seq_cst总序中是否在dummy2.load
之前是“已知的”(通过其他推理)。因此,如果它们访问了同一个变量,加载将会看到存储的值并与之同步。
(对于将原子加载和存储编译为至少1路内存屏障(并且seq_cst操作无法重新排序:例如,seq_cst存储无法通过seq_cst加载)的实现的内存屏障/重新排序推理,任何在dummy2.load
之后的加载/存储都绝对对其他线程可见,并且类似地,对于另一个线程,在y.load
之前......之前。)
您可以在
std::atomic_thread_fence(std::memory_order_seq_cst)
都会编译成一个完整的屏障,但由于整个概念是一个实现细节,在标准中找不到任何提及。(CPU内存模型通常是根据相对于顺序一致性允许哪些重排序来定义的。例如,x86是seq-cst +带有转发的存储缓冲区) - Peter Cordescompare_exchange_*
在不更改原子布尔值的情况下执行RMW操作(只需将期望值和新值设置为相同的值)。 - mpoeteratomic<bool>
有exchange
和compare_exchange_weak
。后者可以通过(尝试)CAS(true, true)或false,false来执行虚拟RMW。它要么失败,要么原子地将值替换为自身。(在x86-64汇编中,使用lock cmpxchg16b
技巧可以保证原子16字节加载;效率低下但比单独获取锁要好。) - Peter Cordesfoo()
和bar()
都可能不会被调用。我不想在代码中引入太多“现实世界”的元素,以避免出现“你认为你有问题 X,但实际上是问题 Y”的回应。但是,如果确实需要了解背景故事:set()
实际上是some_mutex_exit()
,check()
是try_enter_some_mutex()
,y
是“有一些等待者”,foo()
是“退出而不唤醒任何人”,bar()
是“等待唤醒”... 但是,我拒绝在这里讨论这个设计 - 我无法真正改变它。 - qbolec