Go 官方博客
迈向 Go 2
引言
[这是我今天在 Gophercon 2017 上的演讲文本,目的是请整个 Go 社区在我们讨论和规划 Go 2 时提供帮助。]
2007 年 9 月 25 日,在 Rob Pike、Robert Griesemer 和 Ken Thompson 讨论一种新编程语言几天后,Rob 建议使用“Go”这个名字。

第二年,Ian Lance Taylor 和我加入了团队,我们五人一起构建了两个编译器和一个标准库,最终于 2009 年 11 月 10 日发布了开源版本。

在接下来的两年里,在新成立的 Go 开源社区的帮助下,我们尝试了各种大小的改动,改进了 Go,并于 2011 年 10 月 5 日提出了Go 1 计划。

在 Go 社区的更多帮助下,我们修订并实施了该计划,最终于 2012 年 3 月 28 日发布了 Go 1。

Go 1 的发布标志着近五年创造性、紧张工作的顶峰,这段努力将我们从一个名字和一系列想法带到了一个稳定、可用于生产的语言。这也标志着从变化和动荡向稳定的明确转变。
在 Go 1 发布之前的几年里,我们几乎每周都会修改 Go 并导致所有人的 Go 程序无法运行。我们明白,这使得 Go 无法在生产环境中使用,因为在生产环境中,程序不可能每周都重写以跟上语言的变化。正如宣告 Go 1 的博客文章所述,主要的动机是提供一个稳定的基础,用于创建可靠的产品、项目和出版物(博客、教程、会议演讲和书籍),让用户相信他们的程序在未来几年内可以无需更改即可继续编译和运行。
Go 1 发布后,我们知道需要花时间在 Go 设计用于的生产环境中使用它。我们明确地从改变语言转向在我们自己的项目中使用 Go 并改进实现:我们将 Go 移植到许多新系统,重写了几乎所有性能关键部分以使 Go 运行得更高效,并添加了诸如竞态检测器 (race detector)之类的关键工具。
现在我们有五年使用 Go 构建大型、生产质量系统的经验。我们已经形成了对哪些方法有效、哪些方法无效的认识。现在是时候开始 Go 演进和增长的下一步了,规划 Go 的未来。今天我在这里,请所有 Go 社区的成员,无论您是 GopherCon 的现场听众,还是通过视频观看,或者稍后阅读 Go 博客,与我们一起规划和实施 Go 2。
在本次演讲的其余部分,我将解释我们对 Go 2 的目标;我们的约束和限制;总体流程;撰写关于使用 Go 经验的重要性,特别是与我们可能尝试解决的问题相关的内容;可能的解决方案类型;我们将如何交付 Go 2;以及大家如何提供帮助。
目标
我们今天对 Go 的目标与 2007 年相同。我们希望提高程序员管理两种规模的能力:生产规模,特别是与许多其他服务器交互的并发系统,这在今天的云软件中得到体现;以及开发规模,特别是许多工程师松散协调工作的大型代码库,这在今天的现代开源开发中得到体现。
这些规模体现在各种规模的公司中。即使是一家五人创业公司,也可能使用其他公司提供的大型云 API 服务,并使用比自己编写的软件更多的开源软件。生产规模和开发规模在该创业公司与在 Google 同等重要。
我们对 Go 2 的目标是修复 Go 在规模化方面最显著的不足。
(关于这些目标的更多信息,请参阅 Rob Pike 2012 年的文章“Google 的 Go:软件工程中的语言设计”以及我在 GopherCon 2015 上的演讲“Go,开源,社区”。)
约束
自 Go 起初以来,目标并未改变,但 Go 面临的约束确实改变了。最重要的约束是现有的 Go 使用情况。我们估计全球至少有五十万 Go 开发者,这意味着存在数百万 Go 源文件和至少十亿行 Go 代码。这些程序员和源代码代表了 Go 的成功,但它们也是 Go 2 的主要约束。
Go 2 必须带上所有这些开发者。我们必须仅在回报巨大时,才要求他们忘掉旧习惯,学习新习惯。例如,在 Go 1 之前,错误类型实现的方法名为 String
。在 Go 1 中,我们将其重命名为 Error
,以区分错误类型与其他可以格式化自身类型的能力。前几天我在实现一个错误类型时,不假思索地将其方法命名为 String
而不是 Error
,结果当然无法编译。五年过去了,我仍然没有完全忘掉旧的方式。这种澄清性的重命名是 Go 1 中一项重要的更改,但在 Go 2 中如果没有非常好的理由,这将是破坏性太大的改动。
Go 2 也必须带上所有现有的 Go 1 源代码。我们不能分裂 Go 生态系统。在为期多年的过渡期内,Go 2 编写的包导入 Go 1 编写的包,反之亦然的混合程序必须能够轻松运行。我们将不得不仔细考虑如何做到这一点;像 go fix 这样的自动化工具肯定会发挥作用。
为了最大程度地减少破坏,每项更改都需要仔细思考、规划和工具支持,这反过来限制了我们可以进行的更改数量。也许我们可以做两到三项,肯定不会超过五项。
我没有计算诸如允许使用更多口语语言中的标识符或添加二进制整数字面量等次要的清理性更改。这些次要更改也很重要,但它们更容易处理。我今天关注的是可能的重大更改,例如对错误处理的额外支持,或者引入不可变或只读值,或者添加某种形式的泛型,或者尚未提出的其他重要议题。我们只能进行少数几项重大更改。我们必须谨慎选择。
过程
这引出了一个重要问题。Go 的开发过程是怎样的?
在 Go 的早期,当我们只有五个人时,我们在两间相邻的共享办公室里工作,中间隔着玻璃墙。很容易就能把大家召集到一间办公室讨论某个问题,然后各自回到座位上实现解决方案。当实现过程中出现意外情况时,也很容易再次召集大家。Rob 和 Robert 的办公室里有一张小沙发和一个白板,所以通常我们中的一个人会进去,开始在白板上写一个例子。通常当例子写完时,其他人也已在各自的工作中达到了合适的暂停点,准备坐下来讨论。这种非正式方式显然无法扩展到今天的全球 Go 社区。
自 Go 开源发布以来,部分工作是将我们的非正式流程移植到更正式的邮件列表、问题跟踪器和五十万用户的世界中,但我认为我们从未明确描述过我们的整体流程。我们可能从未有意识地思考过这一点。然而,回顾过去,我认为这是我们开发 Go 的基本框架,是我们自第一个原型运行以来一直遵循的过程。

第 1 步是使用 Go,积累使用经验。
第 2 步是识别 Go 中可能需要解决的问题,并阐明它,向他人解释,将其写下来。
第 3 步是针对该问题提出解决方案,与他人讨论,并根据讨论修改方案。
第 4 步是实施解决方案,评估它,并根据评估进行改进。
最后,第 5 步是发布解决方案,将其添加到语言、库或人们日常使用的工具集中。
同一个人不必为某项更改执行所有这些步骤。事实上,通常许多人会协作完成任何一个步骤,并且针对同一个问题可能会提出多种解决方案。此外,在任何时候,我们都可能意识到不想进一步推进某个想法,并返回到之前的某个步骤。
虽然我认为我们从未整体讨论过这个过程,但我们已经解释过其中的一些部分。2012 年,当我们发布 Go 1 并说现在是时候使用 Go 并停止更改它时,我们解释的是第 1 步。2015 年,当我们引入 Go 更改提案流程 (Go change proposal process) 时,我们解释的是第 3、4、5 步。但我们从未详细解释过第 2 步,所以现在我想这样做。
(关于 Go 1 的开发以及从语言更改转向的更多信息,请参阅 Rob Pike 和 Andrew Gerrand 在 OSCON 2012 上的演讲“Go 1 之路”。关于提案流程的更多信息,请参阅 Andrew Gerrand 在 GopherCon 2015 上的演讲“Go 是如何诞生的”以及提案流程文档。)
阐述问题

阐述问题有两个部分。第一部分——比较容易的部分——是精确地陈述问题是什么。我们开发者在这方面相当擅长。毕竟,我们编写的每个测试都是一个待解决问题的陈述,其语言精确到连计算机都能理解。第二部分——更难的部分——是充分描述问题的重要性,以便每个人都能理解为什么我们应该花时间解决它并维护一个解决方案。与精确陈述问题相反,我们不需要经常描述问题的重要性,在这方面也做得远不如前者。计算机从不问我们“为什么这个测试用例很重要?你确定这是你需要解决的问题吗?解决这个问题是你现在能做的最重要的事情吗?”也许将来有一天会问,但不是今天。
让我们看看 2011 年的一个旧例子。这是我在规划 Go 1 时关于将 os.Error 重命名为 error.Value 的记录。

它始于对问题精确的一行陈述:在非常底层的库中,所有东西都为了 os.Error 而导入 “os”。然后有五行,我在这里加了下划线,专门描述问题的重要性:使用 “os” 的包本身无法在它们的 API 中表示错误,而其他包依赖于 “os” 的原因与操作系统服务无关。
这五行能让你相信这个问题很重要吗?这取决于你能在多大程度上填补我遗漏的背景信息:理解需要预料到他人需要知道什么。对于我当时的受众——Google Go 团队的其他十位阅读该文档的同事——这五十个词就足够了。要在去年秋天向 GothamGo 的听众——一个背景和专业领域更加多样化的群体——介绍同一个问题,我需要提供更多的背景信息,我用了大约两百个词,还加上了实际代码示例和图表。今天全球 Go 社区的现实是,描述任何问题的重要性都需要添加背景信息,尤其要通过具体的例子来佐证,而这些内容在与同事交谈时可能会省略。
说服他人某个问题很重要是至关重要的一步。当一个问题看起来不重要时,几乎所有解决方案都会显得过于昂贵。但对于一个重要的问题,通常有很多成本合理的解决方案。当我们对于是否采纳某个特定解决方案存在分歧时,我们实际上往往是对所解决问题的重要性存在分歧。这一点非常重要,因此我想看看最近的两个例子,它们清楚地说明了这一点,至少事后看来是如此。
例子:闰秒
我的第一个例子与时间有关。
假设你想测量一个事件需要多长时间。你记下开始时间,运行事件,记下结束时间,然后用结束时间减去开始时间。如果事件花费了十毫秒,相减结果就是十毫秒,可能加上或减去一个小的测量误差。
start := time.Now() // 3:04:05.000
event()
end := time.Now() // 3:04:05.010
elapsed := end.Sub(start) // 10 ms
这个显而易见的步骤在闰秒期间可能会失败。当我们的时钟与地球的每日自转不太同步时,就会在午夜前插入一个闰秒——官方说法是下午 11:59 分 60 秒。与闰年不同,闰秒没有可预测的模式,这使得它们难以适配到程序和 API 中。操作系统通常不会尝试表示偶尔出现的 61 秒分钟,而是通过在原应是午夜前将时钟回拨一秒来实现闰秒,这样下午 11:59 分 59 秒就会出现两次。这种时钟重置使得时间似乎倒退,因此我们耗时十毫秒的事件可能会被计时为负 990 毫秒。
start := time.Now() // 11:59:59.995
event()
end := time.Now() // 11:59:59.005 (really 11:59:60.005)
elapsed := end.Sub(start) // –990 ms
由于“日历时钟 (time-of-day clock)”在跨越时钟重置(例如这种)计时事件时不够准确,操作系统现在提供第二种时钟,即“单调时钟 (monotonic clock)”,它没有绝对含义,但能计算秒数且永不重置。
除了在少数时钟重置期间,单调时钟并不比日历时钟更好,而日历时钟还有查看时间的额外好处,因此为了简洁,Go 1 的时间 API 只暴露了日历时钟。
2015 年 10 月,一份错误报告指出 Go 程序无法正确地在跨越时钟重置(特别是典型的闰秒)时测量事件时间。建议的修复方法也是最初的问题标题:“添加一个访问单调时钟源的新 API”。我当时认为这个问题不够重要,不足以增加新的 API。几个月前,对于 2015 年中期的闰秒,Akamai、Amazon 和 Google 整天将时钟稍微调慢了一点,通过这种方式吸收了额外的一秒,而没有回拨时钟。看来这种“闰秒涂抹 (leap smear)”方法的最终广泛采用将消除生产系统中闰秒导致的时钟重置问题。相比之下,向 Go 添加新的 API 会引入新的问题:我们将不得不解释这两种时钟,教育用户何时使用哪种,并转换许多现有的代码行,所有这些都是为了一个很少发生且可能自行消失的问题。
当出现没有明确解决方案的问题时,我们总是做同样的事情:我们等待。等待给了我们更多时间来增加对问题的经验和理解,也给了我们更多时间来找到一个好的解决方案。在这种情况下,等待增加了我们对问题重要性的理解,具体形式是在 Cloudflare 发生了一次幸运地算是轻微的中断。他们的 Go 代码在 2016 年底的闰秒期间计时 DNS 请求,结果是大约负 990 毫秒,这导致了他们的服务器同时崩溃,在高峰期中断了 0.2% 的 DNS 查询。
Cloudflare 正是 Go 设计用于的那类云系统,他们因为 Go 无法正确计时事件而发生了生产中断。然后,这是关键点,Cloudflare 在 John Graham-Cumming 撰写的博客文章“闰秒如何以及为何影响了 Cloudflare DNS”中报告了他们的经验。通过分享他们在生产中使用 Go 的具体细节,John 和 Cloudflare 帮助我们理解,跨闰秒时钟重置的精确计时问题太重要了,不能置之不理。该文章发表两个月后,我们设计并实现了一个解决方案,它将在Go 1.9 中发布(事实上,我们是在没有增加新 API的情况下做到的)。
例子:别名声明
我的第二个例子是 Go 对别名声明 (alias declarations) 的支持。
在过去的几年里,Google 成立了一个专注于大规模代码变更的团队,即在我们由数百万源文件和数十亿行 C++、Go、Java、Python 及其他语言编写的庞大代码库中进行 API 迁移和错误修复。我从该团队的工作中学到的一点是,在将 API 从一个名称更改为另一个名称时,能够分多个步骤更新客户端代码,而不是一次性完成,这一点非常重要。要做到这一点,必须能够编写一个声明,将旧名称的使用转发到新名称。C++ 有 #define、typedef 和 using 声明来实现这种转发,但 Go 没有。当然,Go 的目标之一就是很好地扩展到大型代码库,随着 Google Go 代码量的增长,我们清楚地认识到既需要某种转发机制,也意识到其他项目和公司随着其 Go 代码库的增长也会遇到这个问题。
2016 年 3 月,我开始与 Robert Griesemer 和 Rob Pike 讨论 Go 如何处理代码库的逐步更新,我们最终确定了别名声明 (alias declarations),这正是所需的转发机制 (forwarding mechanism)。此时,我感觉 Go 的演进方向非常好。自 Go 早期以来,我们就讨论过别名 (aliases)——事实上,第一份规范草案就有一个使用别名声明 (alias declarations) 的例子——但每次我们讨论别名 (aliases),以及后来的类型别名 (type aliases) 时,都没有明确的用例,所以我们就把它们去掉了。现在我们提议添加别名不是因为它们是一个优雅的概念,而是因为它们解决了 Go 实现可伸缩软件开发目标的一个重大实际问题。我希望这能成为 Go 未来更改的范例。
春天晚些时候,Robert 和 Rob 写了一份提案,Robert 在 Gophercon 2016 的闪电演讲 (lightning talk) 中进行了介绍。接下来的几个月进展并不顺利,这绝对不是未来 Go 更改的范例。我们学到的许多教训之一是阐述问题重要性的重要性。
刚才,我向你解释了这个问题,提供了一些关于它如何产生和为什么产生背景信息,但没有具体的例子来帮助你评估这个问题是否会在某个时候影响你。去年夏天的提案和闪电演讲提供了一个抽象的例子,涉及包 C、L、L1 和 C1 到 Cn,但没有开发者可以联系起来的具体例子。结果,来自社区的大多数反馈都基于这样一种想法:别名只解决了 Google 的问题,而不是其他所有人的问题。
正如我们在 Google 最初没有理解正确处理闰秒时间重置的重要性一样,我们也没有有效地向更广泛的 Go 社区传达在进行大规模更改时处理代码逐步迁移和修复的重要性。
秋天我们重新开始了。我做了一个演讲并写了一篇文章介绍问题,使用从开源代码库中提取的多个具体例子来介绍问题,展示了这个问题是如何普遍存在的,而不仅仅是在 Google 内部。现在,更多的人理解了这个问题并看到了它的重要性,我们就有了一次富有成效的讨论,关于哪种解决方案最好。结果是类型别名 (type aliases)将包含在 Go 1.9 中,并将帮助 Go 扩展到更大的代码库。
经验报告
这里的教训是,以一种不同环境中工作的人也能理解的方式描述问题的重要性是困难但至关重要的。作为社区讨论对 Go 的重大更改时,我们需要特别关注描述我们想要解决的任何问题的重要性。最清楚的方法是展示问题如何影响实际程序和实际生产系统,例如 Cloudflare 的博客文章和我的重构 (refactoring) 文章中所示。
这样的经验报告将抽象问题转化为具体问题,并帮助我们理解其重要性。它们也作为测试用例:任何提议的解决方案都可以通过检查其对报告中描述的实际、真实世界问题的影响来评估。
例如,我最近一直在研究泛型 (generics),但脑海中并没有一个清晰的图景,不清楚 Go 用户需要泛型来解决哪些详细、具体的问题。因此,我无法回答一个设计问题,例如是否支持泛型方法 (generic methods),也就是与接收者 (receiver) 分开参数化 (parameterized) 的方法。如果我们有大量现实世界的用例,就可以通过 بررسی 重要的用例来开始回答这样的问题。
另一个例子是,我看到了一些以各种方式扩展错误接口 (error interface) 的提案,但我还没有看到任何经验报告表明大型 Go 程序如何尝试理解和处理错误,更不用说展示当前错误接口如何阻碍这些尝试了。这些报告将帮助我们更好地理解问题的细节和重要性,这是我们在解决问题之前必须做的。
我可以继续说下去。对 Go 的每一次潜在的重大更改都应该由一份或多份经验报告来推动,这些报告记录了人们现在如何使用 Go 以及为什么它不够好用。对于我们可能考虑的那些明显的 Go 重大更改,我不知道有很多这样的报告,尤其是那些有实际例子说明的报告。
这些报告是 Go 2 提案流程 (Go 2 proposal process) 的原始材料,我们需要大家来撰写它们,帮助我们了解你们使用 Go 的经验。你们有五十万人,在广泛的环境中工作,而我们的人不多。写一篇博客文章,或者写一篇 Medium 文章,或者写一个 GitHub Gist(添加 .md
文件扩展名以使用 Markdown),或者写一个 Google 文档,或者使用任何你喜欢的发布机制。发布后,请将文章添加到我们的新维基页面 golang.org/wiki/ExperienceReports。
解决方案

现在我们知道如何识别和阐述需要解决的问题了,我想简单提一下,并非所有问题都最好通过语言更改来解决,这没关系。
我们可能想要解决的一个问题是,计算机在进行基本算术运算时通常可以计算额外的结果,但 Go 没有直接访问这些结果的途径。2013 年,Robert 提议我们可以将双结果(“逗号-ok”)表达式的思想扩展到基本算术运算。例如,如果 x 和 y 是 uint32 类型的值,lo, hi = x * y
将不仅返回通常的低 32 位,还会返回乘积的高 32 位。这个问题似乎不太重要,所以我们记录了潜在的解决方案,但没有实施。我们选择了等待。
最近,我们为 Go 1.9 设计了一个math/bits 包,其中包含各种位操作函数 (bit manipulation functions)
package bits // import "math/bits"
func LeadingZeros32(x uint32) int
func Len32(x uint32) int
func OnesCount32(x uint32) int
func Reverse32(x uint32) uint32
func ReverseBytes32(x uint32) uint32
func RotateLeft32(x uint32, k int) uint32
func TrailingZeros32(x uint32) int
...
该包中包含每个函数的良好 Go 实现,但编译器在硬件可用时也会替换为特殊的硬件指令。基于在 math/bits 上的经验,Robert 和我都认为通过改变语言来提供额外的算术结果是不明智的,相反,我们应该在 math/bits 这样的包中定义适当的函数。这里最好的解决方案是库更改,而不是语言更改。
在 Go 1.0 之后,我们可能想要解决的另一个问题是,goroutines 和共享内存使得在 Go 程序中引入竞态 (races) 变得太容易,导致生产环境中的崩溃和其他异常行为。基于语言的解决方案本应是找到一种方法来禁止数据竞态 (data races),使得编写或至少编译一个带有数据竞态的程序变得不可能。如何将这一点纳入像 Go 这样的语言,在编程语言领域仍然是一个悬而未决的问题。相反,我们在主要的 Go 分发包中添加了一个工具,并使其易于使用:那个工具,即竞态检测器 (race detector),已成为 Go 体验中不可或缺的一部分。这里最好的解决方案是运行时和工具链的更改,而不是语言更改。
当然,也会有语言更改,但并非所有问题都最好在语言层面解决。
发布 Go 2

最后,我们将如何发布和交付 Go 2?
我认为最好的计划是逐步交付 Go 2 的向后兼容部分 (backwards-compatible parts),一项一项地作为 Go 1 发布序列的一部分。这有几个重要的特性。首先,它使 Go 1 版本保持在常规发布周期 (usual schedule)上,以持续提供用户现在依赖的及时错误修复和改进。其次,它避免了在 Go 1 和 Go 2 之间分散开发工作。第三,它避免了 Go 1 和 Go 2 之间的分歧,以方便所有人的最终迁移。第四,它使我们能够专注于一次交付一项更改,这有助于保持质量。第五,它将鼓励我们设计具有向后兼容性的功能。
在任何更改开始进入 Go 1 版本之前,我们需要时间进行讨论和规划,但我认为大约一年后,在 Go 1.12 左右,我们可能会开始看到一些小的更改。这也给了我们时间先落实包管理支持。
一旦所有向后兼容的工作完成,比如说在 Go 1.20 中, then 我们可以进行 Go 2.0 中的向后不兼容更改 (backwards-incompatible changes)。如果最终发现没有向后不兼容的更改,也许我们直接宣布 Go 1.20 就是 Go 2.0。无论如何,届时我们将从 Go 1.X 发布序列的工作转向 Go 2.X 序列,可能还会为最终的 Go 1.X 版本提供一个延长支持期。
这多少有些推测性,我刚才提到的具体版本号是大概估计的占位符,但我想明确表示,我们不会放弃 Go 1,事实上,我们将尽可能大地带上 Go 1 一同前行。
招募帮助
我们需要您的帮助。
关于 Go 2 的讨论今天开始,它将在公开场合进行,例如在邮件列表和问题跟踪器等公共论坛上。请在每一步都帮助我们。
今天,我们最需要的是使用体验报告。请告诉我们 Go 对您来说如何顺利工作,以及更重要的是,如何不顺利工作。请撰写一篇博文,包含真实的示例、具体的细节和真实的使用经验。并将其链接到我们的维基页面。这就是我们开始讨论 Go 社区可能希望对 Go 进行哪些更改的方式。
谢谢。
下一篇文章:贡献者峰会
上一篇文章:介绍开发者体验工作组
博客索引