19

我有一个关于GCC-Wiki 文章的问题。在标题“总体摘要”下,给出了以下代码示例:

线程 1:

y.store (20);
x.store (10);

线程 2:

if (x.load() == 10) {
  assert (y.load() == 20)
  y.store (10)
}

据说,如果所有 store 都是release并且所有 load 都是acquire,那么线程 2 中的 assert 不会失败。这对我来说很清楚(因为线程 1 中对 x 的存储与线程 2 中来自 x 的负载同步)。

但现在到了我不明白的部分。也有人说,如果所有 store 都是release并且所有 load 都是consume,结果是一样的。y 的负载是否有可能在 x 的负载之前被提升(因为这些变量之间没有依赖关系)?这意味着线程 2 中的断言实际上可能会失败。

4

2 回答 2

19

C11 标准的规定如下。

5.1.2.4 多线程执行和数据竞争

  1. 评估 A在16)评估 B之前是依赖排序的,如果:

    A 对原子对象 M 执行释放操作,并且在另一个线程中,B 对 M 执行消耗操作并读取由 A 为首的释放序列中的任何副作用写入的值,或

    — 对于某些评估 X,A 在 X 之前是依赖排序的,并且 X 携带对 B 的依赖。

  2. 如果 A 与 B 同步,则 A线程间评估发生在评估 B 之前,A 在 B 之前是依赖排序的,或者对于某些评估 X:

    — A 与 X 同步,并且 X 在 B 之前排序,

    — A 在 X 之前排序,并且 X 线程间发生在 B 之前,或者

    — 线程间发生在 X 之前,而 X 线程间发生在 B 之前。

  3. 注 7 “线程间发生在之前”关系描述了“序列之前”、“同步于”和“依赖顺序之前”关系的任意连接,但有两个例外。第一个例外是不允许串联以''dependency-ordered before'' 后跟''sequenced before'' 结尾。这种限制的原因是,参与“依赖排序之前”关系的消费操作仅针对该消费操作实际携带依赖关系的操作提供排序。此限制仅适用于此类连接的末尾的原因是任何后续发布操作都将为先前的消费操作提供所需的排序。第二个例外是不允许串联完全由''sequenced before''组成。这种限制的原因是(1)允许“线程间发生之前”被传递关闭,(2)下面定义的“发生之前”关系提供了完全由“顺序之前”组成的关系''。

  4. 如果 A 在 B 之前排序,或者A 线程间发生在 B之前,则评估 A发生在评估 B 之前。

  5. 相对于 M 的值计算 B,对象 M 上的可见副作用 A满足条件:

    A 发生在 B 之前,并且

    — X 对 M 没有其他副作用,即 A 发生在 X 之前,X 发生在 B 之前。

    由评估 B 确定的非原子标量对象 M 的值应为可见副作用 A 存储的值。

(重点补充)


在下面的评论中,我将缩写如下:

  • 依赖排序前: DOB
  • 线程间发生之前: ITHB
  • 发生在之前: HB
  • 之前测序: SeqB

让我们回顾一下这是如何应用的。我们有 4 个相关的内存操作,我们将其命名为评估 A、B、C 和 D:

线程 1:

y.store (20);             //    Release; Evaluation A
x.store (10);             //    Release; Evaluation B

线程 2:

if (x.load() == 10) {     //    Consume; Evaluation C
  assert (y.load() == 20) //    Consume; Evaluation D
  y.store (10)
}

为了证明断言永远不会出错,我们实际上试图证明A 在 D 处始终是可见的副作用。根据5.1.2.4 (15),我们有:

A SeqB B DOB C SeqB D

这是一个以 DOB 结尾的串联,后跟 SeqB。这被(17)明确规定为不是ITHB 连接,尽管 (16) 说了什么。

我们知道,由于 A 和 D 不在同一个执行线程中,所以 A 不是 SeqB D;因此 (18) 中对于 HB 的两个条件都不满足,并且 A 不满足 HB D。

因此,A 对 D 不可见,因为不满足 (19) 的条件之一。断言可能会失败。


那么,这将如何发挥作用,在 C++ 标准的内存模型讨论此处的第 4.2 节控制依赖项中进行了描述:

  1. (提前一段时间)线程 2 的分支预测器猜测if将被采用。
  2. 线程 2 接近预测采取的分支并开始推测性获取。
  3. 线程 2 乱序并推测性地0xGUNKy(评估 D)加载。(也许它还没有从缓存中驱逐?)。
  4. 线程 1 存储20y(评估 A)
  5. 线程 1 存储10x(评估 B)
  6. 10线程 2从x(评估 C)加载
  7. 线程 2 确认if被占用。
  8. 线程 2 的推测性负载y == 0xGUNK已提交。
  9. 线程 2 断言失败。

允许评估 D 在 C 之前重新排序的原因是因为消耗禁止它。这与acquire-load不同,它可以防止在其之后的任何加载/存储按程序顺序在其之前重新排序。同样,5.1.2.4(15) 指出,参与“依赖-排序之前”关系的消费操作仅针对该消费操作实际携带依赖的操作提供排序,并且绝对没有依赖两个负载之间。


CppMem 验证

CppMem是一个帮助探索 C11 和 C++11 内存模型下的共享数据访问场景的工具。

对于以下近似于问题场景的代码:

int main() {
  atomic_int x, y;
  y.store(30, mo_seq_cst);
  {{{  { y.store(20, mo_release);
         x.store(10, mo_release); }
  ||| { r3 = x.load(mo_consume).readsvalue(10);
        r4 = y.load(mo_consume); }
  }}};
  return 0; }

该工具报告了两种一致的、无竞争的情况,即:

消费,成功场景

其中y=20被成功读取,并且

消费,失败场景

在其中y=30读取“陈旧”初始化值。手绘圆圈是我的。

相比之下,当mo_acquire用于加载时,CppMem 只报告一个一致的、无竞争的场景,即正确的场景:

获取,成功场景

在其中y=20读取。

于 2015-08-17T07:59:04.297 回答
8

两者都在 atomic 存储上建立了一个传递的“可见性”命令,除非它们已被memory_order_relaxed. 如果线程使用其中一种模式读取原子对象x,则可以确定它看到了对所有原子对象y的所有修改,这些修改在写入之前完成x

“获取”和“消费”之间的区别在于对某些变量的非原子写入的可见性z,例如。对于acquire 所有写入,无论是否是原子的,都是可见的。因为consume只有原子的保证是可见的。

thread 1                               thread 2
z = 5 ... store(&x, 3, release) ...... load(&x, acquire) ... z == 5 // we know that z is written
z = 5 ... store(&x, 3, release) ...... load(&x, consume) ... z == ? // we may not have last value of z
于 2015-08-13T16:49:19.860 回答