云原生 Go——为什么 Go 主导了云原生世界

960 阅读19分钟

任何聪明的傻瓜都能让事物变得更大、更复杂、更暴力。而要朝着相反的方向发展,既需要一些天才,也需要大量的勇气。
——E. F. 施马赫,《小即是美》(1973年8月)

Go语言背后的动机

Go语言的构思出现在2007年9月,在谷歌,这一切几乎是不可避免的结果,当一群聪明的人被困在同一个房间里,他们被让人沮丧的情况逼到了极限。

这些人是罗伯特·格里泽梅尔、罗布·派克和肯·汤普森,他们在设计其他编程语言方面都已享有很高的声誉。他们的集体不满来源于当时所有可用的编程语言,发现它们根本不适合描述谷歌正在构建的那种分布式、可扩展、弹性的服务。

本质上,那些当时流行的编程语言是在一个不同的时代开发的,在那时,多个处理器还不常见,网络也不像今天这样普及。这些语言对多核处理和网络的支持——现代云原生服务的基本构建块——要么支持有限,要么需要额外的努力才能使用。简单来说,编程语言并没有跟上现代软件开发的需求。

云原生世界的特性

他们的挫败感很多,但归根结底,都集中在一点:他们所使用的语言过于复杂,导致构建服务器软件变得更加困难。这些问题包括但不限于:

  • 程序可读性差
    代码变得越来越难以理解。多余的记账和重复代码,再加上功能上重叠的特性,往往鼓励聪明而非清晰的写法。
  • 构建速度慢
    语言的构造和多年的功能膨胀,导致即使在大型构建集群上,构建时间也会持续数分钟甚至数小时。
  • 效率低下
    许多程序员回应上述问题,通过采用更灵活、更动态的语言,实际上是牺牲了效率和类型安全,换取了表现力。
  • 更新成本高
    即使是语言的次要版本之间的不兼容,以及它所依赖的任何依赖项(及其传递依赖!),也往往让更新变成一项让人沮丧的任务。

多年来,许多解决方案(通常非常聪明)被提出,用不同的方式解决这些问题,通常在此过程中引入了更多的复杂性。显然,这些问题无法仅仅通过新的API或语言特性来解决。因此,Go的设计者们设想了一种现代语言,首个为云原生时代打造的语言,支持现代的网络和多核计算,既具表现力又易于理解,让用户专注于解决问题,而不是与语言本身作斗争。

最终,Go语言以其显著的特性和不具备的特性而著称。接下来的章节将讨论一些特性(和非特性)以及它们背后的动机。

组合与结构类型

面向对象编程(OOP)基于“对象”这一概念,其中“对象”有各种“类型”,并且每种类型拥有不同的属性。自1960年代以来,OOP一直存在,但它在1990年代初期至中期随着Java的发布和C++引入面向对象特性而真正流行开来。从那时起,OOP成为了主流的编程范式,并且至今依然占据主导地位。

OOP的承诺具有吸引力,其背后的理论也在某种程度上具有直观的合理性。数据和行为可以与事物的类型相关联,这些类型可以被其子类型继承。这些类型的实例可以被概念化为具有属性和行为的具体对象——它们是一个更大系统的组成部分,用于建模具体的、现实世界的概念。

然而,在实践中,使用继承的OOP通常需要仔细考虑和精心设计类型之间的关系,并且必须忠实地遵循特定的设计模式和实践。因此,如图2-1所示,OOP的倾向是将焦点从开发算法转移到开发和维护分类法和本体论上。

image.png

这并不是说Go没有面向对象的特性,它同样支持多态行为和代码复用。Go也有类似于类型的概念,使用结构体(structs),这些结构体可以具有属性和行为。它所拒绝的是继承以及与之相关的复杂关系,而是选择通过将更简单的类型嵌套在一起,来组装更复杂的类型:这种方法被称为组合。

具体来说,继承通常围绕扩展“是一个”("is a")关系展开(例如,一辆车“是一个”机动车),而组合则通过使用“拥有一个”("has a")关系来构造类型,以定义它们能做什么(例如,一辆车“拥有一个”发动机)。在实践中,这种方法允许更大的设计灵活性,同时创建的业务领域也更不容易受到“家族成员”特性干扰的影响。

进一步说,虽然Go使用接口来描述行为契约,但它没有“是一个”概念,因此等价性是通过检查类型的定义来确定的,而不是通过其继承关系。例如,给定一个定义了Area方法的Shape接口,任何具有Area方法的类型都会隐式地满足Shape接口,而无需明确声明自己是一个Shape

type Shape interface {                // 任何Shape都必须有一个Area方法
    Area() float64
}

type Rectangle struct {               // Rectangle没有明确声明自己是Shape
    width, height float64             
}

func (r Rectangle) Area() float64 {   // Rectangle有一个Area方法;它
    return r.width * r.height         // 满足Shape接口
}

这种结构类型机制被描述为“编译时的鸭子类型”(duck typing at compile time),在很大程度上摆脱了像Java和C++等传统面向对象语言中繁琐的分类法维护负担,让程序员可以更多地关注数据结构和算法的设计。

可理解性

像C++和Java这样的语言常常因笨拙、使用困难和冗长而受到批评。它们需要大量的重复和细致的记账,使得项目中充斥着多余的样板代码,这些代码会分散程序员的注意力,迫使他们把焦点从需要解决的问题转移到其他地方,从而在所有这些复杂性下限制了项目的可扩展性。

Go语言的设计是考虑到大型项目和众多贡献者的需求。它的极简设计(仅有25个关键词和1种循环类型)以及编译器的强烈意见,极力推崇清晰而非巧妙的写法。这反过来鼓励简洁和高效的开发,而非杂乱和复杂。由此产生的代码相对容易理解、审查和维护,并且出现“陷阱”的几率远低于其他语言。

CSP风格并发

大多数主流语言都提供了一些手段来并发地运行多个进程,使得程序可以由独立执行的任务组成。正确使用并发非常有用,但它也带来了一些挑战,尤其是在事件排序、进程间通信和共享资源访问协调方面。

传统上,程序员会通过让进程共享某块内存来应对这些挑战,然后通过锁或互斥锁(mutexes)将其封装起来,以便一次只允许一个进程访问。但即使这种策略实现得很好,它也可能产生相当大的记账开销。此外,程序员也容易忘记锁定或解锁共享内存,从而引发竞争条件、死锁或并发修改。这类错误往往非常难以调试。

另一方面,Go语言采用了另一种策略,基于一种名为通信顺序进程(CSP)的正式语言,这一概念最早在Tony Hoare的同名论文中描述,该论文阐述了在并发系统中通过通道传递消息的交互模式。

这一并发模型,在Go中通过goroutines和通道等语言原语实现,使得Go能够优雅地构建并发软件,而不完全依赖于锁。它鼓励开发者限制共享内存,而是通过传递消息的方式让进程之间进行交互。这个理念常常用以下Go谚语来总结:

不要通过共享内存来通信。相反,通过通信来共享内存。

并发不是并行

计算并发和并行常常被混淆,这可以理解,因为这两个概念都描述了在同一时间段内执行多个进程的状态。然而,它们绝对不是相同的概念:

  • 并行性
    描述多个独立进程的同时执行。
  • 并发性
    描述独立执行进程的组合;它并不涉及进程何时执行。

快速构建

Go语言的主要动机之一是当时某些语言的构建时间极其漫长,甚至在谷歌的大型编译集群上,构建时间往往需要几分钟,甚至几小时才能完成。这严重浪费了开发时间,降低了开发者的生产力。考虑到Go的主要目的是提高开发者的生产力,而不是妨碍它,长时间的构建过程必须被解决。

Go编译器的具体实现超出了本书的范围(也超出了我的专业领域)。简而言之,Go语言被设计成一个没有复杂关系的软件构建模型,这大大简化了依赖分析,消除了C风格的包含文件和库以及由此带来的开销。因此,大多数Go构建在几秒钟或偶尔几分钟内完成,即使是在相对简单的硬件上。例如,在一台配备2.3 GHz 8核Intel i9处理器和16 GB内存的MacBook Pro上构建Kubernetes v1.27.3中58.8百万行Go代码,总共花费了大约40秒:

mtitmus:~/workspace/kubernetes[MASTER]$ time make

make  67.02s user 32.51s system 247% cpu 40.231 total

这并非没有妥协。对Go语言的任何提议变更都会部分考虑它对构建时间的影响;一些本来很有前景的提案因可能增加构建时间而被拒绝。

语言稳定性

Go 1.0于2012年3月发布,定义了语言规范和一组核心API的规范。自然的结果是,Go设计团队向Go用户作出明确承诺:用Go 1编写的程序将在Go 1规范的生命周期内继续正确编译和运行,保持不变。也就是说,今天能运行的Go程序,可以预期在未来的“点”版本(如Go 1.1、Go 1.2等)中也能继续正常工作。

这一点与许多其他语言形成鲜明对比,其他语言常常热衷于添加新特性,逐渐增加语言的复杂性,这使得原本简洁的语言变成了庞大的特性集合,变得异常难以掌握。

Go团队认为,这种卓越的语言稳定性是Go的一个重要特性;它使用户能够信任Go并在其基础上构建。它允许库的使用和构建几乎没有麻烦,并显著降低了更新成本,特别是对于大型项目和组织。重要的是,它还允许Go社区使用Go并从中学习——花时间用语言编程,而不是编写语言本身。

这并不是说Go不会发展;API和核心语言当然可以增加新的包和特性,而且有很多提案正是为了这一目标,但不会以破坏现有Go 1代码的方式进行。

话虽如此,实际上可能永远不会有Go 2。更有可能的是,Go 1将继续保持兼容性,并且如果确实引入了破坏性变化,Go将提供一个转换工具,比如在过渡到Go 1时使用的go fix命令。

内存安全

Go的设计者们非常注重确保语言没有直接内存访问相关的各种bug和安全漏洞——更不用说繁琐的记账工作了。指针是严格类型化的,并且始终会初始化为某个值(即使这个值是nil),并且显式禁止指针运算。像映射(maps)和通道(channels)这样的内建引用类型,它们在内部是作为指向可变结构体的指针表示的,并且通过make函数进行初始化。简而言之,Go既不需要也不允许像C和C++那样的手动内存管理和操作,随之而来的复杂性和内存安全上的提升不可小觑。

对于程序员而言,Go是一种垃圾回收语言,这意味着无需仔细追踪和释放每个分配的字节,从而消除了大量的记账负担。没有malloc的生活让人感到解放。

此外,通过消除手动内存管理和操作——甚至是指针运算——Go的设计者们使其实际上免疫了整个类别的内存错误及其可能带来的安全漏洞。没有内存泄漏、没有缓冲区溢出、没有地址空间布局随机化。什么都没有。

当然,这种简洁性和开发的便捷性是有一些权衡的,尽管Go的垃圾回收器非常复杂,但它确实引入了一些开销。因此,Go在纯粹的执行速度上无法与C++和Rust等语言竞争。然而,正如我们在下一节看到的,Go在这一领域仍然表现得相当不错。

性能

面对C++和Java等静态类型编译语言的缓慢构建和繁琐的记账工作,许多程序员转向了Python等更动态、灵活的语言。虽然这些语言在很多方面都表现出色,但相对于像Go、C++和Java这样的编译语言,它们的效率却非常低。

在表2-1的基准测试中,这一点变得十分清楚。当然,基准测试通常需要谨慎对待,但一些结果尤其引人注目。

表2-1:常见服务语言的相对基准(秒)

C++GoJavaNodeJSPython3RubyRust
Fannkuch-Redux7.538.2510.7111.08285.20169.717.21
FASTA1.031.261.1633.1433.4026.180.90
K-Nucleotide1.967.485.0315.7244.1383.822.88
Mandelbrot2.343.734.114.03155.28155.551.01
N-Body4.886.366.778.41383.12190.963.92
Spectral norm1.521.421.541.6678.3657.560.72

来源:Isaac Gouy, The Computer Language Benchmarks Game网站,2023年6月20日。

观察这些数据,可以将结果分为三类,分别对应生成它们的语言类型:

  1. 编译的、严格类型的语言,带有手动内存管理(C++、Rust)
  2. 编译的、严格类型的语言,带有垃圾回收(Go、Java)
  3. 解释型、动态类型的语言(NodeJS、Python、Ruby)

这些结果表明,虽然垃圾回收语言的性能通常略低于带有手动内存管理的语言,但这种差异似乎并不足以在最苛刻的要求下造成影响。

然而,解释型语言和编译型语言之间的差异却十分显著。至少在这些例子中,作为典型动态语言的Python,其基准性能比大多数编译语言慢了大约十到一百倍。当然,可以争辩说,这对于许多——如果不是大多数——用途来说仍然是完全足够的,但对于云原生应用来说,这种差异就不那么合适了,因为云原生应用通常需要承受显著的需求高峰,理想情况下不需要依赖可能昂贵的扩展。

静态链接

默认情况下,Go程序会直接编译成本地的静态链接可执行二进制文件,其中所有必要的Go库和Go运行时都会被复制进去。这会生成稍微大一点的文件(例如一个“hello, world!”程序大约为2MB),但是生成的二进制文件不需要安装外部语言运行时,17也没有外部库的依赖关系需要升级或冲突,18并且可以轻松地分发给用户或部署到主机上,而不用担心依赖或环境冲突。

这种能力在使用容器时尤其有用。由于Go二进制文件不需要外部语言运行时甚至不需要发行版,它们可以被构建到没有父镜像的“scratch”镜像中。最终的结果是一个相对较小的镜像,具有最小的部署延迟和数据传输开销。这些特性在像Kubernetes这样的编排系统中非常有用,因为它可能需要定期拉取镜像。

静态类型

在Go设计的早期,设计者们必须做出选择:是像C++或Java那样采用静态类型,要求在使用前显式定义变量,还是像Python那样采用动态类型,允许程序员在没有定义变量的情况下直接赋值,从而通常能更快地编写代码?这并不是一个特别难做的决定,花费的时间也不长。静态类型显然是更合理的选择,但这并不是随便选择的,或者基于个人偏好的决定。19

首先,静态类型语言的类型正确性可以在编译时进行评估,这使得它们的性能大大提升(见表2-1)。

其次,Go的设计者理解到,开发所花费的时间只是项目生命周期的一小部分,动态类型语言在提高编码速度方面的优势,会被调试和维护这些代码时所增加的难度所弥补。毕竟,哪位Python程序员没有因为试图将字符串作为整数使用而让代码崩溃呢?

举个Python代码片段为例:

my_variable = 0

while my_variable < 10:
    my_variable = my_variable + 1   # 打字错误!无限循环!

看到问题了吗?如果没有,继续尝试,可能需要一秒钟。任何程序员都可能犯这种微妙的拼写错误,而这种错误恰好也会生成完全有效、可执行的Python代码。这仅仅是Go能够在编译时捕捉到的一类错误,而不是(天啊)在生产环境中才发现,并且通常会在代码中更靠近它们被引入的位置捕捉到。毕竟,我们都知道,在开发周期的越早阶段捕捉到bug,修复它就越容易(也更便宜)。

最后,我甚至要提出一个稍微有点争议的观点:有类型的语言更具可读性。Python常被称为特别易读,因为它的宽容性和类似英语的语法20,但如果你遇到如下的Python函数签名,你该怎么办?

def send(message, recipient):

message 是字符串吗?recipient 是某个在其他地方描述的类的实例吗?是的,这可以通过一些文档和合理的默认值进行改进,但我们中的许多人已经维护过足够的代码,知道这类改进往往是遥不可及的。显式定义的类型可以指导开发,减少编写代码时的心理负担,因为它们会自动追踪程序员本应在脑海中追踪的信息,同时也为程序员和所有必须维护代码的人提供了文档支持。

总结

如果第一章关注的是构成云原生系统的关键特性,那么本章可以说聚焦于使得Go语言成为构建云原生服务的良好选择的特点。

然而,尽管云原生系统需要具备可扩展性、松耦合性、弹性、可管理性和可观察性,但云原生时代的语言不仅仅要能够构建具有这些特性的系统。毕竟,凭借一点努力,几乎任何语言都可以技术上用于构建这样的系统。那么,是什么使得Go如此特别呢?

可以说,本章介绍的所有特性,无论是直接还是间接,都有助于实现第一章中的云原生特性。例如,并发和内存安全可以说有助于服务的可扩展性,而结构类型有助于实现松耦合。但是,尽管Go是我所知唯一将所有这些特性汇聚一处的主流语言,它们真的这么新颖吗?

或许Go最显著的特性是它内建的——而非附加的——并发特性,这使得程序员能够充分且更安全地利用现代网络和多核硬件。Go的goroutines和通道无疑是令人惊叹的,它们使得构建弹性强、高度并发的网络服务变得更加容易,但如果考虑到一些不太常见的语言,如Clojure或Erlang,它们从技术上讲并不独特。

我认为Go真正出色之处在于它对“清晰胜于巧妙”这一原则的忠实遵循,这一原则源自于对源代码是由人类为其他人类编写的理解。21 它能编译成机器码几乎是无关紧要的。

Go的设计支持人们实际的工作方式:团队合作,这些团队的成员有时会发生变化,且成员也会从事其他工作。在这种环境中,代码的清晰性、最小化“部落知识”的依赖以及快速迭代的能力至关重要。Go的简洁性常常被误解和低估,但它让程序员可以专注于解决问题,而不是与语言本身作斗争。

在第三章中,我们将详细回顾Go语言的许多具体特性,届时我们将近距离感受其简洁性。