Go 是谷歌于 2007 年底创建的一种编程语言,并于 2009 年 11 月作为开源发布。从那时起,Go 作为一个公共项目运作,有成千上万的个人和数十家公司的贡献。Go 已经成为构建云计算基础设施的流行语言:Linux 容器管理器和容器部署系统是 Go 开发的核心云计算技术。今天,Go 是主要云计算提供商的关键基础设施的基础,也是 Cloud 托管的大多数项目的实现语言。
早期采用者对 Go 感兴趣的原因有很多。用于构建系统的垃圾收集、静态编译的语言是不寻常的。Go 提供的对并行性和并发性的原生支持使其能够充分利用当时成为主流的多核机器。包含的二进制文件和简单的交叉编译使部署更容易。当然,谷歌这个名字也是一大亮点。
但为什么用户会留下来?为什么在许多其他语言项目还没有开发出来的情况下,Go 变得如此流行?我们相信语言本身只是答案的一小部分。一个完整的故事应该包括整个 Go 环境:库、工具、约定和支持该语言编程的软件工程的整体方法。因此,在语言设计方面,最关键的决定是让 Go 更适合大型软件项目,并吸引志同道合的开发人员。
在本文中,我们将回顾那些我们认为对 Go 的成功负有最大责任的设计决策,并探讨这些设计决策如何不仅适用于语言,而且适用于更广泛的上下文。具体决策中的贡献很难区分,因此本文不应被视为科学分析,而应视为对十多年围棋经验和用户反馈的最佳诠释。
起源
Go 诞生于谷歌构建大规模分布式系统时,它在由数千名软件工程师共享的大型代码库中工作。我们希望为这种环境设计的语言和工具能够应对公司和整个行业面临的挑战。随着开发工作的进展和生产系统的大量部署,这些提出了一些挑战。
发展规模。在开发方面,谷歌在 2007 年有大约 4,000 名活跃用户,他们使用单一、共享的多语言(C++、Java、)代码库。单个代码库可以轻松修复,例如,内存分配器中可能降低主 Web 服务器速度的问题。但是在使用库时,很容易在不知不觉中破坏以前未知的客户端,因为很难找到包的所有依赖项。
另外,在我们使用的现有语言中,导入一个库可能会导致编译器递归地加载所有导入的库。在 2007 年的一次 C++ 编译中,我们观察到在传递一组总计 4.2MB 的文件时(经过#处理),编译器读取了超过 8GB 的数据,在一个已经很大的程序上,扩展因子几乎是 2000。如果为编译给定源文件而读取的头文件随源树线性增长,然后编译整个源树的成本呈二次方增长。
为了弥补减速,我们开始研究一个新的、大规模并行和可缓存的构建系统,最终成为开源的 Bazel 构建系统。我们认为只有语言是不够的。
生产规模。在生产方面,谷歌运行着非常大的系统。例如,2005年3月,日志分析系统的1500个CPU集群处理了2.8PB的数据。2006 年 8 月, 的 388 大表服务集群由 24,500 台单独的服务器组成,一组 8,069 台服务器每秒处理 120 万个请求。
然而,与业内其他公司一样,谷歌正在努力编写高效的程序以充分利用多核系统。由于现有多线程支持的繁琐和低性能,我们的许多系统必须在单台机器上运行相同二进制文件的多个副本。大型、固定大小的线程堆栈、重量级堆栈切换以及用于创建新线程和管理它们之间交互的笨拙语法都使得使用多核系统变得更加困难。但显然,在服务器中,核心数量只会增加。
我们还相信语言本身提供了易于使用、轻量级的并发原语。我们还在这些额外的内核中看到了一个机会:垃圾收集器可以在与主程序并行的专用内核上运行,这可以减少其延迟。
我们想知道旨在应对这些挑战的语言会是什么样子,答案就是 Go。Go 的流行部分是由于所有科技行业都面临的挑战。云计算提供商使即使是最小的企业也可以针对大规模生产部署。虽然大多数公司没有数千名员工编写代码,但如今几乎每家公司都依赖于由数千名程序员完成的大规模开源基础设施。
本文的其余部分将探讨具体的设计决策如何解决这些开发和生产扩展目标。我们从核心语言本身开始,向外扩展至周围环境。我们不会全面介绍该语言。为此,请参阅 Go 语言规范或 Go 编程语言 (The Go) 等书籍。
包
Go 程序由一个或多个可导入包组成,每个包包含一个或多个文件。图 1 中的 Web 服务器显示了有关 Go 包系统设计的许多重要细节:
图 1:Go 网络服务器
该程序启动一个本地 Web 服务器(第 9 行),它通过调用 hello 函数来处理每个请求,该函数以消息“hello, world”(第 14 行)进行响应。
与许多语言一样,一个包使用显式语句(第 3-6 行)导入另一个包,但与 C++ 的文本 # 机制不同。然而,与大多数语言不同的是,Go 安排每个人只读取一个文件。比如fmt包的 API是指io包的类型:fmt的第一个参数。是 io 类型的接口值。在大多数语言中,处理 fmt 的编译器也会加载所有 io 来理解 fmt 的定义,这反过来可能需要加载额外的包来理解所有 io 的定义。一条语句最终可能会处理数十个甚至数百个数据包。
Go 以与 -2 类似的方式避免了这项工作,通过将编译后的 fmt 包的元数据与需要了解其自身依赖关系的所有内容包括在内,例如 io 的定义。因此,“fmt”的编译只读取一个完整描述fmt及其依赖关系的文件。此外,这种扁平化可以在编译 fmt 时一次性实现,从而避免每次导入时多次加载。这样就减少了编译器的工作量,加快了构建速度,便于大规模开发。此外,包导入周期是不允许的:因为 fmt 导入 io,io 不能导入 fmt,也不能导入任何其他导入 fmt 的东西,即使是间接的。这也减少了编译器的工作量,确保在单个单独编译的包的级别上拆分特定的构建。
导入 fmt 不会生成 io. 客户可用的名称。如果主包要使用 io. 类型,那么它必须为自己导入“io”。因此,一旦从源文件中删除了对 fmt 限定名称的所有引用——例如,如果删除了“fmt”调用,则可以安全地从源文件中删除“fmt”语句而无需进一步分析。此属性使自动管理源代码中的导入成为可能。事实上,Go 不允许未使用的导入,以避免将未使用的代码链接到程序中导致的膨胀。
导入路径是带引号的字符串文字,可以灵活地解释它们。斜杠分隔的路径在导入时标识包,但随后源代码使用包声明中声明的短标识符引用包。例如,“net/http”声明了顶级名称 http,提供对其内容的访问。在标准库之外,包由以域名开头的类似 URL 的路径标识,例如“//uuid”。稍后我们将详细讨论这个包。
作为最后一个细节,请注意名称 fmt 中的大写字母。和 io。Go 对 C++ 和 Java 的模拟,概念和关键字是一种命名约定。带有大写字母的名称,例如 和 ,是“导出的”(公共的)。其他人不是。基于案例、编译器强制执行的导出规则适用于常量、函数和类型的包级标识符;方法名称;和结构字段名称。我们采用此规则是为了避免在公共 API 中涉及的每个标识符旁边编写这样的关键字的语法负担。随着时间的推移,我们开始重视每次使用时查看标识符是在包外部可用还是纯粹在内部可用的能力。
类型
Go 提供了一组通用的原始类型。布尔值、大小整数,例如 uint8 和 int32、无大小的 int 和 uint(32 位或 64 位,取决于机器大小),以及大小浮点数和复数。它以类似于 C 语言的方式提供指针、固定大小的数组和结构。它还提供了一个内置的字符串类型、一个名为 map 的哈希表和一个名为 . 大多数 Go 程序都依赖于这些,而没有其他特殊的容器类型。
Go 不定义类,但允许将方法绑定到任何类型,包括结构、数组、切片、映射,甚至是整数等原始类型。它没有类型层次结构;我们认为继承往往会使程序在成长过程中更难适应。相反,Go 鼓励类型的组合。
Go 通过其接口类型提供了面向对象的多态性。就像 Java 接口或 C++ 抽象虚拟类一样,Go 接口包含方法名称和签名的列表。比如前面提到的 io. 接口定义在io包中,如图2所示。
图2:io包界面
写入需要一个字节块并返回一个整数和可能的错误。与 Java 和 C++ 不同,任何具有与接口相同名称和签名的方法的 Go 类型都可以被认为实现了该接口,而无需明确声明它确实如此。例如,os.File 类型有一个具有相同签名的 Write 方法,因此它实现了 io.,因此不需要像 Java 的 “” 注释这样的显式信号。
不要将这些接口视为复杂类型层次结构的构建块,而是避免接口和实现之间的显式关联,以便 Go 程序员可以定义小型、灵活且通常是 ad hoc 接口。它鼓励捕获在开发过程中出现的关系和操作,而不是需要提前计划和定义它们。这对于大型程序特别有用,在这些程序中,在第一次开发时很难看到最终结构。删除声明实现的簿记,并鼓励使用标准库中普遍存在的精确的、一或二方法接口,如 、 、 、 (类似于 Java 的方法)等。
第一次学习 Go 的开发人员经常担心类型意外实现了接口。虽然很容易建立假设,但实际上不太可能为两个不兼容的操作选择相同的名称和签名,而且我们从未在实际的 Go 程序中看到过这种情况。
并发
当我们开始设计 Go 时,多核计算机已经被广泛使用,但线程仍然是所有流行语言和操作系统中的重量级概念。创建、使用和管理线程的难度使其不受欢迎,限制了多核 CPU 的全部功能的使用。解决这个矛盾是创建 Go 的主要动机之一。
Go 语言本身包含多个并发控制线程的概念,称为线程,在共享地址空间中运行并有效地多路复用到操作系统线程上。对阻塞操作的调用,例如从文件或网络中读取,只会阻塞执行该操作的人;当调用者被阻塞时,该线程上的其他人可能会被移动到另一个线程以继续执行。从只有几千字节的堆栈开始,它可以根据需要调整大小,而无需程序员参与。开发人员将充当丰富、廉价的结构化程序的原语。服务器程序拥有数千甚至数百万个线程并不少见,因为它们的成本远低于线程。
例如,净。是一个带有侦听和返回传入网络连接的方法的接口。图 3 显示了一个接受连接并为每个连接启动一个新连接以运行服务功能的函数。
图 3:Go 网络服务器。
函数体中的无限 for 循环(第 22-28 行)调用 .,它返回两个值:连接和可能的错误。假设没有错误此网络正在阻止加密的dns流量,go 语句(第 27 行)在新的函数调用 serve(conn) 中开始其参数,类似于 Unix shell 命令的 & 后缀,但在同一操作系统进程中。要调用的函数及其参数在原始值中进行评估;复制这些值以创建新的初始堆栈帧。因此,该程序为每个传入的网络连接运行一个单独的 serve 函数实例。对服务的调用一次处理给定连接上的请求(第 37 行对 (req) 的调用没有以 go 为前缀);每个调用都可以阻塞而不影响其他网络连接的处理。
在幕后,Go 的实现使用了高效的多路复用操作,例如 Linux 的 epoll,它可以处理用户不可见的并发 I/O 操作。Go 的运行时呈现了阻塞 I/O 的抽象,其中每个都按顺序执行而没有回调,这很容易推理。
在创建了多个之后,一个程序必须经常在它们之间进行协调。Go 提供了允许彼此之间进行通信和同步的通道:通道是一种单向、有限大小的管道,用于在它们之间传输类型化信息。Go 还提供了一个多向原语,可以根据通信的进度控制执行。这些想法改编自 Hoare 的“ of ”19 以及早期的语言实验此网络正在阻止加密的dns流量,特别是 Alef 和 Limbo。
图 4 显示了另一个版本,该版本旨在限制任何时候的连接数。
图 4:Go Web 服务器,限制为 10 个连接。
这个版本首先创建了一个名为 ch 的通道(第 42 行),然后启动了一个由 10 个服务器组成的池(第 44-46 行),这些服务器从这个单一通道接收连接。当接收到新连接时,使用发送语句 ch <- conn(第 53 行)在 ch 上发送每个连接。服务器执行接收表达式 <- ch(第 59 行),完成通信。通道的创建没有空间来缓冲正在发送的值(Go 中的默认值),因此在 10 个服务器忙于前 10 个连接后,第 11 个 ch <-conn 将阻塞,直到一个服务器完成服务调用并执行新的接收。阻塞的通信操作会对侦听器产生隐式背压,阻止它接受新连接,直到它放弃前一个连接。
请注意,这些程序中缺少互斥或其他传统同步机制。通过通道进行数据值的通信可以作为同步的一部分进行;按照惯例,通过通道发送数据会将所有权从发送方转移到接收方。Go 有提供互斥体、条件变量、信号量和原子值的库以供低级使用,但通道通常是更好的选择。根据我们的经验,人们对消息传递的推理——使用通信在他们之间转移所有权——比对互斥锁和条件变量的推理更容易、更正确。早期的口号是:“不要通过共享内存进行通信,通过通信来共享内存”。
Go 的垃圾收集器极大地简化了并发 API 的设计,消除了关于谁负责释放共享数据的问题。与大多数语言一样(但与 Rust 不同),类型系统不会静态跟踪可变数据的所有权。相反,Go 集成了 TSAN 以提供用于测试和有限生产用途的动态竞争检测器。
安全
出现任何新语言的部分原因是为了解决以前语言的缺陷,比如 Go,这些缺陷涉及影响网络上软件安全的安全问题。Go 删除了在 C 和 C++ 程序中导致许多安全问题的未定义行为。整数类型不会自动相互固定。空指针取消引用和越界数组和切片索引会导致运行时异常。没有指向堆栈帧的杂散指针。任何可能超出其堆栈帧的变量,例如在闭包中捕获的变量,都将被移动到堆中。堆中也没有杂散指针;使用垃圾收集器代替手动内存管理可以消除使用后错误。当然,Go 并不能解决所有问题,缺少一些应该修复的东西。例如,
由于 Go 是一种用于编写可能需要破坏类型安全的机器级操作的系统的语言,因此它能够将指针从一种类型强制转换为另一种类型,并执行地址算术,但只能使用包及其受限的特殊类型。必须采取措施来保持与垃圾收集器兼容的类型系统的冲突——例如,垃圾收集器必须始终能够识别特定单词是整数还是指针。在实践中,包很少出现:Safe Go 相当有效。所以看到“”是一个信号,可以让我们更仔细地检查源文件是否存在安全问题。
与 C、C++ 等语言相比,Go 更安全,更适合密码学和其他重要的安全代码。在 C 和 C++ 中,一个微不足道的错误,例如数据索引越界,可能会导致敏感数据泄露或远程执行,但在 Go 中会导致运行时异常导致程序停止,极大的潜在影响是有限的. Go 提供了一套完整的加密库,包括对 SSL/TLS 的支持;标准库包括可在生产环境中使用的 HTTPS 客户端和服务器。事实上,Go 结合了安全性、性能和高质量的库此网络正在阻止加密的dns流量
Go的成功负有最大责任的设计决策如何不仅适用于语言
,使其成为现代安全工作的流行试验场。例如,依赖 Go 进行生产服务的免费证书颁发机构 Let’s 最近跨越了一个里程碑:颁发了 10 亿个证书。
正直
Go 在语言、库和工具级别提供了现代开发所需的核心部分。这需要谨慎地平衡添加足够的“开箱即用”功能,而不会添加太多以至于我们自己的开发过程因需要支持的功能太多而陷入困境。
该语言提供字符串、哈希映射和动态大小的数组作为内置的、易于使用的数据类型。如前所述,这些对于大多数 Go 程序来说已经足够了。结果是 Go 程序之间的互操作性更高 – 例如,没有字符串或哈希映射的竞争实现来划分包的生态系统。Go 包括作为完整性的另一种形式的总和。这些功能提供了现代网络程序所需的核心并发能力。直接在语言中而不是在库中提供这些功能,可以更轻松地调整语法、语义和实现,使其尽可能轻量级和易于使用,同时为所有用户提供统一的方法。
标准库包括一个生产就绪的 HTTPS 客户端和服务器。这对于与 上的其他机器交互的程序至关重要。直接解决这一需求可以避免额外的碎片化。我们已经看到了 io。界面; 任何输出数据流都按约定实现此接口,并与所有其他 I/O 适配器互操作。作为另一个示例,图 1 中的调用需要 http 类型的第二个参数。其定义如图5所示。参数http.(hello)通过调用hello实现其方法。该库创建一个新的来处理每个连接,就像本文“并发”部分中的侦听器示例一样,因此可以以简单的阻塞样式编写处理程序,并且服务器可以自动扩展以处理许多同时连接的连接。
暂无评论内容