再说 c++11 内存模型

再说 c++11 内存模型

可见性与乱序

在说到内存模型相关的东西时,我们常常会说到两个名词:乱序与可见性,且两者经常交错着使用,容易给人错觉仿佛是两个不同的东西,其实不是这样,他们只是从不同的角度来描述一个事情,本质是相同的。比如说,我们有如下代码:

atomic<int> g_payLoad = {0};
atomic<int> g_guard = {0};

// thread 0
void foo1()
{
    g_payLoad.store(42, memory_order_relaxed);
    g_guard.store(1, memory_order_relaxed);
}

// thread 1
void foo2()
{
    int r = g_guard.load(memory_order_relaxed);
    if (r)
    {
       r = g_payLoad.load(memory_order_relaxed);
    }
}

因为 g_guard 与 g_payLoad 的读写都是以 relaxed 方式进行,因此我们会认为 foo2() 中 g_guard.load() 与 g_payLoad.load() 有可能会乱序(从不同的地址 load, cpu 可能会 speculate & prefetch,etc),但从另一个角度来看,g_payLoad 被 reorder 到 g_guard 之前(其它类型乱序同理),其实就相当于 g_payLoad 在 foo1 中的修改没有被 foo2 所看到,同一件事情的两个不同角度,如此而已。

但在处理 c++11 的内存模型时,语言标准上的措词却基本上是基于“可见性”来描述的(和 Java 的内存模型描述基本一致),看得多了,我也渐渐发现从“可见性”来理解问题有时真会容易很多,不信请继续看。

Happen-before

Happen-before 指的是程序指令间的一种关系,且是一种运行时的关系,是动态的,如果指令 A happen-before B, 则 A 的副作用能够被 B 所看到,容易理解对吧?且暂时可简单认为 happen-before 具有传递性(transitive)(准确地说是在不考虑 consume ordering的情况下), 如果 A happen-before B, B happen-before C,则我们可以认为 A happen-before C。
两条指令 A 与 B 如要满足 happen-before 关系,必须满足如下条件之一:

  1. A sequence-before B: 这个指的是在同一个线程里两条指令的关系,从程序源码上看如果指令 A 在 B 之前,则 A sequence before B, 比如前面代码中 g_payLoad.store() 这一行代码就是 sequence-before g_guard.store().
  2. A synchronize-before B: 这个指的是两个线程间的指令的关系,接下来一节再介绍。

因此当我们在理解代码时,如果想弄清楚当前线程里某些代码是否能看到公共变量在别的线程里被修改后的效果,我们应该第一时间这样来考虑,当前使用这些公共变量的代码能与别的线程里修改这些公共变量的代码建立一个 happen-before 的关系吗?如果有 happen-before 关系,则可以看到,如果没有则不一定,还是使用前面的例子:

// thread 0
g_payLoad.store(42, memory_order_relaxed); // 1
g_guard.store(1, memory_order_relaxed); // 2

// thread 1
int r = g_guard.load(memory_order_relaxed); // 3
if (r) r = g_payLoad.load(memory_order_relaxed); // 4

如果 g_payLoad.load() 要 load 到 #1 中所写入的值,则 #1 和 #4 这两者之间必须要有 happen-before 关系,而他们之间要有 happen before 关系,只需满足如下两个条件之一:

  1. 它们真的有 happen-before 关系,即上述代码中, #1 happen-before #4, 不过这个没法只根据 1 和 4 这两行代码来证明, 原因下面一节会具体解释。
  2. g_guard.store() 与 g_guard.load() 之间有 happen-before 关系, 即 #2 和 #3 之间有 happen-before 关系,则根据传递性,我们可以知道 #1 happen-before #4.

Synchronize-with

Sychronize-with 指的是两个线程间的指令在运行时的一种特殊关系,这种关系是通过 acquire/release 语义来达成的,具体来说,如果:

  1. 线程 A 中对某个变量 m 以 release 的方式进行修改,m.store(42, memory_order_release).
  2. 线程 B 中对变量 m 以 acquire 的方式进行读取,并且读到线程 A 所写的值(或以 A 为开始的一个 release sequence 所写入的值),则线程 B 中读 m 变量的那条指令与线程 A 中修改 m 的指令存在 synchronize-with 的关系。

顺便说一下 release sequence,操作 A 对变量 m 的 release sequence 指的是满足如下两个条件且作用于 m 上的操作的序列:

  1. 该操作与 A 在同一个线程内,且跟在 A 操作之后。
  2. 该操作在其它线程内,但是是以 Read-Modify-Write(RMW) 的方式对 m 进行修改。

其中, A 操作以 store release 的方式修改变量 m,而 RMW 操作是一种很特殊的操作,它的 read 部分要求永远能读到该变量最新的值。[参看 c++ 标准 29.3.12]

下图展示了一个经典的 synchonize-with 的例子:

这里有一点需要明确,操作 A 与 操作 B 是否存在 synchronize-with 关系,关键在于 B 是否读到了 A 所写入的内容(或 release sequence 写入的内容), 而 B 是否能读到 A 写入的内容与 A 这个操作是否已经进行了无关,c++ 标准的规定是,假如读到了,则 A synchronize-with B, 否则则不,因此对于某些关键变量,如果你想保证当你去读它时,总能读到它在别的线程里写入的最新的值,一般来说,你需要额外再设置一个 flag 用于进行同步,一个标准的模式就是前面例子中的 g_payLoad 与 g_guard。 g_payLoad 是你关注的关键信息或者说想要发布到别的线程的关键信息,而 g_guard 则是一个 flag,用于进行同步或者说建立 happen-before 的关系,只有建立了 happen-before 关系,你去读 g_payLoad 时,才能保证读到最新的内容。

sequential consistency

sequential consistency 这种模型实在是太美好了,它让编码变得这样地简单直接,一切都是和谐有序的,社会主义般地美好,而这种美好又是那么地触手可及,只要你完全不要使用其它模型,SC 就是你的了!而你所需付出的代价只是在某些平台上一点点效率的损失,就那么一点点!但不幸 c++ 程序员里面处女座的太多,因此我们得处理 acquire/release,甚至是 relaxed。而当 SC 与其它模型混合在了一起时,一定不要想当然以为有 SC 出现的地方就都是曾经的美好乐园,不一定了。

  1. 以 sequential consistency 方式进行的 load() 操作含有 acquire 语义。
  2. 以 sequential consistency 方式进行的 store() 操作含有 release 语义。
  3. 所有 sequential consistency 操作在全局范围内有一个一致的顺序,但这个顺序与 happen-before/synchronize-with 没有直接联系,sequential consistency Load 不一定会 Load() 到最新的值,sequential consistency write() 也并不一定就能马上被其它非 sequential consistency load() 所能 load() 到。

除此,需要注意的是 sequential consistency 类型的 fence,它是个例外,和纯粹的 SC load 和 SC store 不同,SC fence 能建立“类似” happen-before 的关系,参看 c++ 标准 29.3.6:

假如存在两个作用于变量 m 的操作 A 和操作 B,A 修改 m,而 B 读取 m,如果存在两个 memory_order_seq_cst 类型的 fence X 与 Y,使得:
1. A sequence-before X,且 Y sequence-before B.
2. 且 X 在全局顺序上处于 Y 之前(因为 X 和 Y 是 memory_order_seq_cst 类型的,因此肯定有一个全局顺序)。
则 B 会读到 A 写入的数据。

Dekker and Petterson's Algo

现在让我们尝试用前面介绍的知识来解决两个问题,如下是一个简化版的 Dekker's Algo,假设所有数据的初始值都是 0,则显然,如果所有内存操作都是以 relaxed 方式进行的话,则 r1 == r2 == 0 是可能的,因为 thread 0 里对 g_a 的读取不一定能看到 thread 1 对 g_a 的修改,对 g_b 的读取同理,现在的问题是,怎么才能阻止同时读到 r1 == r2 == 0?

// thread 0
g_a.store(42, memory_order_relaxed); // 1
r1 = g_b.load(memory_order_relaxed); // 2

// thread 1
g_b.store(24, memory_order_relaxed); // 3
r2 = g_a.load(memory_order_relaxed); // 4.

直接机械地套用 acquire/release 是不行的,#1 和 #4 不一定能建立 synchronize-with 关系,且 g_a 本身是关键变量,我们需要保证的是能读到它的最新值,直接用它来建立 synchonize-with 显然不能保证这点,#3 和 #2 同理。一个解法是分别在两个 thread 里分别加入一个 SC fence:

// thread 0
g_a.store(42, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst); // fence 1
r1 = g_b.load(memory_order_relaxed);

// thread 1
g_b.store(24, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst); // fence 2
r2 = g_a.load(memory_order_relaxed); // 4

原理很简单,因为 fence 1 和 fence 2 是 sequential consistency 类型的, 因此它们的副作用在全局上有一个固定顺序,要么 fence 1 先于 fence 2,要么 fence 2 先于 fence 1,根据前一节的介绍,我们知道要么 g_a 读到 42, 要么 g_b 读到 24, 因此肯定不会出现 r1 == r2 == 0.

现在是第二个问题,如下是 Peterson's Algo 的简化写法, 用于实现互斥锁,问题的关键是怎么保证 flag0 在线程 1 里能读到线程 0 对它的修改?等价问题是怎么阻止 #3 被 reorder 到 #1 之前,#6 被 reorder 到 #4 之前?

// Thread 0
flag0.store(true, memory_order_relaxed); // 1
r0 = turn.store(0, memory_order_relaxed); // 2
r1 = flag1.load(memory_order_relaxed); // 3

// Thread 1
flag1.store(true, memory_order_relaxed); // 4
r0 = turn.exchange(1, memory_order_relaxed); // 5
r1 = flag0.load(memory_order_relaxed); // 6

现在我们尝试用 acquire/release 语义来解决它,假设 thread 0 先执行并进入了临界区,然后 thread 1 后执行,当 thread 1 执行到 #6 时,怎么保证能看到 thread 0 对 flag0 的修改呢?根据前面第二节的介绍,我们知道关键在于要保证 #1 happen-before #6,又由于 #1 和 #6 分别在不同的线程,因此其实就是要保证 #1 synchronize-with #6,因此我们需要在 thread 0 中以 release 的方式写一个变量 A,然后在 thread 1 中以 acquire 的方式读取该变量 A,那么我们应该选取哪个变量作为这个关键变量呢?

  1. flag0 不行,原因与前面第一节的例子相同,flag0 是我们要读取的关键变量,我们要保证的是能读取到它的最新值,而不是通过它来实现 synchronize-with.
  2. flag1 也不行,flag1 在 thread 0 只有一个 load 操作,没有 release 语义(但如果用 flag1.fetech_add(0, memory_order_acq_rel) 呢?应该也是行的,只是不是最好,多了一次无谓的写操作)。
  3. 最优选择应该是 turn 变量。

因此得到如下解法如下:

// Thread 0
flag0.store(true, memory_order_relaxed); // 1
r0 = turn.exchange(1, memory_order_acq_rel); // 2
r1 = flag1.load(memory_order_acquire); // 3

// Thread 1
flag1.store(true, memory_order_relaxed); // 4
r0 = turn.exchange(2, memory_order_acq_rel); // 5
r1 = flag0.load(memory_order_acquire); // 6

让我啰嗦点指出其中一个关键,#2 和 #5 能建立 synchronize-with 关系的关键在于 exchange 是一个 RMW 操作,它的读操作总能够读到变量最新的值(c++ 标准 29.3.12),因此当 thread 0 先执行时,turn 会被以 release 的方式写入一个值,再然后后面 thread 1 执行 #3 ,会以 acquire 的方式对 turn 进行读取,因为 RMW 保证它的 load 会 load 到最新的值,因此此时 #2 synchronize-with #5,皆大欢喜。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值