研究!rsc公司

关于编程的想法和链接,通过

RSS(RSS)

更新Go-Memory模型
(内存模型,第3部分)
发布于2021年7月12日,星期一。PDF格式

当前的Go语言记忆模型是在2009年编写的,之后进行了小更新。很明显,我们至少应该添加一些细节当前的内存模型,其中一个是显式的竞赛检测器的认可以及API如何同步/原子同步程序。

这篇文章重申了围棋的整体哲学和当前的记忆模型然后概述了我认为我们应该做出的相对较小的调整Go内存模型。它假定呈现的背景在之前的帖子中硬件内存型号“和编程语言记忆模型.”

我已经打开了GitHub讨论收集反馈关于这里提出的想法。根据反馈,我打算在本月晚些时候准备一份正式的围棋提案。GitHub讨论的使用本身就是一个实验,继续尝试找到对重要变化展开讨论的合理方式.

Go的设计理念

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

Go鼓励在高级别上实现并发,尤其是通过沟通。第一个围棋谚语是“不要通过共享内存进行交流。通过通信共享内存。”另一句流行的谚语是“清晰胜于聪明”换句话说,Go鼓励通过避免细微代码来避免细微的错误。

围棋的目标不仅是为了可以理解的项目,还为了可理解的语言和可理解的包API。复杂或微妙的语言特性或API与这一目标相矛盾。正如Tony Hoare在他的1980年图灵奖讲座:

我的结论是,构建软件设计有两种方法:一种方法是使其简单到明显地缺陷和另一种方式是使其变得如此复杂没有明显的缺陷。

第一种方法要困难得多。它需要同样的技能、奉献、洞察力甚至灵感随着简单物理定律的发现复杂的自然现象。它还需要愿意接受受以下因素限制的目标物理、逻辑和技术约束,当无法实现相互冲突的目标时,接受妥协。

这与Go的API哲学非常吻合。我们通常在设计过程中花费很长时间来确保API是正确的,努力将其简化为最简单、最有用的本质。

Go作为一个有用的编程环境的另一个方面是具有定义良好的最常见编程错误的语义,这有助于理解和调试。这个想法并不新鲜。再次引用托尼·霍尔的话,这一次是他1972年的话“软件质量”检查表:

除了使用非常简单外,软件程序还必须难以误用;它必须对编程错误友好,给出明确的指示其发生,并且其影响永远不会变得不可预测。

拥有的常识错误程序的定义良好的语义并不常见正如人们所料。在C/C++中,未定义的行为已经演变为编译作者的全权委托把一些有缺陷的程序变成非常不同的bug程序,以更加有趣的方式。Go没有采用这种方法:没有“未定义的行为”特别是,诸如空指针解引用、整数溢出、,和无意的无限循环都在Go中定义了语义。

Go今天的内存模型

Go的内存模型首先,根据围棋的整体理念,提出以下建议:

修改由多个goroutine同时访问的数据的程序必须序列化此类访问。

要序列化访问,请使用通道操作或其他同步原语(如同步同步/原子包装。

如果您必须阅读本文档的其余部分才能理解程序的行为,那么您太聪明了。

不要太聪明。

这仍然是很好的建议。该建议也与其他语言一致鼓励DRF-SC:同步消除数据竞争,然后程序将表现为顺序一致,不需要理解内存模型的其余部分。

根据这个建议,Go内存模型定义传统的基于happens-before的比赛读写的定义。与Java和JavaScript一样,Go中的read可以观察到较早但尚未覆盖的happens-before顺序写入,或任何赛车文字;安排只有一次这样的写作迫使产生特定的结果。

然后,内存模型继续定义同步操作在边缘之前建立交叉goroutine事件。这些操作都是常规操作,带有一些Go特有的调味料:

此列表明显省略了以下内容同步/原子以及包中更新的API同步.

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

Go内存模型的更改

2009年,当我们着手编写Go的内存模型时,Java内存模型进行了新的修订,并且C/C++11内存模型正在最终确定中。一些人强烈鼓励我们采用C/C++11模型,充分利用了其中的所有工作。这对我们来说似乎有风险。相反,我们决定保守地对待我们的保证,这一决定得到了随后十年详细阐述非常微妙的论文的证实Java/C/C++内存模型中的问题。定义足够的内存模型来指导程序员和编译器编写人员很重要,但要以完整的形式正确地定义一个-看起来静止不动这超出了最有才华的研究人员的能力范围。Go继续说最起码的有用信息就足够了。

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

记录Go的总体方法

“不要太聪明”的建议很重要,应该坚持下去,但我们还需要更多地介绍围棋的整体方法在深入了解之前发生的事情的细节之前。我看到了Go方法的多个错误总结,例如,声称Go的模型是C/C++的“DRF-SC或Catch Fire”误读是可以理解的:文件没有说明方法是什么,它很短(而且材料很精细),人们可以看到他们希望看到,而不是看到那里有什么或没有什么。

要添加的文本大致如下:

概述

Go的记忆模型与语言的其他部分非常相似,旨在保持语义简单、易懂和有用。

A类数据竞赛定义为对内存位置的写入与另一个读取或写入同时发生去同一个地方,除非涉及的所有访问都是原子数据访问同步/原子包裹。如前所述,强烈建议程序员使用适当的同步以避免数据竞争。在没有数据竞赛的情况下,围棋程序的行为就像所有的goroutine一样被多路复用到单个处理器上。此属性有时被称为DRF-SC:无数据通道程序以顺序一致的方式执行。

其他编程语言通常采用两种方法之一处理包含数据竞争的程序。首先,以C和C++为例,具有数据竞争的程序是无效的:编译器可能会以任意令人惊讶的方式破坏它们。第二个例子是Java和JavaScript,它是带有数据竞争的程序定义了语义,限制了比赛的可能影响,并制作了程序更可靠,更容易调试。围棋的方法介于两者之间。从某种意义上说,具有数据竞赛的程序是无效的可能会报告比赛并终止程序。但除此之外,具有数据竞争的程序已经定义了语义结果有限,使错误的程序更可靠,更容易调试。

这篇文章应该清楚Go和其他语言的区别,纠正读者先前的任何期望。

在“之前发生”部分的末尾,我们还应该澄清某些种族仍可能导致腐败。目前以以下内容结束:

读取和写入大于单个机器字的值的行为以未指定的顺序执行多个机器或机器大小的操作。

我们应该补充:

注意,这意味着多字数据结构上的竞争会导致不一致的值对应于单个写入。当值取决于内部(指针、长度)或(指针、类型)对的一致性,与大多数情况下的接口值、映射、切片和字符串一样Go实施,这种竞争反过来会导致任意内存损坏。

这将更清楚地说明项目担保的局限性数据竞赛。

同步库之前的文档事件

新的API已添加到同步包裹自从编写了内存模型以来。我们需要将它们添加到内存模型中(第7948期).谢天谢地,这些添加看起来很简单。我相信它们如下。

这些API的用户需要了解保证,以便有效地使用它们。因此,虽然为了便于说明,我们应该将文本保存在记忆模型中,我们还应该将其包含在包中的文档注释中同步.这也将有助于为第三方同步原语树立一个榜样记录API建立的订购保证的重要性。

文档happens-before for sync/atomic

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

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

这是Dmitri Vyukov在2013年提出的建议我在2016年的非正式承诺.它还具有与Java相同的语义不稳定的s和C++的默认原子。

就C/C++菜单而言,同步原子只有两种选择:顺序一致或获取/发布。(松弛原子不会在边之前创建偶发,因此没有同步效果。)这两者之间的决定首先取决于它的重要性能够推理在多个位置进行原子操作,其次,顺序一致性的成本要高得多原子与获取/释放原子进行了比较。

关于第一个考虑,关于原子操作相对顺序的推理多个位置非常重要。在之前的帖子中,我给出了一个具有无锁快速路径的条件变量示例使用两个原子变量实现,通过使用获取/释放原子来打破。这种模式一再出现。例如,以前的同步。等待组习惯于一对原子单位32,wg.计数器wg.服务员.这个信号量的Go运行时实现也取决于两个独立的原子词,即信号量值*地址和相应的服务员数root.nwait(根等待).还有更多。在缺乏顺序一致语义的情况下(也就是说,如果我们采用获取/释放语义),人们仍然会像这样编写代码;它会神秘地失败,并且只在特定的环境中。

根本问题是使用获取/释放原子使程序无数据追踪不会产生程序行为顺序一致,因为原子本身没有。也就是说,此类计划不提供DRF-SC。这使得这样的程序很难推理因此很难正确书写。

关于第二个考虑,如前一篇帖子所述,硬件设计师开始为顺序一致的原子提供直接支持.例如,ARMv8将激光雷达stlr公司实施说明顺序一致的原子,它们也是获取/发布原子的推荐实现.如果我们对同步/原子,在ARMv8上编写的程序无论如何都将获得连续一致性。这无疑会导致项目意外地依赖更强的订单,在较弱的平台上突破。这甚至可能发生在单个体系结构上,如果获取/发布和顺序一致原子由于比赛窗口较小,在实践中很难观察到。

这两种考虑强烈建议我们采用顺序一致的原子优于获取/释放原子:顺序一致的原子更有用,一些芯片已经完全缩小了差距在两个级别之间。如果差距很大,可能其他人也会这样做。

同样的考虑,以及Go的整体哲学拥有最少且易于理解的API,反对作为附加的并行API集提供获取/发布。似乎最好只提供最容易理解、最有用、最不易误用的内容原子操作集。

另一种可能性是提供原始屏障,而不是原子操作。(当然,C++提供了这两者。)障碍的缺点是期望不太明确更具体的体系结构。汉斯·博姆的页面“为什么原子学集成了排序约束提出了提供原子而非屏障的论点(他使用术语围栏)。一般来说,原子比栅栏更容易理解,因为我们现在已经提供了原子操作,所以我们不能轻易删除它们。最好有一个机制,而不是两个机制。

可能:添加类型化API以同步/原子

上述定义表明,当一段特定的内存必须由多个goroutine并发访问,而无需其他同步,消除竞争的唯一方法是使所有访问都使用原子。仅使某些访问使用原子是不够的。例如,与原子读取或写入并发的非原子写入仍然是一个竞争,原子写入与非原子读取或写入并发也是如此。

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

为了提高程序的正确性,我开始认为Go应该定义一组类型化的原子值,类似于当前原子。价值:布尔,国际,Uint公司,国际32,单位32,国际64,单位64、和Uint指针.喜欢价值,这些会有比较和交换,负载,商场、和互换方法。例如:

类型Int32结构{v Int32}func(i*Int32)加法(增量Int32)Int32{return AddInt32(&i.v,delta)}func(i*Int32)CompareAndSwap(旧Int32,新Int32)(交换布尔值){return CompareAndSwapInt32(&i.v,旧的,新的)}func(i*Int32)加载()Int32{return LoadInt32(&i.v)}func(i*Int32)存储(v Int32){return StoreInt32(&i.v,v)}func(i*Int32)交换(新Int32)(旧Int32){return SwapInt32(&i.v,新)}

我已经包括在内了布尔因为我们已经构建了原子Go标准库中原子整数多次出现布尔值(在未报告的API中)。显然有必要。

我们还可以利用即将到来的泛型支持并定义一个类型化且无包的原子指针API不安全的在其API中:

类型指针[T any]结构{v*T}func(p*Pointer[T])CompareAndSwap(旧的,新的*T)(交换bool){return CompareAndSwapPointer(…很多不安全的…)}

(依此类推。)为了回答一个显而易见的建议,我认为没有一种干净的方法来使用泛型只提供一个原子。原子[T]这样我们就可以避免引入布尔,国际等作为单独的类型,至少在编译器中没有特殊情况。这没关系。

可能:添加非同步原子

所有其他现代编程语言都提供了一种不同步程序的并发内存读写但也不要使其无效(不要算作数据竞赛)。C、 C++、Rust和Swift具有宽松的原子结构。Java有VarHandle变量句柄的“普通”模式.JavaScript对SharedArrayBuffer(共享阵列缓冲区)(唯一的共享内存)。Go没有办法做到这一点。也许应该这样。我不知道。

如果我们想添加非同步的原子读写,我们可以加上取消同步添加,取消同步比较和交换,取消同步加载,UnsyncStore(取消同步存储)、和UnsyncSwap(取消同步交换)方法到类型化原子学。将它们命名为“unsync”可以避免“relaxed”这个名称带来的一些问题首先,一些人用放松作为相对比较,如“获取/释放是一种比顺序一致性更宽松的内存顺序。”你可以说这不是这个词的正确用法,但它确实发生了。第二,也是更重要的,关于这些操作的关键细节不是操作本身的内存顺序,而是事实上他们已经无影响同步程序的其余部分。对于记忆模型方面的非专家来说取消同步加载应该明确没有同步,然而松弛载荷可能不会。这也很好取消同步看起来像不安全的.

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

反对提供非同步原子的一个可能的理由是在x86上,忽略潜在编译器重新排序的影响,非同步原子与获取/发布原子没有区别。因此,它们可能会被滥用来编写只在x86上工作的代码。反驳的论点是,这样的诡计不会通过审查使用race检测器,它实现实际的内存模型而不是x86内存模型。

由于我们今天缺乏证据,我们没有理由添加此API。如果有人强烈认为我们应该添加它,立案的方法是收集两方面的证据(1) 程序员需要编写的代码的通用性,以及(2) 广泛使用的系统的显著性能改进由使用非同步原子引起。(使用Go以外的语言的程序来显示这一点很好。)

文档不允许的编译器优化

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

编译不正确

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

不将数据竞赛引入无竞赛项目意味着不移动读取或写出它们出现在其中的条件语句。例如,编译器不得反转此程序中的条件:

i:=0如果条件{i=*p}

也就是说,编译器不得将程序重写为以下内容:

i:=*p如果!康德{i=0}

如果康德是假的,另一个goroutine正在写*第页,然后是原始程序是无赛跑的,但重写的程序包含赛跑。

不引入数据竞争也意味着不假设循环终止。例如,编译器不得将访问移动到*第页*q个在本程序的循环之前:

n:=0对于e:=列表;e!=无;e=下一个{n个++}i:=*p*q=1

如果列表指向循环列表,那么原始程序将永远无法访问*第页*q个,但重写后的程序会。

不引入数据竞争也意味着不假设调用的函数始终返回或不进行同步操作。例如,编译器不得将访问移动到*第页*q个在这个程序的函数调用之前(至少在不直接了解(f)):

f()i:=*p*q=1

如果调用没有返回,那么原始程序将再次无法访问*第页*q个,但重写后的程序会。如果调用包含同步操作,则原始程序可以在访问之前建立边缘*第页*q个,但重写后的程序不会。

不允许一次读取观察多个值意味着不重新加载局部变量来自共享内存。例如,编译器不能溢出然后从*第页在此程序中:

i:=*p如果i<0|i>=len(函数){恐慌(“无效的功能索引”)}…复杂代码。。。//编译器不得在此处重新加载i=*p函数[i]()

如果复杂代码需要多个寄存器,那么单线程程序的编译器可以丢弃不保存副本,然后重新加载 = *第页就在之前函数[i]().Go编译器不能,因为的值*第页可能已经改变了。(相反,编译器可能会溢出到堆栈中。)

不允许一次写入多个值也意味着不使用将局部变量写入临时存储器的内存在写之前。例如,编译器不得使用*第页作为此程序中的临时存储:

*p=i+*p/2

也就是说,它不能将程序重写为以下程序:

*p/=2*p+=i

如果*第页起始值等于2,原始代码为*第页 = ,所以赛车线程只能从中读取2或3*第页.重写后的代码可以*第页 = 1然后*第页 = ,允许赛车线程也读取1。

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

这些类别和示例涵盖了最常见的C/C++编译器优化这与赛跑数据访问的定义语义不兼容。他们清楚地确定Go和C/C++有不同的需求。

结论

Go在其记忆模型中保持保守的一般方法有对我们很有帮助,应该继续下去。然而,有些更改已经过期,包括定义的同步行为中的新API同步同步/原子包装。尤其应记录原子,以提供创造事件的顺序一致的行为边缘同步周围的非原子代码。这将匹配所有其他现代系统语言提供的默认原子。

也许更新中最独特的部分是清楚地说明具有数据竞赛的程序可能会被阻止报道比赛,但在其他方面定义良好的语义。这限制了程序员和编译器,它优先考虑并发程序的可调试性和正确性编译器编写者过于方便。

致谢

这一系列帖子从与以及我很幸运能在谷歌共事的一长串工程师的反馈。我感谢他们。我对任何错误或不受欢迎的意见承担全部责任。