本文使用

Zhihu On VSCode

创作并发布

2021 年 06 月 09 日,Go 语言的父亲之一 Russ Cox 发布了 三篇内存模型相关的推文,从硬件内存模型,逐步地引申到 Go 语言的内存模型。这里仅对这三篇文章进行翻译,这是第三篇,Updating the Go Memory Model.

目前的 Go 语言内存模型是在 2009 年编写的,之后进行了小规模的更新。很明显,我们应该在当前的内存模型中加入一些细节,其中包括对数据竞争检测器的理解及 sync/atomic 包是如何同步程序的。

这篇文章重述了 Go 的整体理念和当前的内存模型,然后概述了我认为我们应该对 Go 内存模型做出的相对较小的调整。它假定了先前的帖子 "硬件内存模型" 和 "编程语言内存模型" 中介绍的背景。

我已经在 GitHub 上开了一个讨论,以收集对这里提出的想法的反馈。基于这些反馈,我打算在本月晚些时候准备一份正式的 Go 提案。使用 GitHub 讨论本身也是一种尝试,继续尝试找到一种合理的方式来扩大对重要变化的讨论。

Go's Design Philosophy

Go 的目标是成为一个用于构建实用、高效系统的编程环境。它的目标是为小型项目提供轻量级的服务,同时也能优雅地扩展到大型项目和大型工程团队。

Go 鼓励在高层次上处理并发问题,特别是通过通信。Go 的第一句谚语是 "不要通过分享内存来通信。通过通信来分享内存"。另一个流行的谚语是:"清楚比聪明好"。换句话说,Go 鼓励通过避免微妙的代码来避免微妙的 bug。

Go 的目标不仅仅是可理解的程序,还包括可理解的语言和可理解的包 API。复杂或微妙的语言特性或 API 与这个目标相矛盾。正如 Tony Hoare 在 1980 年的 图灵奖演讲 中所说。

我的结论是,软件设计可以通过两种方法构建。一种方法是使其简单到明显没有缺陷,另一种方法是使其复杂到没有明显缺陷。

第一种方法要困难得多。它需要与发现简单的物理规律一样的技巧、奉献、洞察力,甚至是灵感,而这些规律是自然界复杂现象的基础。它还要求人们愿意接受受物理、逻辑和技术限制的目标,并在无法满足相互冲突的目标时接受妥协。

这与 Go 的 API 理念相当吻合。在设计过程中,我们通常会花很长时间来确保一个 API 是正确的,努力将其简化为最小的、最有用的本质。

Go 作为一个有用的编程环境的另一个方面是对最常见的编程错误有明确的语义定义,这有助于理解和调试。这个想法并不新鲜。再次引用 Tony Hoare 的话,这次是他 1972 年的 "软件质量" 检查表。

除了使用起来非常简单之外,一个软件程序必须很难被滥用;它必须对编程错误很友好,对它们的发生给予明确的指示,并且在其效果上永远不会变得不可预测。

为有错误的程序提供明确的语义,这种可以说是常识,但并不像人们想象的那样普遍。在 C/C++ 中,未定义行为已经演变成一种编译器编写者的全权委托,可以将稍有缺陷的程序变成非常有缺陷的程序,而且方式越来越有趣。Go 不采用这种方法:没有 "未定义行为"。特别是,像空指针解除引用、整数溢出和无意的无限循环等错误在 Go 中都有明确的语义。

Go's Memory Model Today

Go 的内存模型 从以下建议开始,与 Go 的整体理念一致。

  • 修改被多个 goroutine 同时访问的数据的程序必须将这种访问线性化。
  • 为了线性化访问数据,用通道操作或其他同步原语来保护数据,如 sync 和 sync/atomic 包中的工具。
  • 如果你必须阅读本文档的其余部分才能理解你的程序的行为,那么你就太聪明了。
  • 不要自作聪明。

这仍然是很好的建议。这个建议也与其他语言想尽力做到 DRF-SC 的做法是一致的:通过同步来消除数据竞争,然后程序就会表现得像顺序一致,不需要理解内存模型的其余部分。

在这个建议之后,Go 内存模型定义了一个传统的基于 happens-before 的数据竞争读写操作。像 Java 和 JavaScript 一样,Go 中的读可以观察到任何较早的但尚未被覆盖的写,或者任何有竞争的写;只有一个这样的写可以保证一个特定的结果。

内存接着定义了建立多个协程之间的 happens-before 边界的同步操作。这些操作都是常见的,但有一些 Go 特有的地方。

  • 如果一个包 p 导入了包 q,那么 q 的 init 函数的完成发生在 p 的任何函数的开始之前。
  • 函数 main.main 的开始发生在所有 init 函数完成之后。
  • 启动一个新的 goroutine 的 go 语句发生在 goroutine 的执行开始之前。
  • 在一个通道上的发送发生在该通道的相应接收完成之前。
  • 一个通道的关闭发生在一个返回零值的接收之前,因为该通道已经关闭。
  • 在一个没有缓冲的通道上的接收操作发生在该通道的发送完成之前。
  • 在一个容量为 C 的通道上的第 k 次接收发生在该通道的第 k+C 次发送完成之前。
  • 对于任何 sync. Mutex 或 sync. RWMutex 变量 l 和 n<m,n 个 l. Unlock() 的调用发生在 m 个 l. Lock() 调用返回之前。
  • 调用一次 f() 的 Once. Do(f) 在任一 once. Do(f) 返回之前返回。

这里显然没有提到 sync/atomic 包和 sync 包中较新的 API。内存模型以一些不正确的同步的例子结束。它没有包含不正确的编译的例子。

Changes to Go's Memory Model

2009 年,当我们着手编写 Go 的内存模型时,Java 的内存模型刚刚被修订,而 C/C++11 的内存模型正在定稿。一些人极力鼓励我们采用 C/C++11 模型,以利用所有已经完成的工作。这对我们来说似乎很冒险。相反,我们决定对我们要做的保证采取更保守的方法,这一决定被随后十年的论文所证实,这些论文详细描述了 Java/C/C++ 系列内存模型中非常微妙的问题。定义一个足以指导程序员和编译器编写者的内存模型是很重要的,但是完全正式地定义一个内存模型 -- 正确地定义!-- 似乎仍然超出了最有才华的研究人员的掌握范围。对于 Go 来说,只要继续说出有用的最低限度就足够了。

本节列出了我认为我们应该做出的调整。如前所述,我已经在 GitHub 上开了一个讨论,以收集反馈。根据这些反馈,我计划在本月晚些时候准备一份正式的 Go 提案。

Document Go's overall approach

"不要自作聪明" 的建议很重要,应该保留,但我们也需要在深入研究 happens-before 的细节之前多说说 Go 的整体方法。我看到了多个关于 Go 方法的不正确的总结,比如说声称 Go 的模型是 C/C++ 的 "DRF-SC 或 Catch Fire"。误读是可以理解的:文档没有说方法是什么,而且文档很短(材料也很微妙),人们看到的是他们期望看到的东西,而不是有或没有的东西。

下面的内容是要添加的一些东西:

overview

Go 的内存模型与其他语言的方法基本相同,旨在保持语义的简单、易懂和实用。

数据竞争的定义是:除了所有涉及的访问都是由 sync/atomic 包提供的原子数据访问之外,对一个内存位置的写与对同一位置的另一个读或写同时发生。如前所述,我们强烈建议程序员使用适当的同步来避免数据竞争。在没有数据竞争的情况下,Go 程序的表现就像所有的 goroutines 都被复用到一个处理器上一样。这一特性有时被称为 DRF-SC:无数据竞争的程序以顺序一致的方式执行。

其他编程语言通常对含有数据竞争的程序采取两种方法中的一种。第一种,以 C 和 C++ 为例,含有数据竞争的程序是无效的:编译器可能会以任意令人惊讶的方式破坏它们。第二种,以 Java 和 JavaScript 为例,有数据竞争的程序有明确的语义,限制了数据竞争的可能影响,使程序更可靠,更容易调试。Go 的方法介于这两者之间。有数据竞赛的程序是无效的,因为实现可以报告数据竞争并终止程序。但除此之外,有数据竞争的程序有明确的语义和有限的结果,使错误的程序更可靠,更容易调试。

这段文字应该明确说明 Go 与其他语言的不同之处,纠正读者之前的任何期望。

在 "Happens Before" 这一小节的末尾,我们还应该澄清,某些数据竞争仍然可以导致损坏。它目前的结果是:

对大于一个字的数值的读写,表现为多个字大小且顺序不明确的读写

我们需要添加:

请注意,这意味着多字数据结构上的竞争可能导致不一致的值,而不是对应于单一的写入。当这些值依赖于内部(指针,长度)或(指针,类型)键值对的一致性时,就像大多数 Go 实现中的接口值、map、切片和字符串那样,这种竞争又会导致任意的内存损坏。

这将更清楚地说明对有数据竞争的程序的保证的限制。

Document happens-before for sync libraries

自内存模型编写以来,新的 API 已经被添加到 sync 包 中。我们需要将它们添加到内存模型中(issue #7948)。值得庆幸的是,这些添加的内容似乎很简单,如下所示:

[sync. Cond](https://golang.org/pkg/sync/#Cond)[sync.Map](https://golang.org/pkg/sync/#Map)[sync. Pool](https://golang.org/pkg/sync/#Pool)[sync. WaitGroup](https://golang.org/pkg/sync/#WaitGroup)

这些 API 的使用者需要了解这些保证以便有效使用它们。因此,虽然我们应该在内存模型中保留这些以达到说明性目的,但我们还应将其包含在 sync 包的 DOC 注释中。这还将有助于为第三方同步原语 API 示范给排序保证 (ordering guarantees) 写文档的重要性。

Document happens-before for sync/atomic

内存模型中缺少原子操作,我们需要添加它们(issue#5045)。我认为我们应该说:

sync/atomic 包中的 API 统称为 "原子操作",可以用来同步不同的 goroutine 的执行。如果一个原子操作 A 的效果被原子操作 B 观察到,那么 A 就会在 B 之前发生。在一个程序中执行的所有原子操作的行为就像以某种顺序一致的方式执行。

这是 Dmitri Vyukov 在 2013 年建议的,也是 我在 2016 年非正式承诺的。它还具有与 Java 的 volatiles 和 C++ 的默认 atomics 相同的语义。

在 C/C++ 中,同步原子学只有两种选择:顺序一致 或 acquire/release。(Relaxed atomics 不创建 happens-before 的边边界,因此没有同步的效果) 在这两种选择中,首先,能够推理多个位置上原子操作的相对顺序非常重要,其次,顺序一致的原子操作比 acquire/release 原子操作代价要高得多。

wg.counterwg.waiters*addrroot.nwait

最根本的问题是,使用 acquire/release atomics 来使程序无竞争,并不会导致程序以顺序一致的方式执行,因为原子本身就不是顺序一致执行的。也就是说,这种程序不提供 DRF-SC。这使得这种程序很难推理,因此很难正确编写。

sync/atomic

这两方面的考虑都强烈地表明我们应该采用顺序一致而不是 acquire/release:顺序一致更有用,而且一些芯片已经完全缩小了这两个层次之间的差距。据推测,如果差距很大,其他人也会这样做。

同样的考虑,再加上 Go 的整体理念是拥有最小的、容易理解的 API,因此反对提供 acquire/release 放作为额外的的 API 集。最好只提供最容易理解、最有用、最不容易误用的原子操作集。

另一种可能性是提供原始屏障而不是原子操作。(当然,C++ 同时提供了这两样东西。) 障碍物的缺点是使期望不那么明确,而且在某种程度上更具有体系结构的特殊性。Hans Boehm 的页面 “Why atomics have integrated ordering constraints” 介绍了提供原子操作而不是屏障的论点(他用了 fences 这个词)。一般来说,原子操作要比内存屏障容易理解得多,而且由于我们今天已经提供了原子操作,所以我们不能轻易删除它们。有一个机制比有两个机制更好。

Maybe: Add a typed API to sync/atomic

上面的定义说,当一块特定的内存必须被多个 goroutine 同时访问而没有其他同步操作时,消除竞争的唯一方法是使所有的访问都使用 atomics。仅仅让部分访问使用原子操作是不够的。例如,与原子读或写同时进行的非原子写仍然是数据竞争,而与非原子读或写同时进行的原子写也是如此。

因此,一个特定的值是否应该用原子访问是这个值的一个属性,而不是特定的访问。正因为如此,大多数语言将这一信息放在类型系统中,如 Java 的 volatile int 和 C++ 的 atomic。Go 当前的 API 并没有这样做,这意味着正确的使用需要仔细标注结构体的哪些字段或全局变量只能使用原子 API 访问。

为了提高程序的正确性,我开始认为 Go 应该定义一组类型化的原子值,类似于目前的 atomic. Value, Bool, Int, Uint, Int32, Uint32, Int64, Uint64, 和 Uintptr。像 Value 一样,这些将有 CompareAndSwap、Load、Store 和 Swap 方法。比如说:

我将 Bool 列入清单,因为我们在 Go 标准库中多次从原子整数中构造出 atomic booleans(在未导出的 API 中)。这显然是有必要的。

我们还可以利用即将到来的泛型支持,为原子指针定义一个 API,这个 API 是类型化的,并且在其 API 中没有 unsafe 包。

为了回答一个明显的建议,我没有看到一个干净的方法来使用泛型,只提供一个单一的 atomic. Atomic[T],让我们避免把 Bool、Int 等等作为单独的类型引入,至少在编译器中没有特殊情况。而这也是可以的。

Maybe: Add unsynchronized atomics

所有其他现代编程语言都提供了一种方法来进行并发的内存读写,这些读写不会使程序同步,但也不会使程序失效(不会算作数据竞争)。C、C++、Rust 和 Swift 有 relaxed atomics。Java 有 VarHandle 的 "plain" 模式。JavaScript 有对 SharedArrayBuffer(唯一的共享内存)的非原子式访问。Go 没有办法做到这一点。也许它应该这样做。我不知道。

如果我们想增加非同步的原子读写,我们可以在有类型的 atomics 中增加 UnsyncAdd、UnsyncCompareAndSwap、UnsyncLoad、UnsyncStore 和 UnsyncSwap 方法。将它们命名为 "unsync" 可以避免 "relaxed" 这个名字带来的一些问题。首先,有些人把 "relaxed" 作为一种相对的比较,比如 "acquire/release 是一种比顺序一致性更放松的内存顺序"。你可以争辩说这不是这个术语的正确用法,但它发生了。其次,更重要的是,这些操作的关键细节不是操作本身的内存排序,而是它们对程序其他部分的同步性没有影响。对于那些不是内存模型专家的人来说,看到 UnsyncLoad 应该可以清楚地知道没有同步,而 RelaxedLoad 可能不会。Unsync 看起来和 Unsafe 一样,这也很好。

随着 API 的不断出现,真正的问题是是否要添加这些 API。提供非同步原子的通常原因是,它对某些数据结构中的快速路径的性能确实很重要。我的总体印象是,它在非 x86 架构上最为重要,尽管我没有证据来支持这一点。如果不提供非同步原子,可以说是对这些架构的惩罚。

反对提供非同步原子的一个可能的理由是,在 x86 上如果忽略潜在的编译器重新排序的影响的话,非同步原子与获取 / 释放原子是没有区别的。因此,它们可能被滥用来编写只在 x86 上工作的代码。反驳的理由是,这种潜规则不会通过竞态检测器,它实现了实际的内存模型,而不是 x86 内存模型。

在我们今天缺乏证据的情况下,我们没有理由增加这个 API。如果有人强烈地认为我们应该添加它,那么说明问题的方法是收集以下两方面的证据:(1)程序员需要编写的代码的普遍适用性,以及(2)使用非同步原子在广泛使用的系统上产生的显著性能改进。(用 Go 以外的语言来证明这一点也是可以的。)

Document disallowed compiler optimizations

目前的内存模型以给出无效程序的例子结束。由于内存模型是程序员和编译器编写者之间的契约,我们应该增加无效的编译器优化的例子。例如,我们可以添加:


Incorrect compilation

Go 的内存模型对编译器优化的限制和对 Go 程序的限制一样多。一些在单线程程序中有效的编译器优化在 Go 程序中是无效的。特别是,编译器不能在无竞态程序中引入数据竞争。它不能允许一次读取观察多个值。它也不允许一个单一的写来写多个值。

不在无竞态程序中引入数据竞争意味着不将读或写移出条件语句。例如,编译器不得将此程序中的条件反转。

也就是说,编译器不得将程序改写成这个程序。

cond*p
*p*q
*p*q
*p*q
*p*q*p*q
*p
i = *p*p
*p

也就是说,它不能把程序改写成这个程序。

*p*p=3*p*p=1*p=3

请注意,所有这些优化在 C/C++ 编译器中都是允许的:与 C/C++ 编译器共享后端的 Go 编译器必须注意禁用那些对 Go 无效的优化。


这些类别和例子涵盖了最常见的 C/C++ 编译器优化,这些优化与定义的数据竞争数据访问语义不兼容。它们清楚地确立了 Go 和 C/C++ 有不同的要求。

Conclusion

Go 在内存模型上采取保守的一般做法,对我们来说是很好的,应该继续下去。然而,有一些变化是早该进行的,包括定义 sync 和 sync/atomic 包中新 API 的同步行为。特别是 atomics 应该被记录下来,以提供顺序一致的行为,创建非原子操作和原子操作代码之间的 happens-before 边界来进行同步。这将与所有其他现代系统语言所提供的默认原子学相匹配。

也许更新中最独特的部分是明确指出,有数据竞争的程序可以停止报告这个竞争,但在其他方面有明确的语义。这对程序员和编译器都有约束,它将并发程序的可调试性和正确性置于编译器编写者的便利之上。

Acknowledgements

这一系列的文章在很大程度上受益于与我有幸在谷歌工作的一长串工程师的讨论和反馈。我对他们表示感谢。我对任何错误或不受欢迎的意见承担全部责任。