Go语言之父谈14岁的Go-不止是编程语言
2024-01-12 20:45:33 阿炯

2023年岁末,Go语言之父 Rob Pike 在悉尼 GopherConAU 会议上发表了一场耐人寻味的演讲。Go语言发展14年(2009年11月10日),它已不在是一门编程语言。原文链接

作者 | Rob Pike / 译者 | 弯月
责编 | 夏萌
出品 | CSDN(ID:CSDNnews)

2023年11月10日是Go语言作为开源项目发布的十四周年纪念日。

我还记得那一天,美国加利福尼亚时间下午3点,Ken Thompson、Robert Griesemer、Russ Cox、Ian Taylor、Adam Langley、Jini Kim和我怀着无比期待的心情,看着网站上线,将我们的工作成果展示给全世界。

十四年过去了,有很多事情值得回顾。今天,我想借此机会谈谈那天以来我们所学到的一些重大经验教训。即使是最成功的项目,回顾时也会发现一些本可以做得更好的地方。当然,也有一些方面事后看来似乎是成功的关键。

我想事先明确的是,此次发言仅代表我个人,不代表Go团队,也不代表Google。继往开来,Go是一个由一支专注的团队和庞大社区共同努力的巨大项目,所以如果你同意我的发言,请感谢他们。如果你不同意,一切责任在我,请不要责怪他们。

鉴于本文的标题,很多人可能以为我会分析Go语言的优点和缺点。这方面的内容我当然会谈到,但出于一些原因我也会提到其他方面。

首先,对于编程语言来说,什么是好什么是坏,很大程度上是主观而非客观的,很多人对Go或任何其他语言,乃至一些非常细节的特性发表了很多争议。

还有很多关于诸如换行符放在哪里,nil的工作原理,使用大写字母进行导出,垃圾收集,错误处理等方面的讨论。虽然可以讨论的方面很多,但几乎都已说过了。

但我要谈论的不仅仅是语言本身,因为这门语言并不是这个项目的全部。我们的目标并不是创建一种新的编程语言,而是创建一种更好的编写软件方式。无论是什么语言,我们每个人可能都会认为我们使用的语言有一些问题,但问题根源并非来自这些语言的特性,而是因为使用这些语言创建软件的过程。

新语言的创建为探索其他思想提供了一条新路径,但这只是促成因素,而不是最终目的。如果不是因为当初我在工作中构建二进制文件需要45分钟之长,Go就不会诞生,但这45分钟并不是因为编译器慢(实际上编译器并不慢),也不是因为编写代码所使用的语言很糟糕(实际上语言本身也不差),慢的原因来自其他因素。

而这些因素正是我们想要解决的问题:构建现代服务器软件的复杂性,控制依赖性,大型编程团队的人员不断变动,如何易于维护、高效测试、有效利用多核CPU和网络等等。

简而言之,Go不仅仅是一种编程语言。当然,Go也确实是一种编程语言,这是它的定义,但它的目的是提供一种更好的方法来开发高质量的软件,至少优于我们14年前的环境。

而这仍然是今天的目标:Go是一个旨在降低构建生产软件的难度并提高效率的项目。

几周前,在准备此次演讲时,我只有一个标题。为了激发灵感,我在Mastodon上询问了人们的意见。在众多回复之中,我注意到了一个趋势:人们认为我们做错的方面都集中在语言上,但从更高层面来看我们都做对了,比如gofmt、部署和测试等相关方面。这实际上让我很受鼓舞,因为我们的努力似乎有了结果。

但我必须承认,或许是因为我们觉得这些目标很明显,最初我们并没有清楚地阐明真正目标。为了弥补这一不足,2013年我在SPLASH大会上做了一个演讲,题为《Google的Go语言:服务于软件工程的语言设计》。

那次演讲以及相关博文或许是Go语言诞生动机的最好解释。 而本文可以看作是那次演 讲的后续, 回顾我们在完 成语言构建后,投入到更广泛以及更高层面工作的过程中积累的经验。

首先,当然是Gopher。

1、Gopher

虽然Gopher的诞生很奇特,但它是Go成功的最早因素之一。远在Go语言发布很久之前,我们就知道我们需要一个吉祥物来纪念这个项目——每个项目都需要纪念品,最终Renee French为我们创造了一个,我们的这个决定很正确。

下面是gopher毛绒玩具的第一张照片。


下面是gopher的一个不太成功的原型图片。


Gopher是一个吉祥物,是Go程序员的荣誉徽章,甚至是身份象征。有一场Go语言大会就叫做GopherCon。从第一天起,拥有一个有辨识度、有趣的生物对Go的发展至关重要。它给人一种看似愚蠢、实则聪明的感觉,它可以构建任何东西!


图:Gopher正在构建一个机器人(Renee French创作)

这个吉祥物奠定了社区与项目的互动基调,即技术卓越与真正乐趣相结合。最重要的是,Gopher是Go社区的一面旗帜,尤其是在Go在编程世界仍然是新兴力量的早期,众人都集结在这面大旗之下。

下面是多年前一众Gopher参加巴黎会议的照片。看看它们有多兴奋!


话虽如此,以创作共用署名许可证发布Gopher的设计或许并不是最好的选择。一方面,这鼓励人们以有趣的方式进行二创,而这反过来又有助于培养社区精神。


Renee创建了一份“模型表”来帮助艺术家们进行创作,同时坚守Gopher的精神。

一些艺术家利用这些特性进行了一些很有趣的创作,Renee和我最喜欢的是日本设计师@tottie制作的版本:


以及游戏程序员@tenntenn:


但是,许可证中的“归属”部分常常引发争论,而有些创作并非出自Renee,也不符合原作的精神,但“赞誉”却阴差阳错地给到了她。老实说,有人很不情愿地写出归属,而有人则根本没有写出归属。例如,我怀疑虽然@tenntenn的Gopher插图被有些人使用,但他并没有得到任何报酬,甚至连致谢都没有。

因此,如果从头来过,我们会认真考虑确保吉祥物忠于其理念的最佳方法。维护吉祥物是一个难题,解决方案可遇而不可求。下面我们来谈谈技术。

2、做对了的方面

以下是我认为从客观的角度来看我们做对了的方面。并不是每个语言项目都做了这些事情,但每一项对Go的最终成功都至关重要。我会尽量简洁,因为大家都很熟悉这些主题。

1. 规范

我们的起点始于一个正规化的规范。这不仅确定了编译器的行为,而且允许多种实现存在,并且不同实现拥有相同的行为。只有编译器并不能构成规范。总得有个标准才能测试编译器。

顺便说一下,规范的第一版草稿是在悉尼达令港的一座大楼的18楼写的。此次我们庆祝Go的生日正是在Go的故乡悉尼。

2. 多个实现

我们有多个编译器,都实现了相同的规范。有了规范,实现这一目标就变得更容易。

有一天,Ian Taylor突然发送电子邮件跟我们说,他在阅读我们的规范草案后,动手编写了一个编译器,这让我们感到十分惊讶。

惊讶之余,许多人纷纷效仿,这都得益于规范的存在。
cmd/compile
gccgo
go-llvm/llgo
gopherJS
tinygo
and more...

拥有多个编译器可以帮助我们完善语言并改进规范,同时还可以为那些不喜欢我们的类似于Plan-9的经营方式的人提供了另一种环境。(稍后详谈。)

如今我们有很多兼容的实现,这非常好。

3. 可移植性

我们大幅降低了交叉编译的难度,因此程序员都可以在自己喜欢的任何平台上工作,并在需要的任何平台上发布。就这一点而言,使用Go语言要比其他任何语言都更容易。想象一个在代码运行的机器上的原生编译器很容易,但实际情况并不一定如此。打破这种设想很重要,而且许多开发人员都不太了解这一点。

4. 兼容性

我们为编写1.0版本付出了很大努力,并确保了兼容性。鉴于这对Go的普及产生了如此巨大的、有据可查的影响,我很费解为什么大多数其他项目会抵制这种做法。没错,维护强大的兼容性确实需要付出代价,但可以防止我们过分追求新特性,避免陷入几乎没有什么是稳定的世界,不必担心新版本破坏项目,多么舒心啊。

5. 库

尽管大量库的涌现在一定程度上属于偶然事件,因为刚开始的时候安装了Go代码的人只有我们自己,但拥有一个坚实的、精心编写、其中包含编写21世纪服务器所需的大部分代码的库是一项重要资产。为此,社区一直在使用相同的工具包,直到我们有足够的经验,了解还应该提供哪些工具。这种方式很有效,有助于防止出现变体库,同时有助于统一社区。

6. 工具

我们努力确保Go语言易于解析,以方便构建工具。起初,我们认为Go需要一个集成开发环境(IDE),但简便的工具意味着,Go的IDE会自动出现。随着gopls的出现,IDE确实出现了,并且非常出色。

此外,我们还提供了一组编译器的附加工具,自动化测试、覆盖率和代码审查。当然,还有go命令整合了整个构建过程,对于许多项目来说,这足以构建和维护Go代码了。

而这并没有妨碍Go快速编译的好名声。

7. Gofmt

我将gofmt从工具中拿出来单独讨论,是因为这个工具不仅影响了Go,而且对整个编程社区都产生了影响。在Robert编写gofmt之前(顺便说一下,他坚持从项目之初就编写这个工具),自动格式化工具的质量都不高,因此基本上没有人使用。

事实证明,Gofmt可以出色地完成这项工作,如今几乎每种值得使用的编程语言都有一个标准的格式化工具。无需再争论空格和换行符,为我们节省了很多时间,这些时间比花费在定义标准格式和编写这类自动化代码格式工具上的时间更有价值。

此外,人们在gofmt的基础之上构建了许多其他工具,比如简化器、分析器,甚至是代码覆盖工具。由于gofmt本质上是一个任何人都可以使用的库,因此你可以解析一个程序,编辑AST,然后直接打印出精确到每个字符的、人类和机器都可以阅读的代码。

谢谢你,Robert。

姑且就表扬到这里,下面我们来谈谈一些更有争议的话题。

3、并发

并发有争议吗?至少在2002年,也就是我加入Google的那一年是有的。John Ousterhout曾写文章说线程很糟糕,许多人赞同他的观点,因为线程似乎很难使用。

Google的软件几乎总是在回避线程,甚至可以说线程被彻底禁用了,而实施禁令的工程师引用了John Ousterhout观点。这让我感到不安。自上世纪70年代以来,我一直在使用并发,有时甚至没有意识到,在我看来,并发非常强大。但仔细一想,John Ousterhout犯了两个错误。首先,他把自己对于线程的看法推广到了自己负责之外的领域;其次,他主要是在抱怨使用笨拙的低级包(如pthread)来使用线程,而不是线程的基本思想。

这是所有工程师都会犯的一个错误,他们没有想明白解决方案和这类的问题。有时,提出的解决方案比解决的问题更难,而且很难看到其实还有更容易的方法。但这是另外的话题了。

根据我的经验,我们有更好的方法来使用线程,或者是并发等其他的叫法,甚至在Go语言发布之前我就在一次讲座中谈到过线程。

但并不是只有我一个人清楚这一点,许多关于并发编程的语言、论文,甚至是书籍,都表明并发的效果不错。只不过它还没有成为主流思想,而Go的诞生在某种程度上强调了这点。在上文我提到的45分钟的构建时间里,我尝试将线程添加到了一个非线程化的二进制文件,由于使用了错误的工具,导致我的工作困难重重。

回顾过去,平心而论,我认为Go在以下这个方面做出了很大贡献:说服编程世界并发是一种强大的工具,尤其是在多核网络化的世界中,而且并发比pthread有更好的表现。如今,大多数主流语言都提供了很好的并发支持。

此外,Go的并发版本算是比较新颖的,至少相较于“前辈”语言是很新颖的,因为goroutines是一种原始的并发模型。没有协程、没有任务、没有线程、没有名称,只有goroutines。我们发明了"goroutine"这个词,因为现有的术语都不太合适。直到今天,我仍然希望Unix的spell命令能够学会这个词。

顺便说一句,我经常被问到async/await,所以我打算谈一谈这个问题。让我有点难过的是,许多语言采用async/await模型及其相关的样式来支持并发,但它的改进绝对远胜于pthread。

相较于goroutines、channels和select,对于语言实现者来说,async/await更容易、更小巧,可以轻松地构建或整合到现有平台。但一些复杂性仍然抛给了程序员,常常引发Bob Nystrom所说的“被着色的函数”。

我认为Go展示了CSP(一种不同但更古老的模型)可以完美地融入过程式语言,而且不会太复杂。我甚至见过很多次有人编写这类的库。但是,这类良好的实现需要一个复杂的运行时,我能理解为什么有些人可能不愿意将其构建到系统中。然而,不管提供哪种并发模型,都要确保只提供一种,这一点很重要,因为一个环境提供多个并发实现可能会带来问题。当然,Go直接将CSP融入了语言,并没有采用库的形式。

有关这些问题可以专门做一期演讲,本文暂时就说到这里。

并发的另一个价值在于,它让Go看上去焕然一新。如前所述,以前有一些语言也支持并发,但并发从未成为主流,而Go对并发的支持是一个主要的吸引因素,有助于推动早期采用,吸引了以前没有使用过并发、但对其可能性感到好奇的程序员,

在这方面,我们犯了两个重大错误。

首先,并发是一种有趣的方式,我们都愿意使用,但我们所想到的大多数用例都是与服务器相关的,这意味着我们必须在核心库中实现并发,比如net/http,而且并非每个程序的每个地方都会使用并发。因此,许多程序员在尝试使用并发时,都很难弄清楚如何真正有效利用并发。我们本应事先说明语言中的并发支持带来的好处在于简化服务器软件。对于许多人来说,这个问题空间很重要,并非每个尝试Go的人都应该使用并发,这种指导上的欠缺是我们的责任。

第二点也与此相关,我们花费了太长时间来澄清并行和并发之间的区别,前者指的是在多核机器上支持多个计算,而后者是通过良好的代码组织方式实现并行计算。

无数程序员尝试使用goroutines来并行执行代码,以提升代码的运行速度,结果却适得其反,这让他们感到非常困惑。只有当底层问题本质上是并行的情况下,例如服务HTTP请求,并发代码才能提高速度。我们未能清楚地解释这一点,结果让许多程序员感到困惑,并导致一些人放弃了Go。

为了解决这个问题,2012年我在Waza,Heroku的开发者大会上发表了一段名为“并发不是并行”的演讲。那次演讲本身很有趣,但我应该早一点站出来。

对此我深表歉意。但也有好的一面:Go帮忙推广了并发作为构建服务器软件的一种方式。

4、接口

很明显,接口与并发一样,是Go的一个标志性概念。它们是Go对面向对象设计的回应,采用了最原始的、以行为为中心的风格,尽管许多刚刚加入Go社区的人不断要求让结构来承担这一重担。

接口动态化后,就无需提前声明哪些类型实现了它们,这在早期让一些人感到很困惑,至今仍有人不满意,但这对Go培养的编程风格非常重要。标准库的大部分都建立在它们的基础之上,而更广泛的主题,如测试和管理依赖性,也非常依赖动态接口的“慷慨”以及“包容万象”的本质。

我认为,接口是Go中最好的设计之一。

除了一些关于是否应将数据包含在其定义中的早期讨论外,接口在讨论的第一天就成形了。

说到此处,我想起一段往事。

就在上述所说的第一天,我和Robert在办公室里提出了关于多态性的问题。根据我和Ken的C语言的经验,qsort可以作为一个棘手的测试案例,于是我们三个人开始讨论我们还处于萌芽期的语言如何实现类型安全的排序例程。

我和Robert几乎同时提出了相同的想法:使用类型的方法提供排序所需的操作。这个想法迅速演变成一个概念:值类型具有行为,定义为方法,并且方法的集合可以提供函数操作的接口。紧接着,Go的接口就诞生了。

有一个经常被忽视的事实:Go的排序是作为函数实现的,而这个函数操作的是接口。虽然不同于大多数人熟悉的面向对象编程风格,但这是一个非常强大的概念。

这个概念让我们感到兴奋,而这有可能成为一个基础性编程结构的可能性让人陶醉。后来Russ来了,很快他就指出I/O非常适合这个概念,于是一个库迅速成形,主要基于三个著名的接口:empty、Writer和Reader,平均每个接口都包含三分之二的方法。这些微方法是Go的习惯用法,并且无处不在。

接口的工作方式不仅成为了Go的一个标志性特征,而且也成为了我们思考库、通用性和组合的方式。它让我们激动不已。

但我们的那次谈话就此打住可能是一个错误。

可以看出,我们选择这条路是因为见过太多次泛型编程鼓励在算法之前多多关注类型的思维方式。要尽早抽象,不要作有机设计。要使用容器,不要用函数。

我们在语言中定义了通用的容器:映射、切片、数组、通道,而没有为程序员提供访问它们所包含的通用性。有人认为这是一个错误。我相信(我至今仍认为这是正确的),大多数简单的编程任务可以通过这些类型很好地处理。但有一些任务是无法完成的,语言的功能与用户的控制之间的壁垒确实让一些人感到困扰。

简单来说,虽然我不想改变接口的工作方式,但它们在很多方面影响了我们的思维,而这需要十多年的时间来纠正。Ian Taylor很早就催促我们面对这个问题,但这很难,因为接口是Go编程的基石。

批评者经常抱怨我们应该使用泛型,因为它们很“简单”,而且在某些语言中可能确实如此,但接口的存在意味着任何新形式的多态性都必须考虑它们。找到一种能够与语言的其他部分良好协同工作的前进方式需要反复尝试,放弃很多实现,而且还需要投入大量时间(几个小时,几天,甚至是几周)来讨论。最终,我们找到了一些类型理论学家(由Phil Wadler领导)来帮助。时至今日,尽管Go语言已经有了一个坚实的泛型模型,接口作为方法集的存在仍然会引发一些潜在的问题。

最终的答案,相信大家都知道了:设计一个接口的泛化,可以吸收更多形式的多态性,从“方法集”过渡到“类型集”。尽管这个设计很深奥,却影响巨大,大多数社区似乎都能接受,但我认为抱怨永远不会停止。

有时候,有些问题需要花费好几年来弄清楚,有时你甚至会发现根本没有解决方法。但你仍要前行。

顺便说一下,我希望我们有一个比“泛型”更好的术语,能够代表一种不同的、以数据结构为中心的多态性风格的术语。“参数多态性”能够正确描述Go提供的功能,而且很准确,但不太好听。尽管不太正确,但我们仍采用了“泛型”。

5、编译器

编程语言社区感到困惑的事情之一是早期的Go编译器是用C编写的。在他们看来,正确的方法是使用LLVM或类似的工具包,或者就使用Go语言编写编译器,这个过程称为“自举”。但我们没有采取这两种方式,原因有如下几个。

首先,启动新语言需要先用现有语言完成其编译器的第一步。对于我们来说,很明显应该选择C,因为Ken已经写过一个C编译器,而且它的内部结构可以很好地作为Go编译器的基础。此外,在开发语言的同时,用语言自身编写编译器往往会导致语言变成适合编写编译器的语言,但这并不是我们想要的语言。

早期的编译器能够正常工作。它可以顺利启动Go语言。但这个编译器有点怪异,实际上是我们使用旧思维编写的一款Plan 9风格的编译器,而不是静态单赋之类的新型编译器。生成的代码一般,内部也不好看。但这款编译器很实用且高效,代码本身规模适中,而且我们都很熟悉,因此很方便我们在尝试新想法时快速修改。一个关键步骤是添加了自动增长的分段栈。这很容易添加到我们的编译器中,如果我们使用了类似LLVM的工具包,就无法将这个变化整合到编译器套件中,因为我们需要修改API和垃圾收集器支持。

另一个成功的地方是交叉编译,来自原始的Plan 9编译器套件的工作方式。

虽然我们的方式不正统,但可以帮助我们快速前进。有些人不太满意这种选择,但对我们来说,这是一个正确的决定。

到了Go 1.5版,Russ编写了一个工具,可以将编译器从C语言转换为Go语言(半自动)。当然,Go语言已经完成,我们无需再担心编译器的语言设计。网上有一些关于这个过程的讲座是值得一看。我在2016年的GopherCon上做了一个关于汇编器的演讲,这在我终身追求可移植性的历程中算是一个巅峰。

以C语言作为起点,这个选择是正确的,但最终我们将编译器转换为Go,这样在开发编译器的过程中就可以利用Go的所有优势,包括测试、工具、自动重写、性能分析等等。如今的编译器比最初的版本更加整洁,生成的代码也更好。当然,这就是自举编译器的目标。

请记住,我们的目标可不仅仅是一门语言。

我们不走寻常路绝对不是不尊重LLVM或语言社区中的任何人。我们只是使用了最适合的工具。当然,如今Go也有一个基于LLVM的编译器,还有许多其他编译器也有,这都是理所应当的。

6、项目管理

我们从一开始就知道,Go语言想要成功就必须是一个开源项目。但我们也知道,在明确核心理念,并拥有一个可行的实现之前,私下开发更为高效。刚开始的两年里,搞清楚我们究竟想实现什么,而且不会被干扰是至关重要的。

转向开源是一个巨大的变化,而且对我们很有意义。社区的意见非常多样。与社区互动需要大量的时间和精力,特别是对Ian来说,他不知道如何腾出时间来回答每个人的问题。但开源也带来了很多好处。Windows版的移植速度令我刮目相看,这完全是由社区在Alex Brainman的指导下完成的。真是太神奇了。

我们花了很长时间才理解转成开源项目的影响以及如何管理。

特别是平心而论,我们花了太长时间才搞清楚与社区合作的最佳方式。贯穿此次演讲的一个主题是我们在沟通方面的不足,尽管我们自认为我们之间的沟通良好,但种种误解和期望不匹配浪费了很多时间。我们本可以做得更好。

不过,最终我们说服了社区,至少留下来的那部分人与我们站到了一起,我们的一些想法尽管与常见的开源方式不同,但是很有价值。其中最重要的是,我们坚持通过强制性的代码审查和对细节的关注来保持高代码质量。

有些项目采用了不同的方式,快速接受代码,然后在提交后清理。Go项目则采用了相反的方式,我们希望在第一时间内确保代码的质量。我相信这是更高效的方式,但这意味着将更多的工作推给了社区,他们需要理解这种价值,否则就会感觉自己不受欢迎。我们在这方面仍然有很多需要学习的地方,但我相信现在已经好多了。

顺便提一下一个不太为人知的细节。Go项目使用过4种不同的内容管理系统:SVN、Perforce、Mercurial,然后是Git。Russ费尽心思保留了所有的历史记录,因此即使到了今天,Git代码库仍包含当初SVN中最早的变更记录。我们都认为保留历史记录是有价值的,我非常感谢他为此付出的辛勤努力。

还有一点,人们经常以为Google可以向Go团队发号施令,实则不然。Google给予了Go非常慷慨的支持,但Go语言的发展方向并不取决于Google。Google拥有一个庞大的内部Go代码库,我们团队使用它来测试和验证发布,但这些代码是从公共代码库导入的。简单来说,Go的核心团队是由Google出资建立的,但他们是独立的。

7、包管理

Go的包管理过程并不顺利。我认为语言本身的包设计很优秀,而且在最初一两年中,我们耗费了大量时间讨论。我前面提到的SPLASH演讲详细解释了Go的包管理,如果你感兴趣的话,可以看看。

一个关键点是在import语句中使用普通字符串来指定路径,这种做法很灵活,而且我们相信灵活性很重要。但从只有一个“标准库”到从网上导入代码的过渡并不顺利。

主要有两个问题。

首先,Go团队的早期核心成员都非常熟悉Google的工作方式,包括只使用一个代码库,以及每个人都在主干上构建。但是,Go的包管理器拥有大量包版本,而且解决依赖关系也很难,而我们这方面的经验很欠缺。直到今日,理解这方面的技术复杂性的人仍然寥寥无几,但这并不能成为我们未能从一开始就解决这些问题的借口。我感到非常惭愧,因为我曾是一个类似的项目(该项目旨在为Google的内部构建实现类似的功能)的技术负责人,我本应该意识到我们将要面对的问题。

我构建的deps.dev算是赎罪吧。

其次,虽然与社区合作解决依赖管理问题的想法本意是好的,但当最终的设计出现时,即使有大量的文档和理论说明,社区中的许多人仍感觉被怠慢了。

吃一堑长一智,Go团队学会了如何真正与社区合作,而且结果也有了很多改善。

然而,如今情况已经稳定下来了,而且从技术的角度来看,我们的设计很优秀,而且大多数用户都很满意。只是这中间花费的时间太长,一波三折。

8、文档和示例

文档也是一个不足的方面。我们写了很多文档,而且自以为很不错,但很快我们就发现,社区想要的文档的深度与我们的预期有一定的差距。

其中一个关键的缺失部分是最基本的功能示例。我们以为,只需说明这个东西是干什么的。我们花费了很长时间才明白展示如何使用更有价值。

不过,我们已经吸取了教训。如今我们的文档中有大量示例,主要由开源贡献者提供。此外,我们在很早的时候就确保这些示例可在Web上执行了。我在2012年的Google I/O大会上展示了并发,Andrew Gerrand编写了一小段Web代码,你可以直接在浏览器中运行这段代码。我认为这是直接在浏览器中运行代码的首例,因为Go是一种编译语言,许多人从未见过这种技巧。后来,这种技术被应用到了博客和在线包的文档中。

更重要的是,我们将这个技术部署到了Go Playground,所有人都可以在这个免费的公开沙盒中尝试Go,甚至可以开发代码。

9、总结

回首间,我们已走过漫漫长路。

如今看来,很多方面我们做的很正确,也因此成就了Go。但还有很多方面本可以做得更好,重要的是认识到这些错误,并从中吸取教训。对于管理重大开源项目的人来说,我们和社区双方的经验教训都值得借鉴。

我希望说明这些教训及其原因能有所帮助,或许对于那些对我们的工作内容以及方式持有异议的人来说,这也可以作为一种道歉或解释。

此时此刻,距离Go的发布已经过去了14年了。总的来说,结果还算不错。

很大一部分原因是我们从编写软件的方式来考虑Go的设计和开发,而不仅仅是作为一种编程语言,这些决策让我们抵达了新的彼岸。

我们能有此成就,主要原因如下:
强大的标准库,实现了编写服务器代码所需的大部分基础功能。
并发成为Go语言的一等组件。
基于组合而非继承的方式。
表明依赖管理的打包模型。
集成的快速构建和测试工具。
严格的统一格式化。
可读性高于巧妙性。
保证兼容性。

最重要的是,我们拥有极其强大且多样化的Gopher社区的支持。

无论出自何人之手,Go代码及其工作方式都是一样的;基本上没有人使用不同的子集,无论经过多长时间,代码都可以正常编译和运行,这也许是最有趣的结果之一,在主流编程语言之中可能也是首例。

这一点,我们确实做对了。


Go 语言之父Pike总结成功因素:吉祥物功不可没

Go 语言之父 Rob Pike 在澳大利亚悉尼举行的 GopherCon AU 大会上,为纪念 Go 编程语言发布 14 周年发表了一场演讲,主题旨在回顾(如见上文):"我们做对了什么以及做错了什么 (What We Got Right, What We Got Wrong)"。

Pike 分享了许多关于 Go 早期历史的内部记忆,以及在开发过程中有关一些重要因素的见解。不过他也声明称,本次发言仅代表个人观点,与 Go 团队或谷歌没有关系。

"Go 过去是、现在仍然是一个敬业的团队和一个庞大的社区所付出的巨大努力。所以如果你同意我说的任何话,请感谢他们。如果你不同意,可以责怪我,但请不要说出来。"

Pike 补充到,编程语言的好坏在很大程度上是一个见仁见智的问题,而不是事实。在 2022 年发表的一篇讨论了 Go 流行原因的文章中,Pike 与 Ken Thompson、Russ Cox、Robert Griesemer 和 Ian Lance Taylor 曾共同指出,Go 是专门为并发和并行性而设计的,在处理大规模工作负载的同时利用了新的多核芯片的强大功能。但他们也将 Go 的成功归功于其持续的 “以开发为中心的理念”,以及其蓬勃发展的社区及其贡献(包括新包)。

在演讲中 Pike 再次提到了这个主题表示,“我们最初的目标不是创造一种新的编程语言,而是创造一种编写软件的更好方法..... 如果我当时不花 45 分钟来构建二进制文件,Go 就不会出现。”

简而言之,Pike 指出,Go 是一种编程语言,但也不仅仅是一种编程语言。它的目的是帮助提供一种开发高质量软件的更好方法。时至今日,这仍然是它的目标;让生产软件的开发更简单、更高效的项目。

出人意料的是,Pike 在例举 Go 语言的成功之处时,首先提及的是吉祥物 (Go gopher),并将之誉为 Go 成功的最早因素之一、对 Go 的发展至关重要。他认为,呆萌有趣辨识度高的吉祥物很好的团结了社区氛围,为社区参与项目奠定了基调 —— 即卓越的技术与真正的乐趣相结合。

但对于以 CC 许可发布 Gopher 的设计,Pike 则坦言这 "也许不是最好的选择",如果再重来一次他们会慎重考虑。在提到 Go 语言发展过程中所做出的一些正确决策时,Pike 提到了:确保 Go 易于解析,此举反过来又使得创建 IDE 等工具以及 Go 的官方语言服务器 gopls(也提供 IDE 功能)变得容易;以及为编译器添加了自动测试和代码审查工具。其他还包括:
gofmt 自动格式化工具
Go 的软件包库
发布了 Go 语言的正式规范
在早期就发布了 Go 语言的兼容性保证等等。

此外,Pike 还透露了一些他期待出现的功能,包括:允许使用任意精度的整数,他认为这将消除一整类安全隐患;以及希望看到编译器对 Go 的动态接口进行更多自动检查,并检查资源共享可能导致的进度停滞死锁。"任何能让程序在编译时更安全的东西都是好东西。"

Pike 指出,Go 语言的另一个关键之处在于它的可移植性;也就是说,它可以轻松地为其他平台编译代码。这在一定程度上得益于 Ken Thompson 用 C 语言编写的编译器,尽管其他人认为编译器应该用 Go 本身编写(或使用 LLVM 中的工具)。Pike 也将该编译器描述为 “an odd duck”,但无论如何,他认为这对于当时的处境来说是一个正确的选择。直到 2015 年,Russ Cox 编写了一个工具,可以将编译器从 C 半自动编译为 Go。而有关 Go 最具影响力的决定 —— “并发”。在分享这一故事时,Pike 首先描述了 2002 年自己刚加入 Google 时的世界。在他的记忆中,彼时的谷歌似乎一直在回避进程线程的并发执行,甚至采取 "几乎完全禁止" 的态度,这也让他感到很苦恼。

“自 20 世纪 70 年代以来,我一直在做类似并发的事情,甚至是在无意识的情况下”。事实上,除了 Pike 外,当时许多其他语言、论文甚至书籍都写过关于并发编程的内容,并表明并发编程可以做得很好。只是当时的并发还没有成为主流理念,Go 的诞生部分就是为了解决这个问题,而它最终也成为了 Go 最大的亮点之一。

“回顾过去,我认为可以公平地说,Go 在 让编程世界相信并发是一个强大的工具(尤其是在多核网络世界中)方面发挥了重要作用,并且它可以比 pthread 做得更好。如今,大多数主流语言都对并发提供了良好的支持。但在当时,这让 Go 看起来像是一种新事物......Go 对并发的支持是一个主要的吸引因素,它帮助增加了早期的采用率,吸引了那些之前没有使用过并发但对其可能性很感兴趣的程序员”。

Pike 认为,这是一个绝对成功的举措,Go帮助普及了并发作为服务器软件结构的一种方式。最后,他简洁的总结了一些促使 Go 成功的因素,“最重要的是,我们得到了 Gophers 这个乐于助人、多元化社区的大力支持。”

更多详情可查看其博客


Russ Cox 评 Go 语言发展历史上的重大决策

Go 是 2007 年末由谷歌创立的一种程序设计语言,2009 年 11 月以开源形式发行。自那以后,Go 就作为一个公共项目运作,有成千上万的个人和几十家公司作出贡献。Go 已经成为一种很受欢迎的语言,用于构建云计算基础设施:Linux 容器管理器 Docker 和容器部署系统 Kubernetes 是由 Go 开发的一种核心云计算技术。现如今,Go 已经成为了各大云计算提供商的重要基础设施的基础,也是云原生计算基金会托管的大多数项目的实现语言。

有许多理由让早期使用者对 Go 感兴趣。一种用于构建系统的垃圾收集、静态编译的语言是不寻常的。Go 提供的并行性和并发性的原生支持,使其能够充分发挥当时正在成为主流的多核机器的优势。自带的二进制文件和简单的交叉编译使部署变得更加容易。当然,谷歌这个名称也是一大亮点。

但是为什么用户会留下来?为什么 Go 在很多其他语言项目还没有开发出来的时候,它就变得如此流行了呢?我们认为,语言本身只是答案的一小部分。完整的故事应该包括整个 Go 环境:库、工具、约定和软件工程的整体方法,这些都支持用该语言编程。所以,在语言设计方面,最关键的决策是让 Go 能够更好地适应大型软件工程,并且能够吸引有相同想法的开发人员。

在本节中将会回顾那些我们认为对 Go 的成功负有最大责任的设计决策,并探讨这些设计决策如何不仅适用于语言,而且适用于更广泛的环境。很难将具体决策中的贡献分开,因此本文不应被视为一种科学的分析,而是一种对 Go 十多年来的经验和对用户反馈作出的最好的诠释。

起源

Go 的诞生源于谷歌构建了大规模分布式系统,在一个由成千上万的软件工程师共享的大型代码库中工作。我们希望为这种环境设计的语言和工具能够应对公司和整个行业所面临的挑战。随着开发工作的开展和生产系统的大量部署,这些都带来了一些挑战。

开发规模。在开发方面,谷歌在 2007 年有大约 4000 名活跃的用户在一个单一的、共享的、多语言(C++、Java、Python)的代码库中工作。单一的代码库使它很容易修复,例如,内存分配器中的问题会让主 Web 服务器变慢。但是在使用库的时候,由于很难找到一个包的所有依赖关系,所以很容易在不知不觉中破坏了一个以前未知的客户端。

另外,在我们使用的现有语言中,导入一个库可能会导致编译器递归加载所有导入的库。在 2007 年的一次 C++ 编译中,我们观察到,(在 #include 处理后)传递一组总共 4.2MB 的文件时,编译器读取了超过 8GB 的数据,在一个已经很大的程序上,扩展系数几乎达到 2000。如果为编译一个给定的源文件而读取的头文件的数量随着源树线性增长,那么整个源树的编译成本就会呈平方增长。

为了弥补速度的减慢,我们开始研究一个新的、大规模并行和可缓存的编译系统,它最终成为开源的 Bazel 编译系统。我们认为,光靠语言本身是远远不够的。

生产规模。在生产方面,谷歌运行的是规模非常庞大的系统。例如,在 2005 年 3 月,Sawzall 日志分析系统的一个拥有 1500 块 CPU 的集群处理了 2.8PB 的数据。2006 年 8 月,谷歌的 388 个 Big-table 服务集群由 24500 个独立的 Tablet 服务器组成,其中一组 8069 个服务器每秒处理 120 万个请求。

不过,像业界其他公司一样,谷歌也在致力于编写高效率的程序,以便充分发挥多核系统的优势。我们的很多系统都必须在一台机器上运行同一个二进制文件的多个副本,这是由于现有的多线程支持繁琐且性能低下。庞大的、固定大小的线程栈,重量级的栈开关,以及用于创建新线程和管理它们之间的交互的笨拙语法,都使得使用多核系统变得更加困难。但是显然,在服务器中,内核的数量只会越来越多。

我们还认为,语言自身能够提供易于使用的轻量级的并发性原语。我们也在这些额外的内核中看到了一个机会:垃圾收集器可以在一个专用的内核上与主程序并行地运行,这样可以减少它的延迟。

我们想知道,为应对这些挑战而设计的语言可能会是什么样子的,答案就是 Go。Go 的流行,一定程度上是因为所有的科技行业都要面对这样的挑战。云计算提供商使得最小型企业也可以将目标锁定在大规模的生产部署上。尽管大部分公司没有数千名雇员编写代码,但是如今几乎每个公司都依靠着数以千计的程序员完成的大量开源基础设施。

本节的其余部分将探讨具体的设计决定如何解决这些开发和生产的扩展目标。我们从核心语言本身开始,向外扩展到周围的环境。我们不打算全面地介绍这门语言。关于这一点,可以参阅 Go 语言规范或者《Go 编程语言》(The Go Programming Language)之类的书籍。



一个 Go 程序是由一个或多个可导入的包组成的,每个包都包含一个或多个文件。图 1 中的 Web 服务器展示很多有关 Go 的包系统设计的重要细节:


图 1:Go Web 服务器

该程序启动了一个本地的 Web 服务器(第 9 行),它通过调用 hello 函数来处理每个请求,hello 函数用消息“hello, world”(第 14 行)进行响应。

与许多语言相同,一个包使用明确的 import 语句导入另一个包(第 3-6 行),但与 C++ 的文本 #include 机制不同。不过,与大多数语言不同的是,Go 安排每个 import 只读取一个文件。例如,fmt 包的公共 API 引用了 io 包的类型:fmt.Fprintf 的第一个参数是 io.Writer 类型的接口值。在大多数语言中,处理 fmt 的 import 的编译器也会加载所有的 io 来理解 fmt 的定义,这可能又需要加载额外的包来理解所有 io 的定义。一条 import 语句可能最终要处理几十甚至几百个包。

Go 采用与 Modula-2 相似的方式,将编译后的 fmt 包的元数据包含了了解其自身依赖关系所需的一切,例如 io.Writer 的定义,从而避免了这种工作。因此,import "fmt" 的编译只读取一个完全描述 fmt 及其依赖关系的文件。此外,在编译 fmt 时,可以一次性实现这种扁平化,这样就可以避免每次导入时的多次加载。这种方式减少了编译器的工作量,加快了构建速度,为大规模的开发提供了便利。此外,包的导入循环是不允许的:由于 fmt 导入 io,io 就不能导入 fmt,也不能导入任何其他导入 fmt 的东西,即使是间接的。这也降低了编译器的工作量,确保了在单个单独编译的包的级别上对某个特定的构建进行拆分。这也使我们可以进行增量式的程序分析,即使在执行测试之前,我们也会执行这种分析来捕捉错误,如下所述。

导入 fmt 并不能使 io.Writer 这个名字对客户端可用。如果主包想使用 io.Writer 这个类型,那么它就必须为自己导入“io”。因此,一旦所有对 fmt 限定名称的引用被从源文件中删除——例如,如果 import "fmt" 调用被删除,import "fmt" 语句就可以安全地从源文件中删除,而无需进一步分析。这个属性使得自动管理源代码中的导入成为可能。事实上,Go 不允许未使用的导入,以避免将未使用的代码链接到程序中而造成的臃肿。

导入路径是带引号的字符串字面,这使其解释具有灵活性。斜线分隔的路径在导入时标识了 import 的包,但随后源代码会使用在包声明中声明的短标识符来引用该包。例如,import "net/http" 声明了顶层名称 http,提供对其内容的访问。在标准库之外,包由以域名开头的类似 URL 的路径来识别,如 import "github.com/google/uuid"。我们将在后面对这种包有更多的介绍。

作为最后一个细节,注意 fmt.Fprintf 和 io.Writer 这两个名字中的大写字母。Go 对 C++ 和 Java 的 public、private 和 protected 概念和关键字的模拟是一种命名惯例。带有大写字母的名字,如 Printf 和 Writer,是“导出的”(公共的)。其他的则不是。基于大小写的、编译器强制执行的导出规则适用于常量、函数和类型的包级标识符;方法名称;以及结构域名称。我们采用这一规则是为了避免在公共 API 中涉及的每一个标识符旁边都写上一个像 export 这样的关键字的语法负担。随着时间的推移,我们已经开始重视查看标识符是否在包之外可用或在其每一次使用时纯粹是内部的能力。

类型

Go 提供了一套常见的基本类型。布尔,大小整数,如 uint8 和 int32,非大小 int 和 uint(32 或 64 位,取决于机器大小),以及大小浮点数和复数。它提供了指针、固定大小的数组和结构,其方式类似于 C 语言。它还提供了一个内置的字符串类型,一个称为 map 的哈希表,以及称为 slices 的动态大小的数组。大多数 Go 程序都依赖于这些,而没有其他特殊的容器类型。

Go 不定义类,但允许将方法绑定到任何类型,包括结构体、数组、切片、映射,甚至是基本类型,如整数。它没有类型层次结构;我们认为继承性往往会使程序在成长过程中更难适应。相反,Go 鼓励类型的组合。

Go 通过其接口类型提供面向对象的多态性。就像 Java 接口或 C++ 的抽象虚拟类一样,Go 接口包含一个方法名称和签名的列表。例如,前面提到的 io.Writer 接口被定义在 io 包中,如图 2 所示。


图 2:io 包的 Writer 接口

Write 接收一段字节,然后返回一个整数和可能的错误。与 Java 和 C++ 不同的是,任何 Go 类型如果拥有与某个接口相同的名称和签名的方法,都可以被视为实现了该接口,而无需显式声明它是这样做的。例如,os.File 类型有一个签名相同的 Write 方法,因此它实现了 io.Writer,所以不需要像 Java 的“implements”注释那样的显式信号。

不要把这些接口当作一个复杂类型层次结构的基础块,而是要避免在接口和实现之间的显式关联,这样,Go 程序员就可以定义小型、灵活、通常是临时性的接口。它鼓励捕捉开发过程中出现的关系和操作,而不是需要提前计划和定义它们。这对大型程序尤其有帮助,因为在刚开始开发时,最终的结构是很难看清楚的。去除声明实现的簿记,鼓励使用精确的、只有一种或两种方法的接口,如 Writer、Reader、Stringer(类似于 Java 的 toString 方法)等,这些接口普遍存在于标准库中。

初次学习 Go 的开发人员常常担心一个类型会意外地实现一个接口。虽然构建假设很容易,但在实践中,不太可能为两个不兼容的操作选择相同的名称和签名,而且我们从未在实际的 Go 程序中看到过这种情况发生。

并发性

当我们开始设计 Go 的时候,多核计算机已经开始广泛使用,但线程在所有流行的语言和操作系统中仍然是一个重量级的概念。创建、使用和管理线程的难度使其不受欢迎,限制了对多核 CPU 全部功能的使用。解决这一矛盾是创建 Go 的主要动机之一。

Go 语言本身包含了多个并发控制线程的概念,称为 goroutines,在一个共享地址空间中运行,并有效地复用到操作系统线程上。对阻塞操作的调用,如从文件或网络中读取,只阻塞进行该操作的 goroutine;该线程上的其他 goroutine 可能会被移到另一个线程,以便在调用者被阻塞时继续执行。goroutine 开始时只有几千字节的堆栈,它可以根据需要调整大小,无需程序员参与。开发人员将 Goroutines 作为一种丰富的、廉价的结构化程序的原语。对于一个服务器程序来说,拥有数千甚至数百万个 goroutines 是很平常的,因为它们的成本要远低于线程。

例如,net.Listener 是一个带有 Accept 方法的接口,可以监听并返回新进入的网络连接。图 3 显示了一个接受连接的函数 listen,并为每个连接启动一个新的 goroutine 来运行服务函数。


图 3:一个 Go 的网络服务器。

listen 函数主体中的无限 for 循环(第 22-28 行)调用 listener.Accept,它返回两个值:连接和一个可能的错误。假设没有错误,go 语句(第 27 行)在一个新的 goroutine 中启动其参数——函数调用 serve(conn),类似于 Unix shell 命令的后缀 &,但在同一个操作系统进程中。要调用的函数及其参数在原 goroutine 中被评估;这些值被复制以创建新 goroutine 的初始堆栈框架。因此,程序为每个进入的网络连接运行一个独立的 serve 函数实例。对 serve 的调用一次处理一个给定连接上的请求(第 37 行对 handle(req) 的调用没有以 go 为前缀);每次调用都可以阻塞而不影响对其他网络连接的处理。

在幕后,Go 的实现使用了高效的复用操作,比如 Linux 的 epoll,它可以处理并发的 I/O 操作,但用户是看不到的。Go 的运行库呈现的是阻塞式 I/O 的抽象,其中每个 goroutine 都是按顺序执行的,无需回调,这很容易推理。

在创建了多个 goroutine 之后,一个程序必须经常在它们之间进行协调。Go 提供了通道,允许 goroutine 之间进行通信和同步:通道是一个单向的、尺寸有限的管道,在 goroutine 之间传输类型化的信息。Go 还提供了一个多向 select 原语,可以根据通信的进行来控制执行。这些想法改编自 Hoare 的 "通信顺序过程 "19 和早期的语言实验,特别是 Newsqueak、Alef 和 Limbo。

图 4 展示了另一个版本的 listen,它是为了限制任何时候的连接数量而写的。


图 4:一个 Go 网络服务器,限制为 10 个连接。

这个版本的 listen 首先创建了一个名为 ch 的通道(第 42 行),然后启动了一个由 10 个服务器 goroutines 组成的池(第 44-46 行),它们接收来自这个单一通道的连接。当新的连接被接收时,listen 使用 send 语句 ch < - conn(第 53 行)在 ch 上发送每个连接。一个服务器执行接收表达式 < - ch(第 59 行),完成通信。通道的创建没有空间来缓冲正在发送的值(Go 中的默认值),所以在 10 个服务器忙完前 10 个连接后,第 11 个 ch < - conn 将被阻塞,直到一个服务器完成对服务的调用并执行新的接收。被阻塞的通信操作对监听器产生了隐性的背压,阻止它接受一个新的连接,直到它放弃前一个连接。

请注意,在这些程序中缺乏互斥或其他传统的同步机制。在通道上进行的数据值通信可以作为同步的一部分;按照惯例,在通道上发送数据会将所有权从发送方传给接收方。Go 有提供互斥、条件变量、信号灯和原子值的库,供低级别的使用,但通道往往是更好的选择。根据我们的经验,人们对消息传递——利用通信在 goroutine 之间转移所有权——的推理比对互斥和条件变量的推理更容易、更正确。早期的口号是:“不要通过共享内存来交流,而是通过交流来共享内存”。

Go 的垃圾收集器大大简化了并发 API 的设计,消除了关于哪个 goroutine 负责释放共享数据的问题。与大多数语言一样(但与 Rust 不同),可变数据的所有权不由类型系统静态跟踪。相反,Go 集成了 TSAN,为测试和有限的生产使用提供了一个动态竞争检测器。

安全性

任何新语言的部分原因都是为了解决以前语言的缺陷,比如 Go,它涉及影响网络软件安全的安全问题。Go 删除了在 C 和 C++ 程序中造成许多安全问题的未定义行为。整数类型不会自动相互牵制。空指针取消引用和越界的数组和片索引会导致运行时异常。不存在指向栈框架的迷途指针。任何可能超出其栈框架的变量,例如在闭包中捕获的变量,将被移到堆中。在堆中也没有迷途指针;使用垃圾收集器而不是手动内存管理可以消除使用后的错误。当然,Go 并没有解决所有问题,有些东西被遗漏了,也许应该得到解决。例如,整数溢出可以被定义为运行时错误,而不是定义为绕过。

由于 Go 是一种用于编写系统的语言,它可能需要破坏类型安全的机器级操作,因此它能够将指针从一种类型胁迫到另一种类型,并执行地址运算,但只能通过使用 unsafe 包及其受限制的特殊类型 unsafe.Pointer。必须注意保持对类型系统的违反与垃圾收集器兼容——例如,垃圾收集器必须始终能够识别一个特定的字是一个整数还是一个指针。在实践中,unsafe 包很少出现:安全 Go 是相当有效的。因此,看到 import "unsafe" 是一个信号,可以让我们更仔细地检查源文件是否存在安全问题。

与 C、C++ 之类的语言相比,Go 的安全性更好,更适合用于加密和其他重要的安全代码。在 C 和 C++ 中,一个微小的错误,比如一个越界的数据索引,就会造成敏感数据的泄漏或者被远程执行,但是在 Go 中,它会造成运行时的异常,从而使程序停止,极大地限制了潜在的影响。Go 提供了一个完整的加密库套件,其中包含了 SSL/TLS 的支持;标准库包含 HTTPS 客户端和服务器,可用于生产环境。实际上,Go 的安全性、性能和高品质库的结合使它成为了一个现代安全工作的热门试验场。比如,Let's Encrypt 是一家免费提供证书的机构,它依靠 Go 来提供生产服务,并在最近跨越了一个里程碑:签发了 10 亿份证书。

完整性

Go 在语言、库和工具层面提供了现代开发所需的核心部分。这就需要小心翼翼地平衡,既要增加足够多的“开箱即用”功能,又不能增加太多,以至于我们自己的开发过程因为要支持太多的功能而陷入困境。

该语言提供了字符串、哈希图和动态大小的数组作为内置的、易于使用的数据类型。如前所述,这些对于大多数 Go 程序来说已经足够了。其结果是 Go 程序之间有了更大的互操作性——例如,没有竞争性的字符串或哈希图的实现来分割包的生态系统。Go 包含的 goroutines 和 channel 是另一种形式的完整性。这些功能提供了现代网络程序中所需要的核心并发功能。直接在语言中提供这些功能,而不是在库中提供,这样可以更容易地调整语法、语义和实现,使其尽可能地轻量和易于使用,同时为所有用户提供统一的方法。

该标准库包括一个生产就绪的 HTTPS 客户端和服务器。对于在互联网上与其他机器互动的程序来说,这一点至关重要。直接满足这一需求可以避免额外的碎片化。我们已经看到了 io.Writer 接口;任何输出数据流都按惯例实现了这个接口,并与所有其他 I/O 适配器进行互操作。图 1 的 ListenAndServe 调用,作为另一个示例,期望有一个 http.Handler 类型的第二个参数,其定义如图 5 所示。参数 http.HandlerFunc(hello) 通过调用 hello 实现其 ServeHTTP 方法。该库创建了一个新的 goroutine 来处理每个连接,就像本文“并发性”部分中的监听器示例一样,所以处理程序可以用简单的阻塞风格来编写,服务器可以自动扩展以处理许多同步连接。


图 5:net/http 包的处理程序接口

http 包还提供了一个基本的调度器,它本身就是 Handler 的另一种实现,它允许为不同的 URL 路径注册不同的处理程序。将 Handler 确立为约定俗成的接口,使得许多不同类型的 HTTP 服务器中间件能够被创建并相互操作。我们不需要将所有这些实现添加到标准库中,但我们确实需要建立一个允许它们一起工作的接口。

标准 Go 发行版还提供了对交叉编译、测试、剖析、代码覆盖率、模糊处理等的集成支持。测试是另一个领域,在这个领域中,建立关于核心概念的协议——例如什么是测试用例以及如何运行——使得创建的自定义测试库和测试执行环境都能很好地互操作。

一致性

我们对 Go 的一个目标是让它在不同的实现、执行环境中,甚至在不同的时间内表现出相同的行为。这种“无聊”的一致性行为使开发人员能够专注于他们的日常工作,并使 Go 隐退到后台。

首先,语言尽可能地规定了一致的结果,即使是错误的行为,如空指针解除引用和越界数组索引,正如本文的“安全性”部分所讨论的。Go 需要不一致行为的一个例外是对哈希图的迭代。我们发现,程序员经常不经意地写下依赖于哈希函数的代码,导致在不同的架构或 Go 实现上出现不同的结果。

为了使程序在任何地方都有相同的表现,一种选择是强制规定一个特定的哈希函数。相反,Go 定义了映射迭代是非确定的。该实现为每个映射使用不同的随机种子,并从哈希表中的一个随机偏移量开始对映射进行每次迭代。其结果是,映射在不同的实现中都是不可预知的。代码不能意外地依赖于实现细节。与此类似,竞争检测器为调度决策增加了额外的随机性,创造了更多的机会来观察竞争。

一致性的另一个方面是在程序的生命周期内的性能。使用传统的编译器而不是 Java 和 Node.js 等语言使用的 JIT 来实现 Go 的决策,在启动时和短期程序中提供了一致的性能。不存在“慢速启动”来惩罚每个进程生命周期的前几秒。对于命令行工具和规模较大的网络服务器(如 Google App Engine)来说,这种快速的启动使 Go 成为一个有吸引力的目标。

一致性的性能包括垃圾收集的开销。最初的 Go 原型使用了一个基本的、即停即用的垃圾收集器,当然,它在网络服务器中引入了明显的尾部延时。今天,Go 使用了一个完全并发的垃圾收集器,暂停时间不到一毫秒,通常仅为几微秒,与堆的大小无关。最主要的延迟是操作系统向必须中断的线程传递信号所需的时间。

最后一种一致性是语言和库随着时间的推移而产生的一致性。在 Go 诞生的前几年,我们在每周的发布中都会对它进行修补和调整。用户在更新到新的 Go 版本时,往往不得不改变他们的程序。自动化的工具减轻了负担,但手动调整也是必要的。从 2012 年发布的 Go 版本开始,我们公开承诺只对语言和标准库进行向后兼容的修改,这样程序在编译到较新的 Go 版本时,可以在不改变的情况下继续运行。这一承诺吸引了业界,不仅鼓励了长期的工程项目,也鼓励了其他努力,如书籍、培训课程和第三方软件包的繁荣生态系统。

工具辅助开发

大规模的软件开发需要大量的自动化和工具化。从一开始,Go 的设计就是为了鼓励这种工具化,使其易于创建。

开发人员对 Go 的日常体验是通过 go 命令进行的。与只编译或运行代码的语言命令不同,go 命令为开发周期的所有关键部分提供了子命令:go build 和 go install 构建和安装可执行文件,go test 运行测试用例,go get 添加新的依赖。go 命令还提供了对构建细节的编程访问,例如软件包图,从而实现了新工具的创建。

其中一个工具是 go vet,它可以执行增量的、每次打包的程序分析,可以像缓存编译的对象文件那样缓存,实现增量构建。go vet 工具的目的是高精度地识别常见的正确性问题,这样开发人员就有条件按照它的报告进行处理。简单的例子包括在调用 fmt.Printf 和相关函数时检查格式和参数是否匹配,或者诊断对变量或结构域的未使用的写入。这些不是编译器错误,因为我们不希望仅仅因为发现了一个新的可能的错误就停止编译旧代码。它们也不是编译器警告;用户要学会忽略这些。将这些检查放在一个单独的工具中,可以让它们在开发人员方便的时候运行,而不干扰普通的构建过程。这也使得所有的开发人员都可以使用同样的检查,即使是在使用 Go 编译器的另一种实现,如 Gccgo15 或 Gollvm17。增量方法使得这些静态检查足够有效,我们在 go test 期间自动运行它们,然后再运行测试本身。无论如何,测试是用户寻找错误的时候,而报告往往有助于解释实际的测试失败。这个增量框架也可以被其他工具重用。

分析程序的工具非常有用,但编辑程序的工具就更好了,特别是对于程序的维护方面,很多都是枯燥乏味的,并且已经是成熟的自动化。

Go 程序的标准布局是通过算法定义的。一个名为 gofmt 的工具将源文件解析为抽象的语法树,然后使用一致的布局规则将其格式化为源代码。在 Go 中,在将代码存储到源控制中之前将其格式化被认为是一种最佳做法。这种方法使数以千计的开发人员能够在一个共享的代码库中工作,而不需要经常为大括号样式和其他细节进行争论,这些都是伴随着这种大型工作的。更重要的是,工具可以通过对抽象语法形式的操作来修改 Go 程序,然后用 gofmt 的打印机写出结果。只有实际改变的部分才会被触及,产生的“差异”与人们的手写结果是一致的。人们和程序可以在同一个代码库中无缝协作。

为了实现这种方法,Go 的语法被设计为能够在没有类型信息或任何其他外部输入的情况下就可以对源文件进行解析,并且不需要预处理器或其他宏系统。Go 标准库提供了一些包,允许工具重新创建 gofmt 的输入和输出端,同时还有一个完整的类型检查器。

在发布 Go 第 1 版(第一个稳定的 Go 版本)之前,我们编写了一个叫做 gofix 的重构工具,它使用这些包来解析源代码,重写树,并写出格式良好的代码。例如,当从映射中删除一个条目的语法被改变时,我们就使用了 gofix。每次用户更新到一个新版本时,他们可以在他们的源文件上运行 gofix,自动应用更新到新版本所需的大部分变化。

这些技术也适用于 IDE 插件和其他支持 Go 程序员的工具——过滤器、调试器、分析器、构建自动程序、测试框架等等的构建。Go 的常规语法、既定的算法代码布局惯例以及对标准库的直接支持,使得这些工具的构建比其他方式要容易得多。因此,Go 世界拥有一个丰富的、不断扩展的、可互操作的工具包。



在语言和工具之后,用户如何体验 Go 的下一个关键方面是可用的库。作为一种分布式计算的语言,Go 中没有必须发布 Go 软件包的中央服务器。相反,每个以域名开始的导入路径都被解释为一个 URL(有一个隐含的前导 https://),提供远程源代码的位置。例如,import "github.com/google/uuid" 可以获取托管在相应的 GitHub 仓库的代码。

托管源代码最常见的方式是指向公共的 Git 或 Mercurial 服务器,但私人服务器也同样得到了很好的支持,作者可以选择发布一个静态的文件包,而不是开放对源控制系统的访问。这种灵活的设计和发布库的便利性创造了一个繁荣的可导入 Go 包的社区。依靠域名,避免了在扁平的包名称空间中急于声明有价值的条目。

仅仅下载软件包是不够的,我们还必须知道要使用哪些版本。Go 将包分组为称为模块的版本单位。一个模块可以为它的一个依赖关系指定一个最低要求的版本,但没有其他限制。当构建一个特定的程序时,Go 通过选择最大版本来解决竞争的依赖模块的所需版本。如果程序的一部分需要某个依赖模块的 1.2.0 版本,而另一部分需要 1.3.0 版本,Go 会选择 1.3.0 版本——也就是说,Go 要求使用语义版本划分,其中 1.3.0 版本必须是 1.2.0 的直接替换。另一方面,在这种情况下,即使 1.4.0 版本可用,Go 也不会选择它,因为程序中没有任何部分明确要求使用该较新的版本。这个规则保持了构建的可重复性,并最大限度地减少了因意外破坏新版本所引入的变化而造成的潜在风险。

在语义版本管理中,一个模块只能在一个新的主要版本中引入有意的破坏性变化,比如 2.0.0。在 Go 中,从 2.0.0 开始的每个主要版本在其导入路径中都有一个主要版本后缀,比如 /v2。不同的主版本和其他不同名字的模块一样被分开。这种方法不允许出现钻石依赖性问题,而且在实践中,它可以适应不兼容的情况,也可以适应具有更精细约束的系统。

为了提高从互联网上下载软件包的构建的可靠性和可重现性,我们在 Go 工具链中运行了两个默认使用的服务:一个是可用 Go 软件包的公共镜像,一个是加密签名的预期内容的透明日志。即使如此,广泛使用从互联网上下载的软件包仍然存在安全和其他风险。我们正在努力使 Go 工具链能够主动识别并向用户报告脆弱的软件包。

小结

虽然大多数语言的设计都集中在语法、语义或类型的创新上,但 Go 的重点是软件开发过程本身。Go 语言高效、易学、免费,但我们相信,它的成功之处在于它所采取的编写程序的方式,特别是多个程序员在一个共享代码库上工作时。该语言本身的一个重要属性,即并发性,解决了 2010 年代随着多核 CPU 的大量使用而出现的问题。但更重要的是,早期的工作为打包、依赖关系、构建、测试、部署和软件开发领域的其他工作任务奠定了基础,这些方面通常在语言设计中并不重要。

这些想法吸引了志同道合的开发人员,他们重视的结果是:容易并发、明确的依赖关系、可扩展的开发和生产、安全的程序、简单的部署、自动代码格式化、工具辅助开发等等。这些早期的开发人员帮助普及了 Go,并播种了最初的 Go 包生态系统。他们还推动了该语言的早期发展,例如,将编译器和库移植到 Windows 和其他操作系统上(最初的版本只支持 Linux 和 MacOS X)。

并非所有的人都会喜欢——比如,有些人反对该语言省略了继承和泛型等常见功能。但是 Go 的开发导向的理念足够吸引人,也足够有效,以至于社区在保持最初推动 Go 存在的核心原则的同时,也得到了蓬勃发展。在很大程度上,多亏了这个社区和它所建立的技术,Go 如今已成为现代云计算环境的一个重要组成部分。

自 Go 第一版发布以来,该语言几乎被冻结。然而,工具已经大大扩展,有了更好的编译器,更强大的构建和测试工具,以及改进的依赖性管理,更不用说支持 Go 的大量开源工具了。然而,变化正在到来。2022 年 3 月发布的 Go 1.18,它包含了对语言的真正改变的第一个版本,一个被广泛要求的改变:首次实现了参数化多态。我们将任何形式的泛型排除在原始语言之外,因为我们敏锐地意识到,它很难设计好,而且在其他语言中,往往是复杂性而不是生产力的来源。在 Go 的第一个十年中,我们考虑了很多设计,但直到最近才找到一个我们认为很适合 Go 的设计。在坚持一致性、完整性和社区原则的前提下进行如此大的语言变革,对于这种方式来说,将是一个巨大的挑战。

本文作者为:Russ Cox、Robert Griesemer、Rob Pike、Ian Lance Taylor、Ken Thompson,均为谷歌公司软件工程师,参与了 Go 项目。Rob Pike、Ken Thompson 已退休。