
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 可能会被移到另一个线程