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


13

我想编写可移植的代码(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原子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();
}

之所以能够达到目标,是因为
{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()返回trueset()。(这也知道,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一个线程正在解锁而另一个线程正在解锁try_lock;或者 在ISO标准std::mutex中,仅保证具有获取和发布顺序,而不是seq_cst。

如果是这种情况,那么check()就可以在此之前对主体进行“重新排序” y.store(true)请参阅Alex的答案,他们证明这是在PowerPC上发生的)。
这真的很糟糕,因为现在可能发生以下一系列事件:

  • thread_b()首先加载x0)的旧值
  • 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();
}

选项B

通过对Y使用read-modify-write memory_order_acq_rel操作,使用对Y的控制来实现同步:

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_addfetch_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();
}

如果您认为这里的问题是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()我可以像这样组合各种“边缘”吗(“程序顺序”又名“先后顺序”,“总顺序”,“发布前”,“获取后”)?我对此有严重的怀疑: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的实现


1
C ++内存模型没有任何StoreLoad重新排序的概念,只有“先同步”和“先发生”。(并且UB涉及非原子对象上的数据争用,这与真实硬件的asm不同。)在我知道的所有真实实现中, std::atomic_thread_fence(std::memory_order_seq_cst)确实编译成一个完整的障碍,但是由于整个概念都是实现细节,因此您找不到标准中对此的任何提及。(CPU内存模型通常根据相对于顺序一致性所允许的重新分配定义的。例如,x86是seq-cst +具有转发功能的存储缓冲区)
Peter Cordes

@PeterCordes谢谢,我的写作可能不清楚。我想传达您在“选项A”部分中写的内容。我知道我的问题的标题使用单词“ StoreLoad”,而“ StoreLoad”是一个完全不同的世界的概念。我的问题是如何将此概念映射到C ++中。或者,如果不能直接映射它,那么如何实现我提出的目标:防止foo()bar()调用两者。
qbolec

1
您可以用来compare_exchange_*对原子布尔执行RMW操作而无需更改其值(只需将期望值和新值设置为相同的值)。
mpoeter

1
@Fareanor和qbolec:atomic<bool>具有exchangecompare_exchange_weak。后者可用于(通过尝试)CAS(true,true)或false,false来进行虚拟RMW。它要么失败,要么原子替换其自身的值。(在x86-64 asm中,该技巧lock cmpxchg16b就是如何保证16原子的原子加载;效率低下,但比单独锁定更糟糕。)
Peter Cordes

1
@PeterCordes是的,我知道既foo()不会发生也不会发生这种情况bar()。我不想引入代码的许多“现实世界”元素,以避免出现“您认为自己有问题X但您有问题Y”的回答。但是,如果真的需要知道背景层set()是什么:是some_mutex_exit()check()try_enter_some_mutex()y是“有一些服务员”,foo()是“退出而无需唤醒任何人”,bar()是“等待上床” ...但是,我拒绝在这里讨论这个设计-我真的不能更改它。
qbolec

Answers:


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()

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

对于原子操作上的原子对象中号,其中修改中号取其值,如果有memory_order_seq_cst围栏Xÿ使得之前测序Xÿ之前测序,和Xÿ小号,然后B观察到A的影响或M的后续修改(按其修改顺序)。

也就是说,无论是check()看到存储在该值set,或y.load()认为价值书面方式y.store()(操作上y甚至可以使用memory_order_relaxed)。

方案C:
C ++ 17个标准状态[32.4.3,P1347]:

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

这里的重要词是“一致”。这意味着如果操作A在操作B之前发生,则A必须在S中B之前。然而,逻辑含义是单向的街道上,所以我们不能推断相反:仅仅因为一些操作ç先于操作d小号并不意味着Ç之前发生d

尤其是,在两个不同的对象在两个序列-CST操作不能用来建立关系之前发生的,即使操作在S.全序如果你想在不同的对象的操作顺序,你必须参考序列-CST栅栏(请参阅选项A)。


选项C无效并不明显。即使在私有对象上的seq-cst操作仍然可以在某种程度上命令其他操作。同意没有下同步-着,但我们不关心哪个富或酒吧运行(或显然没有)的,只是他们不跑。我认为先序列关系和seq-cst操作的总顺序(必须存在)确实可以为我们提供这点。
Peter Cordes

谢谢@mpoeter。您能否详细介绍一下选项A。答案中的三个项目符号中的哪一个适用于此?IIUC如果y.load()没有看到的作用y.store(1),那么我们可以从规则中证明atomic_thread_fencethread_a 在S中在thread_b之前atomic_thread_fence。我没有看到的是如何从中得出结论set()是可见的副作用check()
qbolec

1
@qbolec:我已经更新了我的答案有关的选项A.更多的细节
mpoeter

1
是的,在所有seq-cst操作上,本地seq-cst操作仍将是单个总订单S的一部分。但是小号是“唯一” 符合之前发生顺序和修改订单,也就是说,如果一个之前发生,那么一个 必须先小号。但逆不保证,即,仅仅因为一个先于小号,我们不能推出,是一个之前发生
mpoeter

1
好吧,假设setcheck可以安全地并行执行,我可能会选择Option A,尤其是在性能至关重要的情况下,因为它避免了对共享变量的争用y
mpoeter

1

在第一个示例中,y.load()读取0并不意味着y.load()在之前发生y.store(1)

但是,这确实暗示着由于规则seq_cst加载返回总顺序中最后一个seq_cst存储的值或之前未发生的某些non-seq_cst存储的值,因此它在单个总顺序中更早。它(在这种情况下不存在)。因此,如果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

1

@mpoeter解释了为什么选项A和B是安全的。

在实际实现中的实践中,我认为选项A仅std::atomic_thread_fence(std::memory_order_seq_cst)在线程A中需要,而在线程B中则不需要。

seq-cst存储实际上包括一个完整的内存屏障,或者在AArch64上至少不能与以后的获取或seq_cst加载重新排序(stlr顺序释放必须先从存储缓冲区中耗尽,然后ldar才能从缓存中读取)。

C ++-> asm映射可以选择将消耗存储缓冲区的成本放在原子存储或原子负载上。实际实现的明智选择是使原子负载便宜,因此seq_cst存储包括完整的屏障(包括StoreLoad)。尽管seq_cst的负载与大多数情况下的获取负载相同。

(但不是POWER;甚至负载也需要重量级的sync =完全屏障,才能停止从同一核心上的其他SMT线程进行存储转发,这可能导致IRIW重新排序,因为seq_cst要求所有线程都必须能够同意以下顺序:所有seq_cst操作。 其他线程是否总是以相同的顺序看到两次对不同线程中不同位置的原子写入?

(当然,为了获得正式的安全保证,我们确实都需要在栅栏上将获取/发布set()-> check()升级为seq_cst与之同步。我想也可以用于宽松的set中,但是轻松检查可能会与其他线程的POV中的bar重新排序。)


我认为选项C的真正问题在于,它依赖于一些可以与之同步的假想观察者y以及虚拟操作。 因此,我们期望编译器在为基于障碍的ISA创建asm时保留该顺序。

在实际的ISA上,这确实是正确的。这两个线程都包含一个完整的障碍或等效对象,并且编译器(尚未)优化原子。但是,当然,“编译为基于障碍的ISA”不是ISO C ++标准的一部分。 相干共享缓存是存在于asm推理但不存在于ISO C ++推理的假设观察者。

为了使选项C起作用,我们需要类似于dummy1.store(13);/ y.load()/ 的排序set();(如线程B所示)以违反某些ISO C ++规则

运行这些语句的线程必须表现得好像 set()首先执行(由于Sequenced Before)。很好,运行时内存排序和/或操作的编译时重新排序仍然可以做到这一点。

这两个seq_cst ops d1=13y与Sequenced Before(程序顺序)一致。 set()不参与seq_cst操作的必需的全局顺序,因为它不是seq_cst。

线程B不同步,与dummy1.store 所以没有之前发生的要求上set相对d1=13适用,即使该转让为释放操作。

我看不到其他可能的违反规则的情况;我在这里找不到与setSequenced-Before 一致的任何内容d1=13

该缺陷是“ dummy1.store发布set()”的原因。该排序仅适用于与其同步或在asm中同步的实际观察者。 正如@mpoeter回答的那样,seq_cst总订单的存在不会创建或暗示发生在关系之前,并且这是唯一正式保证seq_cst之外的订单的事物。

任何具有一致性共享缓存的“正常” CPU都可能在运行时真正发生这种重新排序,这似乎并不合理。(但是,如果编译器可以删除dummy1dummy2然后显然我们会遇到问题,我认为这是标准允许的。)

但是,由于C ++内存模型不是按照存储缓冲区,共享一致性缓存或允许重新排序的石蕊测试的方式定义的,因此C ++规则并没有正式要求健全性。这也许是有意允许甚至优化那些原来是线程专用的seq_cst变量。(当然,当前的编译器或原子对象的任何其他优化都不会这样做。)

一个线程真正可以看到set()最后一个而另一个线程可以看到set()第一个的实现听起来是难以置信的。甚至POWER都无法做到这一点;seq_cst的加载和存储都包含POWER的全部障碍。(我曾在评论中建议IRIW重新排序在这里可能是相关的; C ++的acq / rel规则足够弱以适应这种情况,但是与情况或其他情况发生之前完全缺乏同步的保证比任何硬件都弱得多。 )

除非实际上一个观察者,然后才为该观察者,否则C ++对non-seq_cst不做任何保证。 没有一个人,我们就在薛定ed的猫区。或者,如果两棵树掉在森林里,一棵树倒在另一棵树上吗?(如果是一片大森林,广义相对论说它取决于观察者,并且没有普遍的同时性概念。)


@mpoeter建议即使在seq_cst对象上,编译器甚至可以删除虚拟加载和存储操作。

我认为当他们可以证明没有任何东西可以与操作同步时,这可能是正确的。例如,可以看到dummy2没有转义该函数的编译器可能会删除该seq_cst负载。

这至少有一个现实的结果:如果针对AArch64进行编译,这将允许较早的seq_cst存储在实践中通过稍后的宽松操作进行重新排序,而这对于seq_cst存储+负载耗尽存储缓冲区之前的存储操作是不可能的。以后可以执行加载。

当然,即使ISO C ++并不禁止原子,当前的编译器也根本不优化原子。对于标准委员会来说,这是一个尚未解决的问题

我认为这是允许的,因为C ++内存模型没有隐式观察者,也没有所有线程都同意排序的要求。它确实提供了基于一致性缓存的某些保证,但是并不需要同时可见所有线程。


不错的总结!我同意,实际上,只要线程A具有seq-cst围栏就足够了。但是,基于C ++标准,我们没有必要保证从那里看到最新的值set(),因此我仍然会在线程B中使用篱笆。我想一个带有seq-cst围栏的宽松商店无论如何都会生成与seq-cst-store几乎相同的代码。
mpoeter

@mpoeter:是的,我只是在实践中而不是正式地谈论。在该部分的末尾添加了注释。是的,实际上,在大多数ISA上,我认为seq_cst存储通常只是普通存储(松弛)+障碍。或不; 在POWER上,seq-cst存储在存储sync 之前执行(重)操作,之后执行任何操作。godbolt.org/z/mAr72P 但是seq-cst负载的两侧都需要一些障碍。
Peter Cordes

1

在ISO标准std :: mutex中,只能保证具有获取和发布顺序,而不是seq_cst。

但是,不能保证没有“ seq_cst排序”,因为seq_cst这不是任何操作的属性。

seq_cst是对给定实现std::atomic或替代原子类的所有操作的保证。因此,您的问题是不正确的。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.