转载 | Memory Models, Part 3-Updating the Go Memory Model
当前的Go语言内存模型是2009年编写的,自那以后只做了小幅更新。显然,至少有一些细节需要添加到当前的内存模型中,其中包括对数据竞争检测器的明确支持,以及对sync/atomic同步程序中API使用方式的清晰说明。
这篇文章重申了Go语言的整体理念和当前的内存模型,然后概述了我认为应该对Go内存模型进行的相对较小的调整。文章假设读者了解早期文章“硬件内存模型”和“编程语言内存模型”中介绍的背景内容。
我已经开启了一个GitHub讨论,以收集对这里提出的想法的反馈。基于这些反馈,我计划在本月晚些时候准备一份正式的Go提案。GitHub讨论本身也是一种试验,目的是继续寻找一种合理方式来扩展对重要变更的讨论。
Go’s Design Philosophy
Go旨在成为一个用于构建实用且高效系统的编程环境。它旨在对小型项目保持轻量,同时也能优雅地扩展到大型项目和大型工程团队。
Go鼓励以高级别处理并发,特别是通过通信。Go的第一个箴言是“不要通过共享内存来通信,而要通过通信来共享内存。”另一个流行的箴言是“清晰胜于巧妙。”换句话说,Go鼓励通过避免复杂代码来避免隐晦的错误。
Go不仅旨在实现易于理解的程序,还致力于实现易于理解的语言和易于理解的包API。复杂或微妙的语言特性或API与这一目标相矛盾。正如托尼·霍尔在他的1980年图灵奖演讲中所说:
我得出结论,构建软件设计有两种方法:一种是将设计做得如此简单,以至于显然没有缺陷;另一种是将设计做得如此复杂,以至于没有明显的缺陷。
第一种方法要困难得多。它要求具备与发现自然界复杂现象背后的简单物理定律相同的技能、奉献、洞察力,甚至灵感。它还需要接受因物理、逻辑和技术限制而设定的目标,并在目标冲突无法调和时做出妥协。
这与Go对API的理念非常契合。我们通常会在设计过程中花费大量时间,确保API设计正确,努力将其简化到最小、最有用的本质。
Go作为一个有用编程环境的另一个方面是它为最常见的编程错误定义了明确的语义,这有助于理解和调试。这个理念并不新颖。再次引用托尼·霍尔的话,这次是他在1972年的“软件质量”清单中说:
“一款软件程序不仅要非常简洁易用,还必须非常难以被误用;它必须对编程错误表现出宽容,清楚地指示错误的发生,并且其影响绝不应变得不可预测。”
对于有缺陷程序定义明确语义的常识并不像预期的那样普遍。在C/C++中,未定义行为已经演变成编译器作者的一种“完全自由裁量权”,能够将稍有缺陷的程序变成行为截然不同的问题程序,方式越来越有趣。Go不采用这种方式:Go不存在“未定义行为”。特别是,像空指针解引用、整数溢出和非故意的无限循环这些错误在Go中都有明确定义的语义。
Go’s Memory Model Today
Go的内存模型以以下建议开始,这与Go的整体理念一致:
修改被多个goroutine同时访问的数据的程序,必须对这种访问进行序列化。
为了序列化访问,可以使用通道操作或其他同步原语来保护数据,比如sync和sync/atomic包中的同步原语。
如果你必须阅读本文档的其余部分才能理解程序的行为,那说明你的程序设计过于巧妙。
不要过于聪明。
这依然是很好的建议。这条建议也与其他语言鼓励使用DRF-SC(数据竞态自由-顺序一致性)的理念一致:通过同步来消除数据竞争,从而使程序表现得像是顺序一致的,无需理解内存模型的其余部分。
在上述建议之后,Go内存模型定义了一个基于传统“先行发生”关系的竞态读写定义。类似于Java和JavaScript,Go中的一次读取可以观察到发生在先但尚未被覆盖的写操作,或者任何竞态写操作;安排只有一个此类写操作会强制产生特定结果。
随后,内存模型定义了建立跨goroutine先行发生边界的同步操作。这些操作是常见的操作,并带有一些Go特有的特征:
- 如果包p导入了包q,则q的init函数执行完成先于p中任何函数的开始。
- main.main函数的开始发生在所有init函数完成之后。
- 启动新goroutine的go语句发生在该goroutine执行之前。
- 通道上的发送操作先于对应的接收完成。
- 关闭通道先于接收返回零值(因通道关闭)。
- 从无缓冲通道接收先于该通道上的发送完成。
- 容量为C的通道上的第k次接收先于第k+C次发送完成。
- 对于任何sync.Mutex或sync.RWMutex变量l,且n < m,l.Unlock()的第n次调用发生在第m次l.Lock()的调用之前。
- once.Do(f)中单次调用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
“不要过于聪明”的建议很重要,也应该保留,但我们在深入讨论先行发生细节之前,还需要更多地说明Go的整体方法。我见过多种对Go方法的错误总结,比如声称Go的模型是C/C++的“DRF-SC或Catch Fire(即要么数据无竞争顺序一致性,要么程序爆炸)”。这样的误读是可以理解的:文档没有明确说明该方法是什么,而且内容非常简短(且材料十分微妙),人们往往看到的是他们期望看到的内容,而非真实存在或不存在的内容。
需要补充的文本大致会是如下内容:
概述
Go对其内存模型的处理方式与语言的整体设计理念类似,旨在保持语义的简单、易懂且实用。
数据竞争(data race)被定义为对同一内存位置的写操作与另一个同时进行的读或写操作,除非所有相关访问都是sync/atomic包提供的原子数据访问。如前所述,强烈鼓励程序员使用适当的同步机制以避免数据竞争。在无数据竞争的情况下,Go程序表现得好像所有goroutine都被多路复用到了单个处理器上。这一特性有时称为DRF-SC:无数据竞争的程序以顺序一致的方式执行。
其他编程语言通常对包含数据竞争的程序采取两种处理方式之一。第一种方式,以C和C++为代表,认为包含数据竞争的程序是无效的:编译器可能以不可预期的方式破坏这些程序。第二种方式,以Java和JavaScript为代表,认为包含数据竞争的程序有定义的语义,限制了数据竞争的可能影响,使程序更可靠且更易调试。Go的方法介于这两者之间。带有数据竞争的程序在某种意义上是无效的,因为实现可以检测到数据竞争并终止程序。但除此之外,带有数据竞争的程序具有定义的语义,且有有限的可能结果,使错误程序更可靠且更易调试。
这段文字应当明确说明Go语言与其他语言的异同,纠正读者之前可能存在的误解。
在“先行发生(Happens Before)”部分的结尾,我们还应澄清某些数据竞争仍然可能导致数据损坏。目前该部分结尾为:
比单个机器字更大的值的读写操作表现为多次以机器字大小为单位的操作,且顺序未指定。
我们应补充说明:
请注意,这意味着对多字数据结构的竞争可能导致不一致的值,这些值并不对应于单次写入。当值依赖于内部一致性的(指针、长度)或(指针、类型)对时,如大多数Go实现中的接口值、映射、切片和字符串,这类竞争反过来可能导致任意内存损坏。
这样可以更清楚地说明存在数据竞争程序的保证限制。
Document happens-before for sync libraries
自内存模型编写以来,sync包中新增了若干API。我们需要将它们添加到内存模型中(见 issue #7948)。幸运的是,新增内容似乎比较直接。我认为它们包括以下内容:
- 对于 sync.Cond:Broadcast 或 Signal 操作发生在解除阻塞的任何 Wait 调用返回之前。
- 对于 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 调用返回之前。
这些API的使用者需要了解这些保证,以便有效地使用它们。因此,虽然我们应在内存模型文本中保留这些说明以作示例,但也应将其加入 sync 包的文档注释中。这同样能为第三方同步原语树立一个良好的示范,即文档化API中定义的排序保证的重要性。
Document happens-before for sync/atomic
内存模型中缺少原子操作。我们需要将它们添加进去(见 issue #5045)。我认为应说明:
sync/atomic 包中的API统称为“原子操作”,可用于同步不同goroutine的执行。如果原子操作B观察到原子操作A的效果,则A发生在B之前。程序中执行的所有原子操作表现得好像按照某种顺序(顺序一致顺序)执行。
这是Dmitri Vyukov在2013年提出的建议,也是我在2016年非正式承诺的内容。它的语义与Java的volatile和C++的默认原子操作相同。
关于C/C++中的原子同步选项,只有两种:顺序一致性(sequentially consistent)或获取/释放(acquire/release)。(放宽的原子操作不会产生先行发生边缘,因此没有同步效果。)在两者之间的选择主要取决于两个方面:第一,能够推理多个位置上原子操作的相对顺序有多重要;第二,顺序一致性原子操作相比于获取/释放原子操作,代价高多少。
对于第一个方面,推理多个位置上的原子操作相对顺序非常重要。在之前的一篇文章中,我给出了一个使用两个原子变量实现的无锁条件变量快速路径的示例,这个实现被获取/释放原子操作破坏了。类似的模式反复出现。例如,sync.WaitGroup的早期实现使用了一对原子uint32值,wg.counter和wg.waiters。Go运行时中信号量的实现也依赖于两个独立的原子字,分别是信号量值*addr和对应的等待者计数root.nwait。还有更多类似的情况。若缺乏顺序一致语义(即改用获取/释放语义时),人们仍会写出类似的代码;只是这些代码会神秘地失败,而且只会在特定上下文中出现问题。
根本问题在于,使用获取/释放(acquire/release)原子操作来实现程序无数据竞争(data-race-free)并不会使程序表现出顺序一致性行为,因为原子操作本身并不具备这种特性。也就是说,这类程序不具备DRF-SC(无数据竞争即顺序一致性)的保证。这使得此类程序非常难以推理,因此也难以正确编写。
关于第二点,正如早期文章中提到的,硬件设计者开始直接支持顺序一致性原子操作。例如,ARMv8增加了ldar和stlr指令用于实现顺序一致性的原子操作,它们也是实现获取/释放原子操作的推荐方案。如果我们对sync/atomic采用获取/释放语义,那么ARMv8上的程序本来就会获得顺序一致性。毫无疑问,这会导致依赖更强排序保证的程序在更弱的平台上意外出错。即使在单一架构上,如果获取/释放和顺序一致性原子操作之间的差异因为数据竞争窗口较小时难以观察到,这种情况也可能发生。
这两点都强烈表明我们应当采用顺序一致性原子操作,而不是获取/释放原子操作:顺序一致性原子更有用,而且一些芯片已经完全消除了这两种级别之间的差距。如果差距显著,估计其他芯片也会跟进。
同样的考量,加上Go整体追求简洁且易理解的API的设计哲学,也反对将获取/释放作为额外的、并行的一组API提供。看起来最好只提供最易理解、最实用、最不易被误用的一组原子操作。
另一种可能是提供原始屏障(barriers)而非原子操作。(C++当然两者皆有。)屏障的缺点是使得期望不那么明确,且相对更依赖架构。Hans Boehm 的文章“Why atomics have integrated ordering constraints”提出了提供原子操作而非屏障的理由(他使用fences一词指屏障)。一般来说,原子操作比屏障更容易理解,而且既然我们今天已经提供了原子操作,就不容易取消它们。宁可只用一种机制,而不是两种。
Maybe: Add a typed API to sync/atomic
上述定义说明,当某块内存必须被多个 goroutine 并发访问且没有其他同步手段时,消除数据竞争的唯一方法是让所有访问操作都使用原子操作。仅让部分访问使用原子操作是不够的。例如,一个非原子写操作与原子读写操作并发仍然构成竞争,原子写操作与非原子读写并发也同样构成竞争。
因此,是否应对特定值使用原子操作是该值的属性,而不是某个具体访问的属性。基于这一点,大多数语言会将这类信息纳入类型系统中,比如 Java 的 volatile int 和 C++ 的 atomic。而 Go 目前的 API 并没有这样做,这意味着正确使用需要仔细注释结构体字段或全局变量,指明哪些字段或变量预计只应通过原子 API 访问。
为了提升程序的正确性,我开始认为 Go 应该定义一套有类型的原子值,类似于当前的 atomic.Value:包括 Bool、Int、Uint、Int32、Uint32、Int64、Uint64 以及 Uintptr。像 Value 一样,这些类型应该具备 CompareAndSwap、Load、Store 和 Swap 方法。例如:
type Int32 struct
func (delta int32) int32
func (old, new int32) (swapped bool)
func () int32
func (v int32)
func (new int32) (old int32)
我将 Bool 包含在列表中,是因为我们在 Go 标准库中(未导出的 API)多次用原子整数构造了原子布尔值,显然有这种需求。
我们还可以利用即将支持的泛型,定义一个针对原子指针的 API,使其具备类型安全且不依赖于 unsafe 包的特性。
type Pointer[T any] struct
func (old, new *T) (swapped bool)
(诸如此类。)针对一个明显的建议,我认为目前还没有简洁的方法利用泛型来提供单一的 atomic.Atomic[T]
,从而避免引入 Bool、Int 等作为单独类型,至少在没有编译器中特殊处理的情况下是如此。这也没关系。
Maybe: Add unsynchronized atomics
所有其他现代编程语言都提供了一种方式,使得并发的内存读写操作不会同步程序,但也不会使程序失效(即不算作数据竞争)。C、C++、Rust 和 Swift 都支持放宽的原子操作。Java 有 VarHandle 的“plain”模式。JavaScript 对 SharedArrayBuffer(唯一的共享内存)进行非原子访问。Go 没有这种做法。也许应该有,我也不确定。
如果我们想添加不同步的原子读写,可以给有类型的原子类型增加 UnsyncAdd、UnsyncCompareAndSwap、UnsyncLoad、UnsyncStore 和 UnsyncSwap 方法。给它们命名为“unsync”可以避免一些与“relaxed”命名相关的问题。首先,有些人把 relaxed 用作相对比较,比如“acquire/release”是比顺序一致性更宽松的内存序。你可能会说这不是该术语的正确用法,但确实存在。其次,更重要的是,这些操作的关键细节不是其自身的内存顺序,而是它们对程序其余部分同步没有任何影响。对不熟悉内存模型的人来说,看到 UnsyncLoad 应该明确它没有同步作用,而 RelaxedLoad 可能不会这么明显。此外,Unsync 看起来像 Unsafe,这也挺好的。
API 讨论暂且放一边,真正的问题是是否应该添加这些不同步的原子操作。支持添加的常见理由是,对于某些数据结构中的快速路径性能确实非常重要。我的总体印象是,这种性能提升主要体现在非 x86 架构上,虽然我没有数据支持这一点。不提供不同步的原子操作可能会对这些架构不利。
反对提供不同步原子的可能论点是,在 x86 平台上,如果忽略编译器重排序的潜在影响,不同步原子操作与 acquire/release 原子操作无法区分。因此,这可能被滥用,导致代码只能在 x86 上正常工作。反驳是,这种狡诈手法不会通过竞态检测器的检测,因为竞态检测器实施的是实际的内存模型,而不是 x86 的内存模型。
鉴于目前缺乏证据,我们没有理由去添加这个 API。如果有人强烈认为应该添加,合理的做法是先收集两方面的证据:(1)程序员需要编写的代码中此功能的普遍适用性;(2)在广泛使用的系统中利用不同步原子操作带来的显著性能提升。(用其他语言中的程序来展示也可以。)
Document disallowed compiler optimizations
当前的内存模型以给出无效程序的示例作为结尾。既然内存模型充当了程序员与编译器开发者之间的契约,我们应该增加无效的编译器优化示例。例如,我们可以添加:
错误的编译优化
Go 的内存模型对编译器优化的限制和对 Go 程序的限制同样严格。有些在单线程程序中有效的编译器优化,在 Go 程序中却无效。特别是,编译器不得在无数据竞争的程序中引入数据竞争,不能允许单次读取观察到多个值,也不能允许单次写入写入多个值。
不在无数据竞争程序中引入数据竞争,意味着不能将读取或写入操作移出它们所在的条件语句。例如,编译器不得将以下程序中的条件反转:
i := 0 if cond
也就是说,编译器不得将程序重写成如下形式:
i := *p if !cond
如果条件 cond 为假且另一个 goroutine 正在写入 *p,那么原程序是无数据竞争的,但重写后的程序却包含数据竞争。
不引入数据竞争还意味着不能假设循环会终止。例如,编译器不得将对 *p 或 *q 的访问移到以下程序的循环之前:
n := 0 for e := list; e != nil; e = e.next i := *p *q = 1
如果 list 指向一个循环链表,那么原程序永远不会访问 *p 或 *q,但重写后的程序却会。
不引入数据竞争还意味着不能假设被调用的函数总是返回或不包含同步操作。例如,编译器不得将对 *p 或 *q 的访问移到以下程序中的函数调用之前(至少在不了解函数 f 的具体行为的情况下):
f() i := *p *q = 1
如果函数调用从未返回,那么原程序依然永远不会访问 *p 或 *q,但重写后的程序会。如果函数调用包含同步操作,那么原程序可以建立在访问 *p 和 *q 之前发生的“先行”关系,而重写后的程序则不能。
不允许单次读取观察到多个值意味着不能从共享内存中重新加载局部变量。例如,编译器不得将变量 i 溢出到内存中,并在这个程序中从 *p 第二次重新加载它:
i := *p if i < 0 || i >= len(funcs) ... complex code ... // compiler must NOT reload i = *p here funcs[i]()
如果复杂代码需要许多寄存器,针对单线程程序的编译器可能会在调用
funcs[i]()
前丢弃变量 i,不做复制,然后重新加载 i = *p。而 Go 编译器不能这样做,因为 *p 的值可能已经改变了。(相反,编译器可以将 i 溢出到栈上。)不允许单次写操作写入多个不同的值也意味着不能使用局部变量写入之前的内存作为临时存储。例如,编译器不得在这个程序中将 *p 用作临时存储:
*p = i + *p/2
也就是说,编译器不得将程序重写成如下形式:
*p /= 2 *p += i
如果 i 和 *p 初始都等于 2,原代码执行 *p = 3,因此竞争线程只能从 *p 读到 2 或 3。重写后的代码先执行 *p = 1,再执行 *p = 3,使得竞争线程也能读到 1。
需要注意的是,所有这些优化在 C/C++ 编译器中是允许的:使用与 C/C++ 编译器共用后端的 Go 编译器必须注意禁用对 Go 无效的优化。
这些类别和示例涵盖了与数据竞争访问定义语义不兼容的最常见的 C/C++ 编译器优化。它们明确表明 Go 和 C/C++ 有不同的要求。
Conclusion
Go在其内存模型上采取保守的总体方法,这对我们非常有利,且应继续保持。然而,有一些迟来的变更是必要的,包括定义sync和sync/atomic包中新API的同步行为。特别是原子操作应被明确记录,以提供顺序一致的行为,创建发生前边缘,确保同步非原子代码。这将与所有其他现代系统语言默认提供的原子操作一致。
此次更新最独特的部分或许是明确指出程序中存在数据竞争时,程序可以被停止以报告竞争情况,但这些程序在其他方面仍具有明确定义的语义。这一限制既约束了程序员,也约束了编译器,并且优先考虑了并发程序的可调试性和正确性,而非编译器编写者的便利。
Acknowledgements
这系列文章得益于我在谷歌有幸共事的一长串工程师们的讨论和反馈。感谢他们。我对任何错误或不受欢迎的观点承担全部责任。