Random Tech Thoughts

The title above is not random

Memory Consistency Model

Memory consistency model 是个不好想的东西,写并发程序的总有一天会遇到这个坑,如果不知道它的存在连怎么掉坑里的都不知道。我目前自己对此也不是弄得一清二楚,写这篇文章更多是让我自己做个总结,所以只做简单介绍,另外推荐一些我觉得介绍这方面的好文章。

什么是 Memory Consistency Model

并行程序在多个 CPU 上同时执行代码,如果每个 CPU 访问的都是自己私有的数据,那世界将非常美好,我们可以既不需要同步也不需要考虑 memory consistency model 的问题。但实际情况是总会有一些数据需要共享,有些 CPU 修改共享数据,有些读取。

Memory consistency model 定义了多个 CPU 同时对共享内存进行读写操作时内存的行为

单核,Strict Consistency 以及 reorder

在开始介绍令人发晕的多核问题前,先考虑单核上对同一块内存地址的读写操作应该有怎样的行为。直观的想法是读操作得到最近一次写操作写入的值,这就是 Strict Consistency。一般在单核上,我们观测到的就是这样的行为。

程序代码中指令的先后顺序被称为 program order。为了优化性能,代码中指令的实际执行顺序有可能跟 program order 不同。例如 CPU 在执行读指令等待内存加载到 cache 的过程中,可以先执行接下来的寄存器操作指令。这样的好处是 CPU 不用白白等着从而更好的利用流水线。

我们可能遇到两种 reorder:

  • compiler reorder: 程序代码中的语句序列,在编译器优化过程中可能发生变化
  • CPU reorder: 二进制代码中指令序列,在 CPU 上执行时可能发生变化

为便于编写程序,通常在单核上保证 strict consistency,也就是说对同一块内存地址,读指令总是得到上一次写指令写入的值。但这并不阻止我们对不同内存地址读写指令进行 reorder。

多说一句,写设备驱动的人有时需要防止 compiler/CPU reorder,因为对硬件寄存器的读写会影响硬件的状态,reorder 可能会改变驱动的语义。

多核会有什么问题

单核有了 reorder,在多核上推断不同 CPU 上指令之间的执行顺序就有了问题。下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
static int a = 0;
static int b = 0;

void foo(void) {
    a = 1;
    b = 1;
}

void bar(void) {
    while (b == 0);
    assert(a == 1);
}

假设 boo, bar 在不同 CPU 上执行。如果 foo 中的两条写操作发生了 reorder,bar 观察到 b 变为 1 并不意味着 a == 1,所以 bar 中的 assert 就有可能触发。

reorder 在多核上带来的一个问题是:我们不能简单的利用单核上的 program order 去推断不同核之间指令执行的先后顺序

要注意的是,并不一定要有 reorder 才会导致上面的这个问题。每个 CPU 有自己的 cache,cache 之间通过 cache coherence protocol 来维护一致性。例如某个 core 在修改自己的缓存时需要向其他 core 发消息说这个地址的内存值我已经修改了,你们不能直接使用之前缓存的内容。这些消息何时传播到其他 CPU,是否要等到消息得到确认才能继续往下执行等等问题才是影响 memory consistency 的关键。我们可以把不同的 CPU 看做分布式系统中的不同节点,即使每个节点按顺序执行操作,对共享数据操作的结果何时传播到其他节点,会影响其他节点观测到的本节点的操作执行顺序

Sequential consistency

有人可能会问,为什么不在多核上也实现 strict consistency 来简化编程?问题在于实现 strict consistency 将迫使我们放弃很多优化的机会,例如很多 cache coherence 消息都需要得到确认之后才能继续往下执行,从而使得 CPU 的流水线不能得到最好的利用。

为了实现一些优化,常用的办法就是牺牲一些一致性的保证。其中最简单的一个一致性模型是 sequential consistency。它要求不同 CPU 上执行的指令能够等价于让它们以某种顺序执行,而且每个 CPU 上的指令在这个序列中以 program order 出现。换句话说就是多个 CPU 上执行的指令能够等价于把它们穿插在一起放在一个 CPU 上执行。sequential consistency 保证所有的指令之间可以定义一个全序

sequential consistency 比 strict consistency 要弱。下面的例子中 CPU 1 和 CPU 2 都访问内存地址 x,横向是时间,例子中的序列在 sequential consistency 下是合法的,但在 strict consistency 下不是(参考 stack exchange 上的一个问题

CPU1:  W(x)1
------------------------
CPU2:        R(x)0 R(x)1

sequential consistency 允许一个 CPU 对内存的修改晚一些传播到所有其他的 CPU,但是一定要同时传播到所有 CPU。

其他 consistency model

除了 strict/sequential consistency,还有很多其他的更弱的一致性模型。关于不同的 consistency model 以及各自能够实现的优化,请参考 Shared Memory Consistency Models: A Tutorial(这篇文章不是从程序员的视角出发,对我来说不是很好懂,我只仔细看了前面几章)。

x86 是一个提供比较强 memory consistency 的体系结构,它只会把访问不同地址的读指令提前到写指令前执行,而像 ARM, SPARC, POWER 这些体系结构的 memory consistency 就弱很多,所以写这对这些平台的程序更加需要当心 memory consistency 的问题。

何时需要考虑 memory consistency model

从我自己的经验来看,在 x86 上使用 pthread 提供的同步原语来写程序,一般不用考虑这个问题。但如果尝试自己实现同步操作,或者尝试实现 lock-free algorithm,那一定要小心 memory consistency 的问题。我在实现一个记录不同线程对共享内存访问顺序的算法时第一次遇到这个问题。如果你想真的观察到 memory reorder,推荐 Memory Reordering Caught in the Act 这篇博客文章。

Paul E. McKenney 的文章 Memory Barriers: a Hardware View for Software Hackers 从 cache coherence protocol 出发,以程序员的视角解释了 CPU 为何要 reorder 以及如何使用 memory barrier 来编写正确的程序,强烈推荐。

Language memory model

有些 language 会专门定义自己的 memory model,Java 是第一个这么做的主流语言,C++11 和 C11 也定义了 memory model,Go 也有自己的 memory model

Go 的 memory model 定义一个 goroutine 中的读操作何时保证能够读到其他 goroutine 中写入的值,另外还定义了同步操作需要保证的先后顺序。一个有意思的地方是 Go 的 channel 操作不满足 sequential consistency,所以在用 channel 来实现 semaphore 时要特别当心,具体请看这篇博客(最好跟下面提到的 reorder 优化一起看)。

Language memory model 限制了哪些操作不能被 reorder,除此以外的操作可以安全的利用 reorder 优化。golang-dev 上的这个回复给了一个利用 channel 操作不要求 sequential consistency 来做 compiler 优化的例子。

总结

Memory model 是一个不小的话题,我不是这方面的专家,这篇博客只是掀开了冰山一角而已。感兴趣的读者还请自己阅读前面提到的几篇文章,对程序员来说最重要的还是 Paul McKenney 的那篇。

Comments