内存一致模型

读者可能注意到了,无论是在谈论 Go 的运行时还是编译器,直到目前为止我们都有意无意的
尝试去回避 Go 语言的「内存模型」这个话题。

这有非常多的原因,作为本章的收尾,也是全书对 Go 语言同步原语与同步模式的一个总结,
我们最后来详细展开内存模型这个话题,解答读者心中的疑惑。
对为什么到目前为止我们都刻意的回避有关内存模型的内容作出一个相对完整的解释。

5.9.1 内存模型的重要性

内存一致模型,或称内存模型,是一份语言用户与语言自身、语言自身与所在的操作系统平台、
所在操作系统平台与硬件平台之间的契约。它定义了并行状态下拥有确定读取和写入的时序的条件,
并回答了一个共享变量是否具有足够的同步机制来保障一个线程的写入能否发生在另一个线程的读取之前这个问题。

在一份 Go 语言的程序被写成后,将经过编译器的转换与优化、所运行操作系统或虚拟机等动态优化器的优化,以及
CPU 硬件平台对指令流的优化才最终得以被执行。这个过程意味着,对于某一个变量的读取与写入操作,可能
被这个过程中任何一个中间步骤进行调整,从而偏离程序员在程序中所指定的原有顺序。
没有内存模型的保障,就无法正确的推演程序在最终被执行时的正确性。

内存模型的策略同样有着长期影响,并且直接决定了程序的可移植性和可维护性。
例如,过强的内存模型将约束硬件和编译器优化的空间,从而严重降低程序性能上限;
已经选择了强内存模型的硬件体系结构,无法在不破坏兼容性的情况下向更弱的内存模型进行迁移,
这种兼容性破坏所带来的代价就是要求其平台上的程序重新实现其源码。

这种横跨用户、软件与硬件三大领域的主题使得内存模型的设计愿景变得异常的困难,至今仍是一个开放的研究问题。因此在讨论 Go 语言的内存模型之前,我们还需要了解现有的内存模型、历史上软硬件平台之间形成契约的经验教训。

5.9.2 强序与弱序

令同步模型为对内存访问的一组约束,这些约束指定了需要如何以及何时完成同步,则当且仅当硬件与遵循该同步模型的所有软件顺序一致时,称该同步模型对于硬件而言满足弱序(Weak Ordering)。

5.9.3 免数据竞争范式

当一个程序在特定输入上具有顺序一致的执行顺序时,且其中两个相互冲突的操作同时执行,则称其为无数据竞争(Data-Race-Free, DRF)。

5.9.4 历史实践

C++ 是一个在内存模型方面实践优秀的一个例子。

线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。

        x.store(1)      x.load()
G1 ---------+----------------+------>


G2 -------------------+------------->
                x.store(2)

在这种情况下线程 G1, G2x 的两次写操作是原子的,且 x.store(1) 是严格的发生在 x.store(2) 之前,x.store(2) 严格的发生在 x.load() 之前。 值得一提的是,线性一致性对全局时钟的要求是难以实现的,这也是人们不断研究比这个一致性更弱条件下其他一致性的算法的原因。

顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。

        x.store(1)  x.store(3)   x.load()
G1 ---------+-----------+----------+----->


G2 ---------------+---------------------->
              x.store(2)

或者

        x.store(1)  x.store(3)   x.load()
G1 ---------+-----------+----------+----->


G2 ------+------------------------------->
      x.store(2)

在顺序一致性的要求下,x.load() 必须读到最近一次写入的数据,因此 x.store(2)x.store(1) 并无任何先后保障,即 只要 G2x.store(2) 发生在 x.store(3)s 之前即可。

因果一致性:它的要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求。

      a = 1      b = 2
G1 ----+-----------+---------------------------->


G2 ------+--------------------+--------+-------->
      x.store(3)         c = a + b    y.load()

或者

      a = 1      b = 2
G1 ----+-----------+---------------------------->


G2 ------+--------------------+--------+-------->
      x.store(3)          y.load()   c = a + b

亦或者

     b = 2       a = 1
G1 ----+-----------+---------------------------->


G2 ------+--------------------+--------+-------->
      y.load()            c = a + b  x.store(3)

上面给出的三种例子都是属于因果一致的,因为整个过程中,只有 cab 产生依赖,而 xy 在此例子中表现为没有关系(但实际情况中我们需要更详细的信息才能确定 xy 确实无关)

最终一致性:是最弱的一致性要求,它只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。因此我们甚至可以对此条件稍作加强,例如规定某个操作被观察到的时间总是有界的。当然这已经不在我们的讨论范围之内了。

    x.store(3)  x.store(4)
T1 ----+-----------+-------------------------------------------->


T2 ---------+------------+--------------------+--------+-------->
         x.read()      x.read()           x.read()   x.read()

在上面的情况中,如果我们假设 x 的初始值为 0,则 T2 中四次 x.read() 结果可能但不限于以下情况:

3 4 4 4 // x 的写操作被很快观察到
0 3 3 4 // x 的写操作被观察到的时间存在一定延迟
0 0 0 4 // 最后一次读操作读到了 x 的最终值,但此前的变化并未观察到
0 0 0 0 // 在当前时间段内 x 的写操作均未被观察到,但未来某个时间点上一定能观察到 x 为 4 的情况

5.9.5 发生序关系

Go 的 Goroutine 采取并发的形式运行在多个并行的线程上,
而其内存模型就明确了 对于一个 Goroutine 而言,一个变量被写入后一定能够被读取到的条件
在 Go 的内存模型中有事件时序的概念,并定义了 happens before ,即表示了在 Go 程序中执行内存操作的一个偏序关系。

我们不妨用 < 表示 happens before,则如果事件 e1 < e2,则 e2 > e1
同样,如果 e1e2e1 ≤ _e2,则 e1e2 happen concurrently (e1 = e2)。
在单个 Goroutine 中,happens-before 顺序即程序定义的顺序。

我们稍微学院派的描述一下偏序的概念。
(严格)偏序在数学上是一个二元关系,它满足自反、反对称和传递性。happens before(<)被称之为偏序,如果满足这三个性质:

  1. (反自反性)对于 ∀e1∈{事件},有:非 e1 < e1;
  2. (非对称性)对于∀e1, e2∈{事件},如果 e1 ≤ e2,e2 ≤ e1 则 e1 = e2,也称 happens concurrently;
  3. (传递性)对于∀e1, e2, e3 ∈{事件},如果 e1 < e2,e2 < e3,则 e1 < e3。

可能我们会认为这种事件的发生时序的偏序关系仅仅只是在探讨并发模型,跟内存无关。
但实际上,它们既然被称之为内存模型,就是因为它们与内存有着密切关系。
并发操作时间偏序的条件,本质上来说,是定义了内存操作的可见性。

编译器和 CPU 通常会产生各种优化来影响程序原本定义的执行顺序,这包括:编译器的指令重排、 CPU 的乱序执行。
除此之外,由于缓存的关系,多核 CPU 下,一个 CPU 核心的写结果仅发生在该核心最近的缓存下,
要想被另一个 CPU 读到则必须等待内存被置换回低级缓存再置换到另一个核心后才能被读到。

Go 中的 happens before 有以下保证:

  1. 初始化:main.init < main.main
  2. Goroutine 创建: go < Goroutine 开始执行
  3. Goroutine 销毁: Goroutine 退出 = ∀ e
  4. channel: 如果 ch 是一个 buffered channel,则 ch<-val < val <- ch
  5. channel: 如果 ch 是一个 buffered channel 则 close(ch) < val <- ch & val == isZero(val)
  6. channel: 如果 ch 是一个 unbuffered channel 则,ch<-val > val <- ch
  7. channel: 如果 ch 是一个容量 len(ch) == C 的 buffered channel,则 从 channel 中收到第 k 个值 < k+C 个值得发送完成
  8. mutex: 如果对于 sync.Mutex/sync.RWMutex 的锁 l 有 n < m, 则第 n 次调用 l.Unlock() < 第 m 次调用 l.Lock() 的返回
  9. mutex: 任何发生在 sync.RWMutex 上的调用 l.RLock, 存在一个 n 使得 l.RLock > 第 n 次调用 l.Unlock,且与之匹配的 l.RUnlock < 第 n+1 次调用 l.Lock
  10. once: f() 在 once.Do(f) 中的调用 < once.Do(f) 的返回

那么在 Go 语言发展的着十余年间,真正解决了内存模型的设计吗?

因此,Go 语言对其用户的忠告可以归结为五个字:别自作聪明。

最后编辑: kuteng  文档更新时间: 2021-10-19 14:31   作者:kuteng