Go 博客
迈向 Go 2
引言
[这是我在 2017 年 Gophercon 上的演讲文本,呼吁整个 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 运行得更高效,并且添加了关键工具,例如竞态检测器。
现在,我们拥有五年使用 Go 构建大型、生产级系统的经验。我们已经对哪些有效以及哪些无效有了感觉。现在是时候开始 Go 演进和发展中的下一步,规划 Go 的未来。我今天来这里是为了请求 Go 社区的所有人,无论您是 GopherCon 的现场观众、观看视频还是稍后阅读 Go 博客,都与我们一起规划和实施 Go 2。
在本次演讲的剩余部分,我将解释我们对 Go 2 的目标;我们的约束和限制;总体流程;撰写关于我们使用 Go 经验的重要性,尤其是与我们可能尝试解决的问题相关的经验;可能的解决方案类型;我们将如何交付 Go 2;以及你们所有人如何提供帮助。
目标
我们今天对 Go 的目标与 2007 年相同。我们希望使程序员更有效地管理两种规模:生产规模,尤其是与许多其他服务器交互的并发系统,今天由云软件举例说明;以及开发规模,尤其是由许多工程师协作开发的大型代码库,今天由现代开源开发举例说明。
这些规模在各种规模的公司中都会出现。即使是一个五人的初创公司也可能使用其他公司提供的基于云的大型 API 服务,并且使用比自己编写的软件更多的开源软件。生产规模和开发规模对于该初创公司与 Google 一样重要。
我们对 Go 2 的目标是修复 Go 在扩展方面最显著的失败之处。
(有关这些目标的更多信息,请参阅 Rob Pike 在 2012 年撰写的文章“Go at Google: Language Design in the Service of Software Engineering”以及我在 2015 年 GopherCon 上的演讲“Go, Open Source, Community”。)
约束
Go 的目标自始至终没有改变,但 Go 的约束条件肯定发生了变化。最重要的约束是现有的 Go 使用情况。我们估计全球至少有50 万 Go 开发人员,这意味着有数百万个 Go 源文件和至少 10 亿行 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 开源以来,部分工作是将我们的非正式流程移植到更正式的邮件列表和问题跟踪器以及 50 万用户的世界中,但我认为我们从未明确地描述过我们的总体流程。我们可能从未有意识地想过它。但是,回顾过去,我认为这是我们 Go 工作的基本概述,是我们自第一个原型运行以来一直遵循的流程。
步骤 1 是使用 Go,积累使用经验。
步骤 2 是识别 Go 中可能需要解决的问题并将其阐明,向他人解释,并将其写下来。
步骤 3 是针对该问题提出解决方案,与他人讨论并根据讨论结果修改解决方案。
步骤 4 是实施解决方案,对其进行评估并根据评估结果对其进行改进。
最后,步骤 5 是发布解决方案,将其添加到语言、库或人们每天使用的工具集中。
同一个人不必对某个特定更改执行所有这些步骤。事实上,通常许多人会协作完成任何给定的步骤,并且对于一个问题可能会提出许多解决方案。此外,在任何时候我们都可能意识到我们不想进一步推进某个特定想法,并回到之前的步骤。
虽然我们从未完整讨论过这个流程,但我们解释过其中的一部分。2012 年,当我们发布 Go 1 并宣布现在是时候使用 Go 并停止修改它时,我们是在解释步骤 1。2015 年,当我们引入 Go 更改提案流程时,我们是在解释步骤 3、4 和 5。但我们从未详细解释过步骤 2,所以我想现在来做这件事。
(有关 Go 1 的开发和语言更改转向的更多信息,请参阅 Rob Pike 和 Andrew Gerrand 在 2012 年 OSCON 上的演讲“Go 1 之路”。有关提案流程的更多信息,请参阅 Andrew Gerrand 在 2015 年 GopherCon 上的演讲“Go 是如何诞生的”以及提案流程文档。)
解释问题
解释问题有两个部分。第一部分——较容易的部分——是准确地陈述问题是什么。我们开发人员在这方面做得不错。毕竟,我们编写的每个测试都是一个要解决的问题陈述,其语言精确到连计算机都能理解。第二部分——较难的部分——是充分描述问题的意义,以便每个人都能理解为什么我们应该花费时间来解决它并维护解决方案。与精确地陈述问题相比,我们不需要经常描述问题的意义,而且我们在这方面远不如前者。计算机从未问过我们“这个测试用例为什么重要?你确定这是你需要解决的问题吗?解决这个问题是你能做的最重要的事情吗?”也许有一天它们会问,但不是今天。
让我们看看 2011 年的一个旧例子。以下是我在计划 Go 1 时关于将 os.Error 重命名为 error.Value 所写的内容。
它以一个简洁的、单行的问题陈述开始:在非常底层的库中,所有内容都导入“os”以获取 os.Error。然后是五行,我在这里用下划线标出,专门用于描述问题的意义:“os”使用的包本身无法在其 API 中呈现错误,而其他包出于与操作系统服务无关的原因依赖于“os”。
这五行是否让你相信这个问题很重要?这取决于你能否很好地补充我省略的上下文:被理解需要预料到其他人需要知道什么。对我当时的受众——谷歌 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
由于每日时钟对于跨越此类时钟重置的事件计时不准确,因此操作系统现在提供了第二个时钟,即单调时钟,它没有绝对意义,但可以计数秒数,并且永远不会重置。
除了奇怪的时钟重置之外,单调时钟并不比每日时钟好,而每日时钟具有额外的好处,即可用于显示时间,因此为简单起见,Go 1 的时间 API 仅公开每日时钟。
2015 年 10 月,一个错误报告指出,Go 程序无法正确计时跨越时钟重置的事件,尤其是一般的闰秒。建议的修复方法也是最初的问题标题:“添加一个新的 API 来访问单调时钟源”。我认为这个问题的重要性不足以证明新的 API 正当。几个月前,为了 2015 年年中发生的闰秒,Akamai、亚马逊和谷歌在整整一天内都将时钟稍微减慢了一点点,吸收了额外的秒数,而没有将时钟拨回。这似乎表明,这种“闰秒平滑”方法的最终广泛采用将消除闰秒时钟重置作为生产系统中的问题。相比之下,向 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 中对别名声明的支持。
在过去的几年里,谷歌成立了一个专注于大规模代码更改的团队,这意味着 API 迁移和错误修复应用于我们的包含数百万个源文件和数十亿行代码的代码库,这些代码库是用 C++、Go、Java、Python 和其他语言编写的。我从该团队的工作中学到的一件事是,当将 API 从使用一个名称更改为另一个名称时,能够分多个步骤而不是一次性更新客户端代码的重要性。为此,必须能够编写一个声明,将对旧名称的使用转发到新名称。C++ 有 #define、typedef 和 using 声明来启用此转发,但 Go 没有。当然,Go 的目标之一是很好地扩展到大规模代码库,并且随着谷歌中 Go 代码量的增长,我们清楚地意识到我们需要某种转发机制,并且其他项目和公司在他们的 Go 代码库增长时也会遇到这个问题。
2016 年 3 月,我开始与 Robert Griesemer 和 Rob Pike 讨论 Go 如何处理渐进的代码库更新,我们得出了别名声明,这正是所需的转发机制。在这一点上,我对 Go 的发展方向感觉很好。我们从 Go 的早期就开始讨论别名——事实上,第一个规范草案有一个使用别名声明的示例——但每次我们讨论别名和后来的类型别名时,我们都没有明确的用例,因此我们将其省略了。现在,我们提议添加别名不是因为它们是一个优雅的概念,而是因为它们解决了一个重要的实际问题,即 Go 如何实现可扩展软件开发的目标。我希望这能成为未来 Go 更改的典范。
在那个春天的晚些时候,Robert 和 Rob 撰写了一个提案,Robert 在2016 年 Gophercon 的闪电演讲中介绍了它。接下来的几个月进展并不顺利,它们绝对不是未来 Go 更改的典范。我们学到的众多教训之一是描述问题意义的重要性。
一分钟前,我向您解释了这个问题,并提供了一些关于它如何出现以及原因的背景信息,但没有提供任何具体的例子来帮助您评估这个问题是否会在某个时候影响到您。去年夏天的提案和闪电演讲提供了一个抽象的例子,涉及包 C、L、L1 和 C1 到 Cn,但没有开发人员可以关联的具体例子。结果,社区的大部分反馈都是基于这样的观点:别名只解决了 Google 的问题,而不是其他所有人的问题。
就像我们 Google 最初没有理解正确处理闰秒时间重置的重要性一样,我们也没有有效地向更广泛的 Go 社区传达处理大规模更改期间的逐步代码迁移和修复的重要性。
在秋季,我们重新开始。我做了一个演讲,并写了一篇文章介绍了这个问题,使用了从开源代码库中提取的多个具体示例,展示了这个问题是如何在各个地方出现的,而不仅仅是在 Google 内部。现在更多的人理解了这个问题并看到了它的重要性,我们进行了关于哪种解决方案最合适的富有成效的讨论。结果是类型别名将被包含在 Go 1.9 中,并将帮助 Go 扩展到更大的代码库。
经验报告
这里的教训是,用其他人可以理解的方式描述问题的意义是困难但必要的。为了作为一个社区讨论 Go 的重大变化,我们需要特别注意描述我们想要解决的任何问题的意义。最清晰的方法是展示问题如何影响真实的程序和真实的生产系统,就像在Cloudflare 的博文和我的重构文章中一样。
像这样的经验报告将抽象问题转化为具体问题,并帮助我们理解其意义。它们也充当测试用例:任何提出的解决方案都可以通过检查其对报告描述的实际、现实世界问题的影响来进行评估。
例如,我最近一直在研究泛型,但我脑海中没有 Go 用户需要泛型来解决的详细、具体问题的清晰画面。结果,我无法回答诸如是否支持泛型方法的设计问题,也就是说,与接收器分开参数化的方法。如果我们有一大批现实世界的用例,我们可以通过检查重要的用例来开始回答这样的问题。
再举一个例子,我看到了以各种方式扩展错误接口的提案,但我没有看到任何经验报告显示大型 Go 程序如何尝试理解和处理错误,更不用说当前的错误接口如何阻碍这些尝试了。这些报告将帮助我们更好地理解问题的细节和意义,我们必须在解决问题之前做到这一点。
我还可以继续说下去。Go 的每一个主要的潜在变化都应该由一个或多个经验报告来推动,这些报告记录了人们今天如何使用 Go 以及为什么这还不够好。对于我们可能考虑对 Go 进行的明显重大更改,我并不知道有多少这样的报告,尤其是没有用现实世界示例进行说明的报告。
这些报告是 Go 2 提案流程的原始材料,我们需要你们所有人来撰写它们,以帮助我们了解你们使用 Go 的经验。你们有 50 万人,在广泛的环境中工作,而我们中只有少数人。在您自己的博客上发布帖子,或撰写Medium 帖子,或撰写GitHub Gist(为 Markdown 添加 .md
文件扩展名),或撰写Google 文档,或使用您喜欢的任何其他发布机制。发布后,请将帖子添加到我们的新 wiki 页面golang.org/wiki/ExperienceReports。
解决方案
现在我们知道了如何识别和解释需要解决的问题,我想简要说明一下,并非所有问题都最适合通过语言更改来解决,这很好。
我们可能想要解决的一个问题是,计算机通常可以在基本的算术运算期间计算额外的结果,但 Go 没有提供对这些结果的直接访问。2013 年,Robert 建议我们可能将双结果(“逗号 ok”)表达式的概念扩展到基本的算术运算。例如,如果 x 和 y 是,比如说,uint32 值,则 lo, hi = x * y
将不仅返回通常的低 32 位,还返回乘积的高 32 位。这个问题似乎并不特别重要,因此我们记录了潜在的解决方案,但没有实现它。我们等待着。
最近,我们为 Go 1.9 设计了一个math/bits 包,其中包含各种位操作函数
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 之后,我们可能想要解决的另一个问题是,goroutine 和共享内存使在 Go 程序中引入竞争条件变得过于容易,从而导致生产环境中的崩溃和其他错误行为。基于语言的解决方案将是找到某种方法来禁止数据竞争,使编写或至少编译存在数据竞争的程序成为不可能。如何在像 Go 这样的语言中实现这一点仍然是编程语言世界中的一个开放性问题。相反,我们在主发行版中添加了一个工具,并使其易于使用:该工具,即竞态检测器,已成为 Go 体验中不可或缺的一部分。这里最好的解决方案是运行时和工具更改,而不是语言更改。
当然,也会有语言更改,但并非所有问题都最适合在语言中解决。
发布 Go 2
最后,我们将如何发布和交付 Go 2?
我认为最好的计划是将 Go 2 的向后兼容部分逐步地、逐个特性地作为 Go 1 发布序列的一部分发布。这具有一些重要的特性。首先,它使 Go 1 发布保持在通常的计划上,以继续进行用户现在依赖的及时错误修复和改进。其次,它避免了在 Go 1 和 Go 2 之间分割开发工作。第三,它避免了 Go 1 和 Go 2 之间的差异,以方便每个人的最终迁移。第四,它允许我们一次专注于一个更改并交付一个更改,这应该有助于保持质量。第五,它将鼓励我们设计向后兼容的特性。
在任何更改开始进入 Go 1 版本之前,我们将需要时间来讨论和计划,但我认为我们可能会在大约一年后开始看到一些小的更改,例如 Go 1.12 左右。这也给了我们时间首先实现包管理支持。
一旦所有向后兼容的工作完成,例如在 Go 1.20 中,那么我们就可以在 Go 2.0 中进行向后不兼容的更改。如果事实证明没有向后不兼容的更改,也许我们只需宣布 Go 1.20 就是 Go 2.0。无论哪种方式,在那个时候,我们将从处理 Go 1.X 发布序列过渡到处理 Go 2.X 发布序列,可能为最终的 Go 1.X 发布提供扩展的支持窗口。
这都只是推测,我刚刚提到的具体版本号是粗略估计的占位符,但我想明确表示我们并没有放弃 Go 1,事实上,我们将尽可能地支持 Go 1。
需要帮助
我们需要您的帮助。
Go 2 的对话从今天开始,它将在公开场合进行,在公开论坛(如邮件列表和问题跟踪器)中进行。请在整个过程中帮助我们。
今天,我们最需要的是经验报告。请告诉我们 Go 对您来说是如何工作的,更重要的是,它对您来说是如何不工作的。写一篇博客文章,包含真实的例子、具体的细节和真实的体验。并在我们的wiki 页面上链接它。这就是我们将开始讨论我们(Go 社区)可能想要更改 Go 的哪些方面的方式。
谢谢。
下一篇文章:贡献者峰会
上一篇文章:介绍开发人员体验工作组
博客索引