如何在 C++11 中实现 StoreLoad 屏障?

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_cstatomic stores 和loads实现目标,如下所示:

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()。(这也知道,setcheck是线程安全的,也不能创建数据竞争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_fenceC++中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)

如果你认为这里的问题是atomics 是局部的,那么想象一下将它们移动到全局范围,在下面的推理中,这对我来说似乎并不重要,我故意以这种方式编写代码来暴露 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 的实现

mpo*_*ter 5

选项 A 和 B 是有效的解决方案。

  • 选项 A:seq-cst 栅栏翻译成什么并不重要,C++ 标准清楚地定义了它提供的保证。我在这篇文章中列出了它们:memory_order_seq_cst 栅栏什么时候有用?
  • 选项B:是的,你的推理是正确的。对某个对象的所有修改都有一个总顺序(修改顺序),因此您可以使用它来同步线程并确保所有副作用的可见性。

然而,选项C是合法!同步关系只能通过对同一对象的获取/释放操作来建立。在您的情况下,您有两个完全不同且独立的对象dummy1dummy2. 但是这些不能用于建立一个发生在之前的关系。事实上,由于原子变量是纯粹的局部变量(即,它们只被一个线程触及),编译器可以根据 as-if 规则自由地删除它们

更新

选项 A:
我假设set()check()确实对某个原子值进行操作。然后我们有以下情况(-> 表示sequenced-before):

  • set()-> fence1(seq_cst)->y.load()
  • y.store(true)-> fence2(seq_cst)->check()

所以我们可以应用以下规则:

对于原子对象M上的原子操作AB,其中A修改M并且B取其值,如果存在栅栏XY使得AX之前排序,YB之前排序,并且XS 中位于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]:

所有操作都应有一个总订单Smemory_order_seq_cst,与所有受影响位置的“发生之前”订单和修改订单一致 [...]

这里的重要词是“一致”。这意味着如果操作A发生在操作B之前,则A必须在S 中的B之前。然而,逻辑蕴涵是单向街,所以我们不能推断出逆:仅仅因为某些操作CS中的操作D之前并不意味着CD之前发生。

特别是,两个单独对象上的两个 seq-cst 操作不能用于建立发生之前的关系,即使这些操作在 S 中是完全有序的。如果要对单独对象上的操作进行排序,则必须参考 seq-cst -围栏(见选项 A)。


归档时间:

查看次数:

1003 次

最近记录:

5 年,3 月 前