【译】更新 Go 内存模型 Updating the Go Memory Model

2022-10-26 15:30:17 浏览数 (2)

这是 RSC 关于 Go 内存模型系列文章的最后一篇,介绍了 Go 处理竞争的整体思路和后续需要或可能做的一些更新,主要包括需要在文档中明确清楚 Go 能保证什么,不能保证什么以及一些可能需要添加的 API。作者更多的是站在 Go 明了易用的设计哲学角度去思考每种方案的优缺点。

更新 Go 内存模型

(Memory Models, Part 3)

Posted on Monday, July 12, 2021. PDF

当前 go 语言的内存模型是在 2009 年编写成的,后来进行了一些细微的修改,很明显,我们需要为当前的内存模型添加一些细节,这其中包括对竞争检测器明确的背书以及清楚地说明 sync 和 atomic 中的 API 是如何进行同步的。

这一部分我们将重申 Go 的设计哲学和他目前的内存模型,在这之后我将概述我认为我们还应该对 Go 内存模型做哪些细微的调整。它以前面的 硬件内存模型 和 编程语言内存模型 为背景。

我在 GitHub 上开了一个讨论区 来收集关于这部分的意见和反馈。基于这些反馈,我会在本月晚些时候提交一份正式的提案。使用 GitHub 的讨论功能本身就是一场实验,对于重大的变化,我们试图找到一种合理的方式来扩大它的讨论范围。

Go 的设计哲学

Go 的目标是成为一个用于构建实用,高效系统的编程环境。它的目标是对与小项目来说,它是足够轻量的,但是他也可以足够优雅地扩展到大型项目和大型工程团队。

Go 鼓励在高级别上实现并发,特别是通过通信。Go 的第一条格言就是:“不要通过共享内存来通信,通过通信来共享内存”, 另外一条流行的格言是:“清晰胜过小聪明”,换句话说,换句话说,Go 鼓励避免微妙的代码来避免微妙的错误。

Go 的目标不仅仅是可理解的程序,还包括可理解的语言以及可理解的 package API。复杂或微妙的语言特性或 API 违背了这一目标。正如托尼·霍尔在他 1980 年的图灵奖演讲中所说:

I conclude that there are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies and the other way is to make it so complicated that there are no obvious deficiencies. 我的结论是,构建软件设计有两种方法:一种是使它简单到没有明显缺陷,另一种是使它复杂到没有明显缺陷。 The first method is far more difficult. It demands the same skill, devotion, insight, and even inspiration as the discovery of the simple physical laws which underlie the complex phenomena of nature. It also requires a willingness to accept objectives which are limited by physical, logical, and technological constraints, and to accept a compromise when conflicting objectives cannot be met. 第一种方法要困难得多。它需要同样的技能、奉献精神、洞察力,甚至是灵感,就像发现构成复杂自然现象的简单物理规律一样。它还需要愿意接受受到物理、逻辑和技术限制的目标,并在相互冲突的目标无法实现时接受妥协。

这与 Go 的 API 哲学非常契合。在设计过程中,我们通常会花很长时间来确保 API 是正确的,努力将其减少到最小、最有用的本质。

Go 作为一个有用的编程环境的另一个方面是对最常见的编程错误拥有定义良好的语义,这有助于理解和调试。这个想法并不是什么新鲜事。托尼·霍尔再次引用了他1972年的《软件质量》核对表:

As well as being very simple to use, a software program must be very difficult to misuse; it must be kind to programming errors, giving clear indication of their occurrence, and never becoming unpredictable in its effects. 软件程序除了简单易用外,还应该不容易被滥用。它必须妥善对待编程错误。它们发生时,应该给出明确的指示,而且永远不要让它的影响变得不可预测。

给拥有错误的程序定义一个良好的语义并不像人们常识中的那样简单。在 C/C 中,未定义的行为已经演变成了完全委托编译器作者将一个漏洞百出的程序变成另一个漏洞百出的程序。Go 不会采用这种方法,这里没有不确定的行为,针对诸如空指针引用,整数溢出,死循环等错误都在 Go 中有明确的语义。

Go 如今的内存模型

Go 的内存模型始于以下建议,这与 Go 的设计理念相吻合:

  • 修改由多个 Goroutine 同时访问的数据的程序必须串行化这种访问。
  • 想要串行化访问,请使用 channel 或其他同步原语(如 sync 或 atomic 中的)。
  • 如果您需要阅读本文的剩余部分才能理解程序行为,那您就太聪明了。
  • 不要太聪明了(don't be clever)。

目前它们仍是很好的建议。这些建议与其他语言对 DRF-SC 的鼓励是一致的:通过同步以消除数据竞争,使得程序的行为就像是顺序一致的一样,而无需去理解内存模型的其余部分。

在这个建议之后,Go 内存模型基于传统的 happened-before 对竟态读写进行了定义。就像在 Java 和 JavaScripts 中一样,Go 中的读操作可以在 happened-before 顺序下观察到那些较早发生但还未被覆盖的写或任意的竟态写操作。只安排这样的一次写入会强制只产生一个特定的结果。

然后,内存模型继续为交错执行的 Groutine 建立 happened-before 关系定义同步操作,这些操作是稀疏平常的,但仍具有一些 Go 的特色:

  • 如果包 p 引入了包 qq 完整的 init 函数将发生在 p 的任何执行之前。
  • Main 函数发生在所有 init 函数完成之后。
  • 开始一个新的 goroutine 的 go 语句发生在goroutine开始执行之前。
  • channel 上的 send 操作一定发生在该 channel 上对应 receive 操作完成之前。
  • channel 的 close 操作一定发生在 receive 操作因为 channel关闭而接收到零值之前。
  • 无缓冲 channel 的接受操作一定发生在该 channel 上的 send 操作完成之前。
  • 容量为 C 的 channel 的第 k 次 receive 发生在该 channel 的第 k C 次 send 完成之前。
  • 对于任何 sync.Mutexsync.RWMutex 变量 l 且存在 n < m,那么 l.Unlock() 的 n 次调用发生在 l.Lock() 的 m 次调用返回之前。
  • once.Do(f) 中的对 f 的单次调用一定发生在任何对 once.Do(f) 调用返回之前

值得注意的是,这个列表忽略了 sync 包中中新加的 API 以及 sync/atomic 的 API。

内存模型以一些不正确同步的示例结束。它不包含不能正确编译的示例。

Go 内存模型的改变

2009 年,当我们正着手准备编写 Go 内存模型的时候,Java 内存模型已经重新修订,C/C 11 的内存模型也最终已经确定,有人强烈建议我们采用 C/C 11 的模型,充分利用已经在造的轮子。但这对我们来说似乎太冒险了。相反,我们打算采用一种更保守的方式来确定我们应该做出什么样的保证,这一决定由近十年来详细描述 Java/C/C 模型中各种微妙问题的论文确定。定义足够多的内存模型去指导程序员和编译器开发者固然重要,但要在形式上完全正确的模型似乎任然超出了最有才华的研究人员的能力范畴。对 Go 来说,继续保持够用的最低限度就可以了。

这一部分列出了我认为我们应该做的调整。如前所述,我打开了一个 GitHub 讨论 来收集反馈。基于这些反馈,我计划在本月晚些时候准备一份正式的 Go 提案。

撰写 Go 整体方法的文档

"don't be clever" 的建议任然很有用,应该被保留下来,但是在深入了解 happened-before 的细节之前,我们还是需要谈一谈 Go 的整体方法。我见过许多对 Go 方法的错误总结,例如声称 Go 模型是 C/C 的 “DRF-SC or Catch Fire.” 这种误读是可以理解的,毕竟我们的文档没有说明我们的方法是什么,并且它是如此简短(和精妙),以至于人们只看到了它们所期望看到的而不是其本来的样子。

将添加的文本类似于:

概览 Go 处理其内存模型的方式与该语言的其他部分基本相同,目的是保持语义的简单、可理解和有用。 数据竞争的定义是对内存某一位置的写操作与对同一位置的读或斜操作同时进行,除非所有涉及的访问均为 sync/atomic 包提供的原子数据访问。如前所述,强烈建议程序员使用适当的同步来避免数据竞争。在没有数据竞争的情况下,Go 程序的行为就好像所有的 goroutines 被多路复用到一个单一的处理器上。这个属性有时被称为DRF-SC:无数据竞争的程序以顺序一致的方式执行。 其他编程语言对于存在数据竞争的程序一般采取两种方法:首先,以 C 和 C 为例,带有数据竞争的程序是无效的:编译器可能会以任意令人惊讶的方式破坏它们。第二种情况(以Java和JavaScript为例)是,具有数据竞争的程序已经定义了语义,从而限制了竞争可能产生的影响,并使程序更可靠、更容易调试。Go 的方法介于两者之间。具有数据竞争的程序在某种意义上是无效的,因为实现可能报告竞争并终止程序。但是,具有数据竞争的程序已经定义了具有有限数量结果的语义,这使得出错的程序更可靠,更容易调试。

这篇文章应该清楚地说明围棋与其他语言的不同之处,纠正读者之前的任何期望。

在 “Happened- Before” 部分的结尾,我们还应该澄清某些竞争仍然可以导致腐败。它目前以:

读取和写入大于单个机器字的值时,其行为相当于多个机器字大小的操作,但顺序不确定。

结束。我们应该添加:

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

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

为 sync 库的 happened-before 关系撰写文档

编写完内存模型以后,新的 API 又被加入了 sync 包 我们需要将他们添加到内存模型的文档中。谢天谢地,需要添加的东西还算简单,我认为它们如下:

  • 对于 sync.Cond: BroadcastSignal 一定在对任何的 Wait 的调用解除阻塞之前发生(happened-before)(wait 会一直等待指导接收到 BroadcastSignal 的信号)
  • 对于 sync.Map: Load, LoadAndDelete, LoadOrStore 是读操作. Delete, LoadAndDelete, Store 是写操作. 如果 LoadOrStore 的 loaded 返回 false 那他就是一个写操作. 写操作发生在任何观察写操作效果的读操作之前。
  • 对于 sync.Pool: 对 Put(x) 的调用一定在通过 Get 获取到相同的值 x 之前发生,类似的,对返回 x 的 New() 的调用一定发生在通过 Get 获取到相同的值 x 之前。
  • 对于 sync.WaitGroup: 对 Done 的表用一定发生在任何对 Wait 的调用解除阻塞之前发生(wait 会等待所有的 done 返回之后再返回)

这些 API 的用户需要知道这些保证,以更好地使用它们。因此,尽管出于演示目的,我们应该将这些文本添加到内存模型中,同时我们也应该在 sync 包的注释中添加这些内容。这也将有助于为第三方同步原语树立一个榜样,说明记录由API建立的顺序保证的重要性。

为 sync/atomic 的 happened-before 关系撰写文档

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

sync/atomic 包中的 API 统称为 “原子操作”,可用于同步不同 Goroutine 的执行。如果原子操作 B 观察到原子操作 A 的效果,则 A 发生在 B 之前。在程序中执行的所有原子操作的行为就好像是以某种顺序一致的顺序执行的。

这是 Dmitri Vyukov 在 2013 年提出的 也是我 在 2016 年非正式同意的 他与 Java 的 volatile 和 C 默认的原子有相同的行为。

就C/C 菜单而言,同步原子只有两种选择:顺序一致或 acquire/release。(宽松的原子无法建立 happened-before 关系,因此也就没有同步效果)对这两者的决策归结为,第一,能够推理出多个位置上原子操作的相对顺序有多重要,第二,顺序一致的原子与 acquire/release 原子相比要多昂贵。

首先,对多个位置的原子操作的相对顺序进行推理是非常重要的。在之前的一篇文章中,我举了一个 使用两个原子变量实现的无锁快速路径的条件变量的例子,这两个原子变量被使用 acuqire/release 原子打破了。这种模式会反复出现,例如,过去 sync.WaitGroup 的实现使用了一对原子 uint32 值 wg.countwg.waiters。Go 运行时信号量的实现也依赖于两个独立的原子字(atomic word)即信号量的值 *addr 和对应的等待计数器 root.nwait,还有很多。如果没有顺序一致的语义(也就是说,如果我们采用 acuqire/release 语义),人们仍然会编写这样的代码;它只会在某些情况下神秘地失败。

这里的核心问题是你使用 acquire/release 原子去编写了一个无数据竞争的程序,但它并不会得出完全按顺序一致的方式执行的程序的结果,因为你用的原子本身没有提供这样的保证。

关于第二个考虑,正如在之前的文章中提到的,硬件设计师开始为顺序一致的原子提供直接支持。例如,ARMv8 添加了用于实现顺序一致的原子的 ldar 和 stlr 指令,它们也是 acquire/release 原子的推荐实现。如果我们采用 acquire/release 原子实现 sync/atomic,那么在 ARMv8 的处理器上无论如何都会获得顺序一致的结果,但这无疑会导致依赖较强顺序性的程序在保证性较弱的平台上失效。这甚至可能发生在单个架构上,如果由于竞争窗口很小, acquire/release 和结果一致的原子之间的差异在实践中很难观察到。

这两个考虑都强烈建议我们应该采用顺序一致的原子而不是 acquire/release 原子:顺序一致的原子更有用,一些芯片已经完全缩小了这两个级别之间的差距。如果差距很大,想必其他芯片也会这么做。

出于同样的考虑,再加上 Go 拥有更少,更易理解的 API 设计总体理念,因此反对将 acquire/release 作为一组额外的、并行的 API 提供。似乎最好的办法是只提供最容易理解、最有用、最少误用的原子操作集。

另一种可能是提供原始屏障,而不是原子操作。(当然,c 两者都提供了。)障碍的缺点是使期望不那么明确,并且在某种程度上更局限于特定的体系结构。Hans Boehm 的 "Why atomics have integrated ordering constraints" 一页提出了提供原子而不是壁垒的观点(他使用了栅栏这个术语)。一般来说,原子操作要比栅栏操作容易理解得多,因为我们今天已经提供了原子操作,所以不能轻易地删除它们。一种机制总比两种机制好。

Maybe: 为 sync/atomic 提供类型化的 API

上面的定义表明,当一个特定的内存片段必须由多个 Goroutine 并发访问而没有其他同步时,消除竞争的唯一方法是对所有访问都使用原子。仅对部分访问使用原子是不够的。例如,与原子读或写并发的非原子写仍然是竞争,与非原子读或写并发的原子写也是竞争。

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

为了提高程序的正确性,我开始认为Go应该定义一组类型化的原子值,类似于当前的原子值。取值范围: Bool、Int、Uint、Int32、Uint32、Int64、Uint64、Uintptr。和 Value 一样,它们也有 CompareAndSwap、Load、Store 和 Swap 方法。例如

代码语言:javascript复制
type Int32 struct { v int32 }

func (i *Int32) Add(delta int32) int32 {
    return AddInt32(&i.v, delta)
}

func (i *Int32) CompareAndSwap(old, new int32) (swapped bool) {
    return CompareAndSwapInt32(&i.v, old, new)
}

func (i *Int32) Load() int32 {
    return LoadInt32(&i.v)
}

func (i *Int32) Store(v int32) {
    return StoreInt32(&i.v, v)
}

func (i *Int32) Swap(new int32) (old int32) {
    return SwapInt32(&i.v, new)
}

我把 Bool 包含在列表中,是因为我们在 Go 标准库中(在未导出的 api 中)多次用原子整数构造了原子布尔值。这显然是有必要的。

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

代码语言:javascript复制
type Pointer[T any] struct { v *T }

func (p *Pointer[T]) CompareAndSwap(old, new *T) (swapped bool) {
    return CompareAndSwapPointer(... lots of unsafe ...)
}

(以此类推)。来回答一个显而易见的建议,我没有看到一种好的方法去通过泛型来只提供单个原子。Atomic[T] 使我们可以避免将 Bool、Int 等作为单独的类型引入,至少在编译器中没有特殊情况时是这样。这是好的。 这没什么。

Maybe:添加非同步原子

所有其他现代编程语言都提供了一种进行并发内存读写的方法,这些读写不会同步程序,也不会使程序无效(不将其算作数据竞争)。C、C 、Rust 和 Swift 已经有了 relaxed 原子。Java有 VarHandle 的 “plain” 模式。JavaScript 可以以非原子方式访问SharedArrayBuffer(唯一的共享内存)。Go 做不到这一点。或许应该要做到。我不知道。

如果我们想要添加非同步的原子读写,可以将 UnsyncAdd、UnsyncCompareAndSwp、UnsyncLoad、UnsyncStore 和 UnsyncSwp 方法添加到类型化的原子中。将它们命名为 “unsync” 可以避免使用 “Relaced” 这个名称时可能出现的一些问题。首先,有些人使用Relaxed 作为一个相对比较,如在“acquire/release 是一个比顺序一致性更宽松的内存顺序。”你可以争辩说,这不是这个术语的恰当用法,但它确实发生了。第二,也是更重要的是,这些操作的关键细节不是操作本身的内存顺序,而是它们对其余程序的同步没有影响的事实。对于不是内存模型专家的人来说,看到 UnsyncLoad 应该会清楚地表明没有同步,而 RelaxedLoad 可能不会。Unsync 乍看起来像是不安全的,这也很好。

有了 API 之后,真正的问题是是否要添加这些功能。提供非同步原子的通常理由是,它确实对某些数据结构中的快速路径的性能很重要。 我的总体印象是,它在非 x86 体系结构上最重要,尽管我没有数据来支持这一点。不提供不同步的原子可以被认为是对这些体系结构的惩罚。

反对提供非同步原子的一个可能的理由是,在 x86 上,忽略潜在的编译器重新排序的影响,非同步原子与 acquire/release 原子没有区别。因此,它们可能会被滥用来编写只能在 x86 上运行的代码。相反的论点是,这种诡计不会通过竞争检测器的测试,该检测器实现的是实际的内存模型,而不是x86内存模型。

由于目前缺乏证据,我们没有理由添加这个API。如果有人强烈认为我们应该添加它,那么提出这种情况的方法将是收集以下两方面的证据:(1)程序员需要编写的代码的普遍适用性,以及(2)由于使用非同步原子而在广泛使用的系统上产生的显著性能改进。(用除 Go 以外的其他语言的程序来展示这一点会很好。)

为禁止编译器优化撰写文档

当前的内存模型最后给出了无效程序的例子。由于内存模型充当程序员和编译器编写者之间的契约,因此我们应该添加无效编译器优化的示例。例如,我们可以添加:

非法编译 Go内存模型对编译器优化的限制不亚于对 Go 程序的限制。一些在单线程程序中有效的编译器优化在 Go 程序中是无效的。特别是,编译器不能在无竞争的程序中引入数据竞争。它必须不允许一个读取观察多个值。并且它一定不允许一个写入操作可以写入多个值。 不将数据竞争引入到无竞争程序中意味着不移动出现的条件语句中的读或写操作。例如,编译器不能在这个程序中反转条件:

代码语言:javascript复制
i := 0
if cond {
	 i = *p
}

编译器不能把它优化成下面这样:

代码语言:javascript复制
i := *p
if !cond {
	i = 0
}

如果 cond 为假,而另一个 goroutine 正在写入 *p ,则原始程序是无竞争的,但优化后的程序包含竞争的。 不引入数据竞争也意味着不假设循环终止。例如,在这个程序中,编译器不能在循环之前移动对 *p*q 的访问:

代码语言:javascript复制
n := 0
for e := list; e != nil; e = e.next {
	n  
}
i := *p
*q = 1

如果 list 指向循环列表,那么原始程序将永远不会访问 *p*q,但优化后的程序将访问。 不引入数据竞争还意味着不假定所调用的函数总是返回或不包含同步操作。例如,在这个程序中,编译器不能在函数调用之前移动对 *p*q 的访问(至少在不直接知道f的精确行为的情况下不能)。

代码语言:javascript复制
f()
i := *p
*q = 1

如果调用从未返回,那么原始程序将永远不会访问 *p*q,但优化后的程序将访问。如果调用包含同步操作,那么原始程序可以在访问 *p*q 之前建立 happened-before 关系,但优化后的程序不会。 不允许单个读取观察多个值意味着不从共享内存重新加载本地变量。例如,在这个程序中,编译器不能泄漏 i 并从 *p 重新加载它

代码语言:javascript复制
i := *p
if i < 0 || i >= len(funcs) {
	panic("invalid function index")
}
... complex code ...
// compiler must NOT reload i = *p here
funcs[i]()

如果复杂的代码需要许多寄存器,单线程程序的编译器可以丢弃i而不保存副本,然后在 funcs[i]() 之前重新加载 i = *p .Go 编译器不能这样做,因为 *p 的值可能已经改变了。(相反,编译器可能会将 i 泄漏到堆栈中。) 不允许一次写操作对多个值进行写操作也意味着不使用将在写操作之前将局部变量写入为临时存储的内存。例如,编译器不能在这个程序中使用*p作为临时存储

代码语言:javascript复制
*p = i   *p/2

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

代码语言:javascript复制
*p /= 2
*p  = i

如果 i 和 *p 的起始值为 2,则原始代码执行 *p = 3,因此一个竞争线程只能从 *p 读取 2 或 3。重写的代码执行 *p = 1,然后 *p = 3,允许一个竞争线程也读取 1。 请注意,所有这些优化都允许在 C/C 编译器中进行:与 C/C 编译器共享后端的 Go 编译器必须注意禁用这些在 Go 中无效的优化。

总结

Go 在其内存模型中保持保守的总体方法对我们很有帮助,应该继续下去。然而,有一些更改是早就应该做的,包括在 sync 和 sync/atomic 包中定义新 API 的同步行为。atomic 尤其应该被记录下来,以提供顺序一致的行为,从而创建 happened-before 关系来同步它们周围的非原子代码。这将与所有其他现代系统语言提供的默认原子相匹配。

也许更新最独特的部分是明确声明,具有数据竞争的程序可能会被停止以报告竞争,但除此之外,程序具有定义良好的语义。这限制了程序员和编译器,而且它优先考虑并发程序的可调试性和正确性,而不是编译器编写者的便利性。

鸣谢

这一系列的文章使我在与许多工程师的反馈和讨论中受益匪浅,我庆幸在谷歌能与他们共事。对文章中的错误和不受欢迎的观点,我将承担全部责任。

0 人点赞