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

16

我想编写可移植的代码(适用于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原子storeload来实现此目标:

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()。(还“已知”setcheck是线程安全的,并且不能创建数据竞争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_addexchange之前,要么在之后。

如果fetch_addexchange之前,则fetch_add的“释放”部分与exchange的“获取”部分同步,因此set()的所有副作用都必须对执行check()的代码可见,所以不会调用bar()

否则,exchangefetch_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之前......之前。)


您可以在


2
C++内存模型没有任何关于StoreLoad重排序的概念,只有Synchronizes-with和happens-before。对于非原子对象的数据竞争,会出现UB,这与真实硬件上的汇编不同。在我所知道的所有实际实现中,std::atomic_thread_fence(std::memory_order_seq_cst)都会编译成一个完整的屏障,但由于整个概念是一个实现细节,在标准中找不到任何提及。(CPU内存模型通常是根据相对于顺序一致性允许哪些重排序来定义的。例如,x86是seq-cst +带有转发的存储缓冲区) - Peter Cordes
1
您可以使用 compare_exchange_* 在不更改原子布尔值的情况下执行RMW操作(只需将期望值和新值设置为相同的值)。 - mpoeter
1
@Fareanor和qbolec:atomic<bool>exchangecompare_exchange_weak。后者可以通过(尝试)CAS(true, true)或false,false来执行虚拟RMW。它要么失败,要么原子地将值替换为自身。(在x86-64汇编中,使用lock cmpxchg16b技巧可以保证原子16字节加载;效率低下但比单独获取锁要好。) - Peter Cordes
1
@PeterCordes 是的,我知道 foo()bar() 都可能不会被调用。我不想在代码中引入太多“现实世界”的元素,以避免出现“你认为你有问题 X,但实际上是问题 Y”的回应。但是,如果确实需要了解背景故事:set() 实际上是 some_mutex_exit()check()try_enter_some_mutex()y 是“有一些等待者”,foo() 是“退出而不唤醒任何人”,bar() 是“等待唤醒”... 但是,我拒绝在这里讨论这个设计 - 我无法真正改变它。 - qbolec
显示剩余9条评论
4个回答

6

方案 A 和 B 都是有效解决方案。

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

但是,方案 C 不是有效的!只有在同一个对象上进行 acquire/release 操作时才能建立 synchronize-with 关系。在您的情况下,您有两个完全不同且独立的对象 dummy1 和 dummy2。但是这些无法用于建立 happens-before 关系。实际上,由于原子变量是纯本地的(即它们只由一个线程操作),编译器可以基于 as-if 规则自由删除它们。

更新

方案 A:
我假设 set() 和 check() 确实对某个原子值进行操作。那么我们有以下情况(-> 表示序列化):

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

因此,我们可以应用以下规则:

对于原子对象 M 上的原子操作 A 和 B,其中 A 修改 M 并且 B 取其值,如果存在 memory_order_seq_cst 栅栏 X 和 Y,使得 A 在 X 之前序列化,Y 在 B 之前序列化,并且 X 在 S 中先于 Y,则 B 观察到 A 的效果或者 M 的后续修改。

也就是说,要么 check() 看到 set 存储的值,要么 y.load() 看到 y.store() 写入的值(y 上的操作甚至可以使用 memory_order_relaxed)。

选项 C:
根据C++17标准[32.4.3, p1347]规定:

所有memory_order_seq_cst操作上必须有一个单一的全序S,并且要与“happens before”顺序和所有受影响位置的修改顺序保持一致[...]。

这里重要的词是“一致”。它意味着如果操作A发生在操作B之前,那么A必须在S中先于B。然而,逻辑蕴含只是单向的,因此我们不能推断出反向的结论:即使某些操作CS中先于操作D,也不能推断出C发生在D之前。

特别地,两个对象上的两个seq-cst操作不能用于建立happens before关系,即使这些操作在S中是完全有序的。如果想对不同的对象进行操作排序,必须参考seq-cst-fences(见选项A)。


1
@qbolec:我已经更新了关于选项A的更多细节的答案。 - mpoeter
@Peter Cordes:我不太同意。本地原子变量会引入什么排序保证?即使对于原子操作,编译器也可以根据as-if规则自由应用各种优化。请参阅http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4455.html。 - mpoeter
1
是的,本地seq-cst操作仍将成为所有seq-cst操作上的单个总顺序S的一部分。但是S仅与happens-before顺序和修改顺序一致,即如果A happens-before B,则A必须在S中先于B。但是反之并不保证,即使AS中先于B,我们也不能推断出A* happens-before B - mpoeter
1
假设setcheck可以安全地并行执行,我可能会选择选项A,特别是如果这是性能关键的话,因为它避免了对共享变量y的争用。 - mpoeter
没关系,这可能不明显,因为 Stack Overflow 会向拥有你正在评论的帖子的用户发送通知,以及 @user 的通知,所以我不需要@你,反之亦然。无论如何,我提出了真实的ISA,因为如果您能找到一个真实的ISA会打破编译器通常编译它的方式的情况,那就非常确定了。同时,弄清楚在某些ISA上使其安全的ISA规则可以帮助我们看到它依赖于C++没有保证的内容。(通常归结为允许不同线程对非顺序一致事件的顺序持不同意见) - Peter Cordes
显示剩余14条评论

2
@mpoeter解释了为什么选项A和B是安全的。
在实际实现中,我认为选项A只需要在线程A中使用std::atomic_thread_fence(std::memory_order_seq_cst),而不需要在线程B中。
实际上,在seq-cst存储中,包括完整的内存屏障,或者至少在AArch64上不能与后续的acquire或seq_cst加载重新排序(stlr顺序释放必须从存储缓冲区中排出,然后ldar才能从高速缓存中读取)。 C++ -> asm mappings有一个选择,即将排出存储缓冲区的成本放在原子存储或原子加载上。对于实际实现来说,明智的选择是使原子加载变得便宜,因此seq_cst存储包括完整的屏障(包括StoreLoad)。虽然大多数情况下,seq_cst加载与acquire加载相同。
但不包括POWER; 在那里,甚至需要重量级同步=完全屏障来阻止来自同一核上的其他SMT线程的存储转发,这可能导致IRIW重新排序,因为seq_cst要求所有线程能够就所有seq_cst操作的顺序达成一致。不同线程中对不同位置进行的两次原子写入是否总是按相同顺序被其他线程看到? 当然,为了正式保证安全,我们需要在两个位置都加上一个fence,以将acquire/release set() -> check()提升为seq_cst synchronizes-with。我认为这也适用于松散的set,但从其他线程的POV来看,放松的check可能会与bar重新排序。
我认为选项C的真正问题在于它依赖于一些假设的观察者,该观察者可以与y和虚拟操作同步。因此,我们期望编译器在基于屏障的ISA(有一个单一的一致共享内存状态,并且屏障按照这个核/线程对该共享状态的访问进行排序)生成汇编代码时保留这种顺序。请参见C11 Standalone memory barriers LoadLoad StoreStore LoadStore StoreLoad,了解有关这种模型与弱于seq_cst的屏障的stdatomic synchronizes-with ordering模型之间的更多信息。
这在实际的ISA上是正确的;两个线程都包含完整的屏障或等效物,并且编译器不会(尚未)优化原子操作。但是,“编译到基于屏障的ISA”并不是ISO C++标准的一部分。一致的共享缓存是存在于asm推理中但不存在于ISO C++推理中的假设观察者。
为了使选项C起作用,我们需要像dummy1.store(13); / y.load() / set();(由线程B看到)这样的顺序来违反一些ISO C++规则。
运行这些语句的线程必须表现得好像set()首先执行(因为Sequenced Before)。这没问题,运行时内存排序和/或编译时操作重排仍然可以做到这一点。
两个seq_cst操作d1=13y与Sequenced Before(程序顺序)一致。 set()不参与seq_cst所需存在的全局顺序,因为它不是seq_cst。
线程B不与dummy1.store同步,因此不适用于d1=13相对于set的happens-before要求,即使该赋值是一个release操作。
我没有看到任何其他可能的规则违规行为;我找不到在这里需要与set Sequenced-Before d1=13保持一致的任何内容。
“dummy1.store releases set()” 推理是错误的。这种排序只适用于与真实观察者同步或在asm中的情况。正如@mpoeter所回答的那样,seq_cst总序列的存在并不会创建或暗示happens-before关系,而这是唯一正式保证seq_cst之外排序的事情。
在具有一致共享缓存的任何“普通”CPU中,这种重新排序在运行时确实可能发生似乎不太可能。(但如果编译器可以删除dummy1和dummy2,那么显然我们会遇到问题,我认为这是标准允许的。)
但由于C++内存模型没有定义存储缓冲区、共享一致缓存或允许重新排序的litmus测试,因此健全性要求的事情在C++规则上并非正式要求。这可能是有意为之,以便优化即使是线程私有的seq_cst变量。(当前编译器当然不会这样做,也不会对原子对象进行任何其他优化。)
“一个线程能够看到set()的最后,而另一个线程可能会首先看到set()”的实现听起来不可行。即使是POWER处理器也不能做到这一点;POWER处理器的seq_cst加载和存储都包括完整的屏障。 (我曾在评论中建议IRIW重新排序在这里可能是相关的;C ++的acq / rel规则足够弱,可以容纳这一点,但在synchronizes-with或其他happens-before情况之外没有任何保证,这比任何硬件都要弱。) C ++对于非seq_cst不保证任何内容,除非确实存在观察者,然后仅适用于该观察者。如果没有观察者,我们就处于薛定谔猫的领域。或者,如果两棵树倒在森林中,那么其中一棵树是不是先倒下了?(如果是一个大森林,广义相对论认为这取决于观察者,并且没有普遍的同时性概念。)
@mpoeter建议编译器甚至可以删除虚拟负载和存储操作,即使在seq_cst对象上也是如此。
我认为当他们能够证明没有任何操作与同步时,这可能是正确的。例如,编译器可以看到dummy2不会逃逸函数,可能会删除该seq_cst负载。
这至少有一个现实世界的后果:如果编译为AArch64,那将允许较早的seq_cst存储在实践中重新排序以与后续松弛操作一起使用,而在seq_cst存储+负载排空存储缓冲区之前,任何后续加载都无法执行。
当然,当前的编译器根本不优化原子操作,尽管ISO C++并不禁止;这是标准委员会的一个未解决的问题
我认为这是允许的,因为C++内存模型没有隐式观察者或所有线程都同意排序的要求。它基于一致的缓存提供一些保证,但它不要求对所有线程的可见性同时进行。

很好的总结!我同意在实践中,如果只有线程A有一个seq-cst栅栏,那么可能已经足够了。然而,基于C++标准,我们不会有必要的保证,即我们看到set()的最新值,因此我仍然会在线程B中使用栅栏。我想,一个带有seq-cst栅栏的松散存储将生成几乎与seq-cst存储相同的代码。 - mpoeter
@mpoeter:是的,我只是在谈论实践中的情况,而不是正式的情况。在该部分末尾添加了一条注释。是的,在大多数ISA上,实际上seq_cst存储通常只是普通存储(松散)+屏障。或者不是;在POWER上,seq-cst存储会在存储之前执行(重量级)sync,之后没有任何操作。https://godbolt.org/z/mAr72P 但是,seq-cst加载需要两侧都有一些屏障。 - Peter Cordes

1
在ISO标准中,std::mutex仅保证具有获取和释放顺序,而不是seq_cst。
但是没有任何东西被保证具有“seq_cst ordering”,因为seq_cst不是任何操作的属性。
seq_cst是针对给定实现的std::atomic或替代原子类的所有操作的保证。因此,您的问题是不合理的。

1
在第一个例子中,y.load()读取0并不意味着y.load()发生在y.store(1)之前。然而,它确实意味着它在总的单一顺序中更早,这要归功于一个规则:seq_cst load返回总顺序中最后一个seq_cst store的值,或者某个不在它之前发生的non-seq_cst store的值(在这种情况下不存在)。因此,如果y.store(1)在总的顺序中先于y.load()y.load()将返回1。
证明仍然是正确的,因为单一总顺序没有循环。
这个解决方案怎么样?
std::atomic<int> x2{0},y{0};

void thread_a(){
  set();
  x2.store(1);
  if(!y.load()) foo();
}

void thread_b(){
  y.store(1);
  if(!x2.load()) bar();
}

OP的问题在于,“我无法控制‘X’”——它被包装宏或其他东西所包裹,可能不是seq-cst存储/加载。我更新了问题以更好地突出这一点。 - Peter Cordes
@PeterCordes 的想法是创建另一个他可以控制的"x"。为了更清晰,我将在我的答案中将其重命名为"x2"。我确定我错过了一些要求,但如果唯一的要求是确保不同时调用foo()和bar(),那么这就满足了要求。 - Tomek Czajka
所以 if(false) foo(); 也可以,但我认为 OP 也不想要那样的结果 :P 有趣的观点,但我认为 OP 希望条件调用基于他们指定的条件! - Peter Cordes
1
嗨,@TomekCzajka,感谢您抽出时间提出新的解决方案。在我的特定情况下,它不起作用,因为它省略了check()的重要副作用(请参见我在问题中的评论以获取set,check,foo,bar的实际含义)。我认为可以改成if(!x2.load()){ if(check())x2.store(0); else bar(); }会更好。 - qbolec

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接