Go语言创世纪
前言
到今天,笔者学习 Go 语言也差不多有一年了,好像都没有认真地了解过 Go 语言的前世今生以及为什么要学习 Go 语言。不如趁着这个机会,好好地了解一下 Go 语言的特点吧。
本部分分为全书的开篇,在本部分中,笔者将和读者一起穿越时空,回顾历史,详细了解 Go 语言的诞生、演进以及今天的发展,归纳总结 Go 语言的设计哲学;和读者一起站在语言设计者的高度去理解 Go 语言与众不同的设计,深刻体会 Go 设计者在那些看似陈旧、实则经过深思熟虑的设计上的付出。
希望经过本部分的学习,读者能够在更改层次上与 Go 语言的设计者形成共鸣,产生认同感。或许这种认同会在你后续的 Go 语言的学习和精进之路持续激发你的 Go 语言学习和精进之路上持续激发你的热情,帮助你快速领悟Go语言原生编程思维,并更快、更好地达成编写出高质量 Go 代码的目标。
或者你可以在阅读本文时先想一想下面三个问题:
- 为什么会出现 Go 语言?
- Go 语言的设计哲学是什么?
- Go 语言与 C++、Java 有什么区别?特性是什么?
Go 语言的出现和发展
Go语言诞生于何时?它的最初设计者是谁?它为什么被命名为Go?它的设计目标是什么?它如今发展得怎么样?带着这些问题,我们一起穿越时空,回到2007年9月Go语言诞生的那一历史时刻吧。
简介
Go 语言(也称为 Golang)是一种由 Google 开发的开源编程语言。
过去,许多开发者在使用 C++ 来开发大型的服务端软件时,由于二进制文件一般都非常大,需要耗费大量的时间在编译文件上,同时编程语言的设计思想也已经非常陈旧,这些情况都充分表明了现有的编程语言已不符合时下的生产环境。
学者们坐下来总结出了现在生产环境与软件开发之间的主要矛盾,并尝试设计一门全新的编程语言来解决这些问题。他们讨论得出的对编程语言的设计要求:
- 能够以更快的速度开发软件
- 开发出的软件能够很好地在现代的多核计算机上工作
- 开发出的软件能够很好地在网络环境下工作
- 使人们能够享受软件开发的过程
Go 语言就在这样的环境下诞生了,它的主要目标是“兼具 Python 等动态语言的开发速度和 C/C++ 等编译型语言的性能与安全性”。
Go 语言出现的目的是在编程领域中创造出最实用的方式来进行软件开发。它并不是要用奇怪的语法或晦涩难懂的概念来从根本上推翻已有的编程语言,而是重建并改善了 C、C#、Java 中的许多语法风格。
起源
Go 语言的起源可以追溯到 2007 年。
在 2007 年的时候,谷歌开发工作的规模与正在部署的生产系统规模暴增,需要有个好的解决方案应对这些挑战。
当时 Robert Griesemer、Rob Pike 和 Ken Thompson 都是用的 C++,编译一个分布式集群大概要花费 45 分钟,这个过程让三个人都很难以忍受。
2007 年 9 月 20 日星期四下午,在等待编译的时候 Rob Pike 把 Robert Griesemer 和 Ken Thompson 喊到一起决定要做些什么:他们不想永远使用 C++,并且想要很好处理并发的问题。希望创造一个能够摒弃其他语言的缺点的新语言,保持静态类型和运行时效率、具有可读性和可用性、具备高性能网络和并发处理。
Go 这个名字是 Rob Pike 取的,认为它很短、易于输入,非常合适这一新语言的特性。
最初的一周内,他们就讨论出来了很多 Go 语言的风格和特性,并着手开发。
经过两年的努力,于 2009 年 11 月,Google 宣布了 Go 语言的首个公开发布版本,即 Go 1。
顶级初创团队:
① Robert Griesemer,参与开发 Java HotSpot 虚拟机,并负责 Chrome 浏览器和 Node.js 使用的 Google V8 JavaScript 引擎的代码生成部分。
② Rob Pike,Go 语言项目总负责人,贝尔实验室 Unix 团队成员,参与的项目包括 Plan 9,Inferno 操作系统和 Limbo 编程语言。
③ Ken Thompson,贝尔实验室 Unix 团队成员,C 语言、Unix 和 Plan 9 的创始人之一,与 Rob Pike 共同开发了 UTF-8 字符集规范。
随着更多有才华的程序员加入到 Go 开发团队中,更多贡献者开始为 Go 语言项目添砖加瓦。使得 Go 在发布的当年就成为了著名编程语言排行榜 TIOBE 的年度最佳编程语言。
Go 发布后就吸引了一些公司,尤其是云计算领域的初创公司成为了 Go 语言的早期接纳者。在经过若干年的磨合后,在这些公司中诞生了不乏像 Docker(容器引擎)、Kubernetes(云原生事实标准平台)、Ethereum(区块链公链以太坊)等“杀手级”或示范性项目,这些项目也让 Go 被誉为云计算基础设施新兴语言
或直接称为云计算语言
。
Go 在近些年云原生领域的广泛应用也让其跻身云原生时代的头部编程语言。
Logo 的诞生
在 Go 立项的时候,Rob Pike 的妻子 Renee French(著名美国插画师、漫画家和作家,以其独特的风格和奇特的创意而闻名)就帮他们画了一个标志,然后这个图标就出现在 Google Code 网站和第一件 Go T 恤上,该 Logo 被用来体现 Go 的速度:
2009 年 11 月 10 日 Go 准备开源发布的之前,Rob Pike 的妻子建议,将她在 1999 年左右为新泽西州 WFMU 广播电台年度筹款活动设计的,作为宣传的形象,改编成为 Go 的吉祥物 ——— Gopher(地鼠):
发错了,是这个 ~(~ ̄▽ ̄)~:
之后,Rob Pike 的妻子又绘制了更多的 Go gopher 形象,代表着 Go 项目和各地的 Go 程序员。这些可爱的形象成为 Go 世界中最受欢迎的事物之一,被世界各地的 Go 程序员广泛使用:
发展历程
初期版本(2009 年 - 2012 年):
初期版本的 Go 语言主要集中于提供简洁、高效的编程体验,以及强大的并发支持。这些特性使其成为了云服务、网络应用和大规模分布式系统开发的理想选择。
Go 1 发布(2012 年):
Go 1 是 Go 语言的首个稳定版本,也是第一个被广泛用于生产环境的版本。发布 Go 1 的目标是提供稳定的 API 和 ABI,以便未来版本的兼容性。
生态系统的发展(2012 年 - 至今):
随着 Go 语言的发展,其生态系统也在不断壮大。包括标准库、第三方库、框架以及工具链在内的生态系统都得到了极大的丰富和改进,使得 Go 语言更加适用于各种类型的应用开发。
Go 语言在工业界的应用(2010 年至今):
自从 Go 语言发布以来,越来越多的公司和组织开始采用 Go 语言进行开发。一些知名的公司,如 Google、Uber、Dropbox、Docker、Cloudflare、MongoDB 等,都在生产环境中使用 Go 语言开发核心系统。
版本更新和改进(2012 年至今):
Go 语言的开发团队持续不断地发布新的版本,以改进语言的性能、稳定性和功能。Go 社区也积极参与到语言的发展中,提出改进建议、修复 bug,并贡献各种开源项目。
Go 语言的特性
简单易学
Go 语言的语法设计简洁明了,摒弃了一些繁琐的特性和语法元素,使得代码更易于阅读和维护。这使得 Go 语言成为一门学习曲线较为平缓的编程语言,即使是没有编程经验的人也能相对轻松地上手。
高性能并发
Go 语言天生支持并发编程,通过 goroutine 和 channel 机制,使得并发编程变得非常简单。
传统编程语言(如 C、C++ 等)的并发实现,实际上就是基于操作系统调度的,即程序负责创建线程(一般通过 pthread 等函数库调用实现),操作系统负责调度。这种传统支持并发的方式主要有两大不足:复杂与难于扩展。
为了解决这些问题,Go 果断放弃了传统的基于操作系统线程的并发模型,而采用了
用户层轻量级线程
或者说是类协程(coroutine)
,Go 将之称为 goroutine。goroutine 占用的资源非常少,Go 语言运行时默认为每个 goroutine 分配的栈空间仅 2KB,会自动在配置的一组逻辑处理器上调度执行 goroutine。每个逻辑处理器绑定到一个操作系统线程上。这让用户的应用程序执行效率更高,而开发工作量显著减少。
goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,在一个 Go 程序中可以创建成千上万个并发的 goroutine。所有的 Go 代码都在 goroutine 中执行,哪怕是 Go 的运行时代码也不例外。
而 channel(通道)则提供了不同 goroutine 之间的通信和同步机制,使得编写并发代码变得直观而安全,可以帮助用户避免在其他语言里常见的共享内存访问的问题。
快速编译
Go 语言的编译速度非常快,这得益于先进的编译器和优化器。快速的编译速度可以大大提高开发效率,特别是在大型项目中。
内存管理
Go 语言拥有自动内存管理功能,也就是垃圾回收机制。这意味着开发者不需要手动管理内存分配和回收,大大减轻了编程的负担,同时也有助于防止内存泄露。
静态类型语言
Go 语言是一门静态类型的编程语言,这意味着在编译期间就能捕获到一些类型相关的错误。静态类型检查有助于提前发现潜在的 Bug,减少在运行时可能出现的错误。
跨平台支持
Go 语言的编译器可以在多种平台上运行,可以轻松地将 Go 程序编译成适用于不同操作系统和硬件架构的可执行文件。这使得 Go 语言成为跨平台开发的理想选择。
强调并遵循软件工程原则
Go 语言鼓励开发者编写清晰、简洁、可维护的代码。它有一套明确的代码风格规范,并自带了一些工具来帮助开发者保持一致的代码风格。
丰富的标准库
Go 语言附带了丰富而强大的标准库,覆盖了网络、文件处理、加密、并发等方面。开发者可以直接使用标准库提供的功能,而无需引入大量的第三方库。
工具链
完整的工具链对于日常开发极为重要。Go 在此做得相当不错,无论是编译、格式化、错误检查、帮助文档,还是第三方包下载、更新都有对应的工具。其功能未必完善,但起码算得上简单易用。
内置完整测试框架,其中包括单元测试、性能测试、代码覆盖率、数据竞争,以及用来调优的 pprof,这些都是保障代码能正确而稳定运行的必备利器。
除此之外,还可通过环境变量输出运行时监控信息,尤其是垃圾回收和并发调度跟踪,可进一步帮助我们改进算法,获得更佳的运行期表现。
文档资源
Go 语言拥有丰富的官方文档资源,包括语言规范、标准库文档、命令行工具说明等。此外,Go 语言社区中也有许多优秀的教程、博客和论坛,为开发者提供了学习和交流的平台。
性能测评
以下是 Go 语言与其他编程语言的对比测试数据(源于网络资料):
- 在相同的环境和执行目标的情况下,Go 程序比 Java 或 Scala 应用程序要快上 2 倍,并比这两门语言使用少占用 70% 的内存,执行效率大约比 C++ 慢 20%;
- Go 的编译速度要比绝大多数语言都要快,比 Java 和 C++ 快 5 至 6 倍,比 Scala 快 10 倍;
- Go 语言通过垃圾回收器自动管理内存,这在某些情况下可能会引入一些运行时开销。相比之下,C/C++ 需要手动管理内存,这可能会导致内存泄漏和悬挂指针等问题;
- Go 和 Python 在一般开发的平均水平测试中,Go 要比 Python 3 快 25 倍左右,少占用三分之二的内存,但比 Python 大概多写一倍的代码,毫无疑问,开发效率上,Python 是要技高一筹的;
- 比较 Go 和 Python 在简单的 web 服务器方面的性能,单位为传输量每秒:
原生的 Go net/http 包要比 web.py 快 7 至 8 倍,如果使用 web.go 框架则稍微差点,比 web.py 快 6 至 7 倍。如果是使用Python 中的 tornado 异步服务器和框架开发出的Web应用,那么要比传统的 web.py 快很多,此时,Go 大概只比它快 1.2 至 1.5 倍,Go 在 Web 开发的领域比 Python 要快,但目前来看,并非碾压态势。
业务方向
网络编程:
Go 语言原生支持高效的并发编程,因此非常适合用于构建网络应用程序和分布式系统。
大数据处理:
Go 语言具有高效的执行性能和并发处理能力,因此很适合用于处理大量数据。
云原生开发:
随着云原生应用的兴起,Go 语言也成为云原生开发的热门选择。Go 的快速启动时间、小内存占用和高并发性能使其非常适合在云环境中构建轻量级容器化应用和无服务器函数。
微服务:
Go 语言对于构建微服务和 API 很有优势,因为它的代码结构简单,易于维护,同时具有高效和高并发特性
数据库和存储系统:
Go 语言提供了丰富的数据库和存储库,包括 SQL 数据库(如 MySQL、PostgreSQL)、NoSQL 数据库(如 MongoDB、Redis)以及分布式存储系统(如 etcd)。这使得 Go 成为开发高性能、可扩展和可靠的数据存储解决方案的理想语言。
Web 开发:
Go 语言拥有轻量级的 HTTP 服务器,使其成为构建高性能 Web 应用程序的理想选择。它支持快速开发和部署,并且具有良好的性能和可靠性。很多人使用 Golang 是因为它非常快,而且它可以用来并行运行进程,这样他们就不必互相等待。
它内置了对并发的支持,并促进了单个进程中线程和处理器之间的并行性。这可以使你的网站更容易快速加载并为你提供最佳的用户体验。
区块链开发:
Go 语言在区块链开发领域也得到了广泛应用。许多知名的区块链项目(如 Ethereum)使用 Go 语言作为其主要开发语言,因为 Go 具有高效的并发能力和良好的性能,适合处理区块链交易和智能合约。
Go 语言的设计哲学
从Go语言诞生的那一刻起至今已经有十多年了,Go语言的魅力使得其在世界范围内拥有百万级的拥趸。那么究竟是什么让大量的开发人员开始学习Go语言或从其他语言转向Go语言呢?笔者认为,Go语言的魅力就来自Go语言的设计哲学。
关于Go语言的设计哲学,Go语言之父们以及Go开发团队并没有给出明确的官方说法。在这里笔者将根据自己对他们以及Go社区主流观点和代码行为的整理、分析和总结,列出4条Go语言的设计哲学。理解这些设计哲学将对读者形成Go原生编程思维、编写高质量Go代码起到积极的作用。
追求简单,少即是多
简单是一种伟大的美德,但我们需要更艰苦地努力才能实现它,并需要经过一个教育的过程才能去欣赏和领会它。但糟糕的是:复杂的东西似乎更有市场。——Edsger Dijkstra,图灵奖得主
当我们问Gopher“你为什么喜欢Go语言”时,我们通常会得到很多答案,如图所示。
但在我们得到的众多答案中,排名靠前而又占据多数的总是“简单”(Simplicity)。
不同于那些通过相互借鉴而不断增加新特性的主流编程语言(如C++、Java等),Go的设计者们在语言设计之初就拒绝走语言特性融合的道路,而选择了“做减法”,选择了“简单”,他们把复杂性留给了语言自身的设计和实现,留给了Go核心开发组自己,而将简单、易用和清晰留给了广大Gopher。因此,今天呈现在我们眼前的是这样的Go语言:
- 简洁、常规的语法(不需要解析符号表),它仅有25个关键字;
- 内置垃圾收集,降低开发人员内存管理的心智负担;
- 没有头文件;
- 显式依赖(package);
- 没有循环依赖(package);
- 常量只是数字;
- 首字母大小写决定可见性;
- 任何类型都可以拥有方法(没有类);
- 没有子类型继承(没有子类);
- 没有算术转换;
- 接口是隐式的(无须implements声明);
- 方法就是函数;
- 接口只是方法集合(没有数据);
- 方法仅按名称匹配(不是按类型);
- 没有构造函数或析构函数;
- n++和n—是语句,而不是表达式;
- 没有++n和—n;
- 赋值不是表达式;
- 在赋值和函数调用中定义的求值顺序(无“序列点”概念);
- 没有指针算术;
- 内存总是初始化为零值;
- 没有类型注解语法(如C++中的const、static等);
- 没有模板/泛型;
- 没有异常(exception);
- 内置字符串、切片(slice)、map类型;
- 内置数组边界检查;
- 内置并发支持;
- ……
任何设计都存在权衡与折中。Go 设计者选择的“简单”体现在,站在巨人肩膀上去除或优化在以往语言中已被证明体验不好或难于驾驭的语法元素和语言机制,并提出自己的一些创新性的设计,比如首字母大小写决定可见性,内存分配初始零值,内置以 go 关键字实现的并发支持等)。Go 设计者推崇“最小方式”思维,即一件事情仅有一种方式或数量尽可能少的方式去完成,这大大减少了开发人员在选择路径方式及理解他人所选路径方式上的心智负担。
正如 Go 语言之父 Rob Pike 所说:“Go 语言实际上是复杂的,但只是让大家感觉很简单。”这句话背后的深意就是“简单”选择的背后是 Go语言自身实现层面的复杂性,而这种复杂性被 Go 语言的设计者“隐藏”起来了。比如并发是复杂的,但我们通过一个简单的关键字“go”就可以实现。这种简单其实是 Go 开发团队缜密设计和持续付出的结果。
此外,Go 的简单哲学还体现在 Go 1兼容性的提出。对于面对工程问题解决的开发人员来说,Go 1大大降低了工程层面语言版本升级所带来的消耗,让 Go 的工程实践变得格外简单。
从 Go 1.0 发布起至今,Go 1 的兼容性得到很好的保障,当初使用 Go 1.4 编写的代码如今也可以顺利通过最新的 Go 1.16 版本的编译并正常运行起来。正如前面引用的图灵奖得主 Edsger Dijkstra 的名言,这种创新性的简单设计并不是一开始就能得到程序员的理解的,但在真正使用Go之后,这种身处设计哲学层面的简单便延伸到Go语言编程应用的方方面面,持续影响着Go语言编程思维。
在Go演化进入关键阶段(走向Go 2)的今天,有人向Go开发团队提出过这样一个问题:Go后续演化的最大难点是什么?Go开发团队的一名核心成员回答道:“最大的难点是如何继续保持Go语言的简单。”
偏好组合,正交解耦
当我们有必要采用另一种方式处理数据时,我们应该有一些耦合程序的方式,就像花园里将浇水的软管通过预置的螺丝扣拧入另一段那样,这也是Unix IO采用的方式。——Douglas McIlroy,Unix管道的发明者(1964)
C++、Java等主流面向对象(以下简称OO)语言通过庞大的自上而下的类型体系、继承、显式接口实现等机制将程序的各个部分耦合起来,但在Go语言中我们找不到经典OO的语法元素、类型体系和继承机制,或者说Go语言本质上就不属于经典OO语言范畴。针对这种情况,很多人会问:那Go语言是如何将程序的各个部分有机地耦合在一起的呢?就像上面引述的Douglas McIlroy那句话中的浇水软管那样,Go语言遵从的设计哲学也是组合。
在语言设计层面,Go提供了正交的语法元素供后续组合使用,包括:
- Go语言无类型体系(type hierarchy),类型之间是独立的,没有子类型的概念;
- 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;
- 接口(interface)与其实现之间隐式关联;
- 包(package)之间是相对独立的,没有子包的概念。
我们看到无论是包、接口还是一个个具体的类型定义(包括类型的方法集合),Go语言为我们呈现了这样一幅图景:一座座没有关联的“孤岛”,但每个岛内又都很精彩。现在摆在面前的工作就是以最适当的方式在这些孤岛之间建立关联(耦合),形成一个整体。Go采用了组合的方式,也是唯一的方式。
Go语言提供的最为直观的组合的语法元素是类型嵌入(typeembedding)。通过类型嵌入,我们可以将已经实现的功能嵌入新类型中,以快速满足新类型的功能需求。这种方式有些类似经典 OO语言中的继承机制,但在原理上与其完全不同,这是一种Go设计者们精心设计的语法糖。被嵌入的类型和新类型之间没有任何关系,甚至相互完全不知道对方的存在,更没有经典OO语言中的那种父类、子类的关系以及向上、向下转型(type casting)。在通过新类型实例调用方法时,方法的匹配取决于方法名字,而不是类型。这种组合方式,笔者称之为“垂直组合”,即通过类型嵌入,快速让一个新类型复用其他类型已经实现的能力,实现功能的垂直扩展。
通过在interface的定义中嵌入interface类型来实现接口行为的聚合,组成大接口,这种方式在标准库中尤为常用,并且已经成为Go语言的一种惯用法。
interface 是 Go 语言中真正的“魔法”,是 Go 语言的一个创新设计,它只是方法集合,且与实现者之间的关系是隐式的,它让程序各个部分之间的耦合降至最低,同时是连接程序各个部分的“纽带”。隐式的 interface 实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,这在其他语言中是需要很刻意的设计谋划才能实现的,但在 Go interface 看来,一切却是自然而然的。通过 interface 将程序各个部分组合在一起的方法,笔者称之为“水平组合”。水平组合的模式有很多,一种常见的方法是通过接受 interface 类型参数的普通函数进行组合。
此外,Go 语言内置的并发能力也可以通过组合的方式实现对计算能力的串联,比如通过 goroutine+channel
的组合实现类似Unix Pipe的能力。
综上,组合原则的应用塑造了 Go 程序的骨架结构。类型嵌入为类型提供垂直扩展能力,interface 是水平组合的关键,它好比程序肌体上的“关节”,给予连接“关节”的两个部分各自“自由活动”的能力,而整体上又实现了某种功能。组合也让遵循简单原则的 Go 语言在表现力上丝毫不逊色于复杂的主流编程语言。
原生并发,轻量高效
并发是有关结构的,而并行是有关执行的。——Rob Pike(2012)
将时钟回拨到 2007年,那时 Go语言的三位设计者 Rob Pike、Robert Griesemer 和 Ken Thompson 都在 Google 使用 C++ 语言编写服务端代码。当时 C++ 标准委员会正在讨论下一个 C++ 标准(C++0x,也就是后来的 C++11 标准),委员会在标准草案中继续增加大量语言特性的行为让 Go 的三位设计者十分不满,尤其是带有原子类型的新 C++ 内存模型,给本已负担过重的 C++ 类型系统又增加了额外负担。三位设计者认为 C++ 标准委员会在思路上是短视的,因为硬件很可能在未来十年内发生重大变化,将语言与当时的硬件紧密耦合起来是十分不明智的,是没法给开发人员在编写大规模并发程序时带去太多帮助的。
多年来,处理器生产厂商一直遵循着摩尔定律,在提高时钟频率这条跑道上竞争,各行业对计算能力的需求推动了处理器处理能力的提高。CPU 的功耗和节能问题成为人们越来越关注的焦点。CPU 仅靠提高主频来改进性能的做法遇到了瓶颈。主频提高导致CPU的功耗和发热量剧增,反过来制约了 CPU 性能的进一步提高。依靠主频的提高已无法实现性能提升,人们开始把研究重点转向把多个执行内核放进一个处理器,让每个内核在较低的频率下工作来降低功耗同时提高性能。2007年处理器领域已开始进入一个全新的多核时代,处理器厂商的竞争焦点从主频转向了多核,多核设计也为摩尔定律带来新的生命力。与传统的单核CPU相比,多核CPU带来了更强的并行处理能力、更高的计算密度和更低的时钟频率,并大大减少了散热和功耗。Go 的设计者敏锐地把握了 CPU 向多核方向发展的这一趋势,在决定不再使用C++而去创建一门新语言的时候,果断将面向多核、原生内置并发支持作为新语言的设计原则之一。
Go语言原生支持并发的设计哲学体现在以下几点。
Go 语言采用轻量级协程并发模型,使用 Go 应用在面向多核硬件时更具可拓展性
传统编程语言(如C、C++等)的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过pthread等函数库调用实现),操作系统负责调度。这种传统支持并发的方式主要有两大不足:复杂和难于扩展。
复杂主要体现在以下方面:
- 创建容易,退出难:使用C语言的开发人员都知道,创建一个线程时(比如利用pthread库)虽然参数也不少,但还可以接受。而一旦涉及线程的退出,就要考虑线程是不是分离的(detached)?是否需要父线程去通知并等待子线程退出(join)?是否需要在线程中设置取消点(cancel point)以保证进行join操作时能顺利退出?
- 并发单元间通信困难,易错:多个线程之间的通信虽然有多种机制可选,但用起来相当复杂;并且一旦涉及共享内存(shared memory),就会用到各种锁(lock),死锁便成为家常便饭。
- 线程栈大小的设定:是直接使用默认的,还是设置得大一些或小一些呢?
难于拓展主要体现在以下方面:
- 虽然线程的代价比进程小了很多,但我们依然不能大量创建线程,因为不仅每个线程占用的资源不小,操作系统调度切换线程的代价也不小。
- 对于很多网络服务程序,由于不能大量创建线程,就要在少量线程里做网络的多路复用,即使用epoll/kqueue/IoCompletionPort 这套机制。即便有了libevent、libev这样的第三方库的帮忙,写起这样的程序也是很不容易的,存在大量回调(callback),会给程序员带来不小的心智负担。
为了解决这些问题,Go果断放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程或者说是类协程(coroutine),Go将之称为 goroutine。goroutine 占用的资源非常少,Go运行时默认为每个 goroutine 分配的栈空间仅2KB。goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,在一个Go程序中可以创建成千上万个并发的goroutine。所有的Go代码都在 goroutine 中执行,哪怕是 Go 的运行时代码也不例外。
不过,一个Go程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine的存在。goroutine的调度全靠Go自己完成,实现 Go 程序内 goroutine 之间公平地竞争CPU资源的任务就落到了Go运行时头上。而将这些 goroutine 按照一定算法放到 CPU 上执行的程序就称为 goroutine 调度器(goroutine scheduler)。关于 goroutine 调度的原理,我们将在后面详细说明,这里就不赘述了。
Go 语言为开发者提供的支持并发的语法元素和机制
我们先来看看那些设计并诞生于单核年代的编程语言(如C、C++、Java)在语法元素和机制层面是如何支持并发的。
- 执行单元:线程
- 创建和销毁的方式:调用库函数或调用对象方法
- 并发线程间的通信:多基于操作系统提供的 IPC机制(进程间通信),比如共享内存、Socket、Pipe等,当然也会使用有并发保护的全局变量。
与上述传统语言相比,Go提供了语言层面内置的并发语法元素和机制。
- 执行单元:goroutine
- 创建和销毁的方式:go+函数调用;函数退出即 goroutine 退出
- 并发线程间的通信:通过语言内置的channel传递消息或实现同步,并通过select实现多路channel的并发控制。
对比来看,Go对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。
并发原则对 Go 开发者在程序结构设计层面的影响
由于 goroutine 的开销很小(相对线程),Go官方鼓励大家使用 goroutine 来充分利用多核资源。但并不是有了 goroutine 就一定能充分利用多核资源,或者说即便使用 Go 也不一定能写出好的并发程序。
为此Rob Pike曾做过一次关于“并发不是并行”的主题分享,图文并茂地讲解了并发(Concurrency)和并行(Parallelism)的区别。Rob Pike认为:
- 并发是有关结构的,它是一种将一个程序分解成多个小片段并且每个小片段都可以独立执行的程序设计方法;并发程序的小片段之间一般存在通信联系并且通过通信相互协作。
- 并行是有关执行的,它表示同时进行一些计算任务。
采用并发方案设计的程序在单核处理器上也是可以正常运行的(在单核上的处理性能可能不如非并发方案),并且随着处理器核数的增多,并发方案可以自然地提高处理性能,提升吞吐量。而非并发方案在处理器核数提升后,也仅能使用其中的一个核,无法自然扩展,这一切都是程序的结构所决定的。这告诉我们:并发程序的结构设计不要局限于在单核情况下处理能力的高低,而要以在多核情况下充分提升多核利用率、获得性能的自然提升为最终目的。
除此之外,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计层面对程序进行拆解组合,再映射到程序执行层面:goroutine 各自执行特定的工作,通过 channel+select 将 goroutine 组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让 Go语言更适应现代计算环境。
面向工程,自带电池
软件工程指引着Go语言的设计。——Rob Pike(2012)
要想理解这条设计哲学,我们依然需要回到三位Go语言之父在设计Go语言时的初衷:面向真实世界中Google内部大规模软件开发存在的各种问题,为这些问题提供答案。主要的问题包括:
- 程序构建慢;
- 失控的依赖管理;
- 开发人员使用编程语言的不同子集(比如C++支持多范式,这样有些人用OO,有些人用泛型);
- 代码可理解性差(代码可读性差、文档差等);
- 功能重复实现;
- 升级更新消耗大;
- 实现自动化工具难度高;
- 版本问题;
- 跨语言构建问题。
很多编程语言的设计者或拥趸认为这些问题并不是编程语言应该解决的,但Go语言的设计者并不这么看,他们以更高、更广阔的视角审视软件开发领域尤其是大规模软件开发过程中遇到的各种问题,并在Go语言最初设计阶段就将解决工程问题作为Go的设计原则之一去考虑Go语法、工具链与标准库的设计,这也是Go与那些偏学院派、偏研究性编程语言在设计思路上的一个重大差异。
Go语言取得阶段性成功后,这种思路开始影响后续新编程语言的设计,并且一些现有的主流编程语言也在借鉴Go的一些设计,比如越来越多的语言认可统一代码风格的优越之处,并开始提供官方统一的fmt工具(如Rust的rustfmt),又如Go创新提出的最小版本选择(Minimal Version Selection,MVS)被其他语言的包依赖工具所支持(比如Rust的cargo支持MVS)。
Go设计者将所有工程问题浓缩为一个词:scale(笔者总觉得将scale这个词翻译为任何中文词都无法传神地表达其含义,暂译为“规模”吧)。从Go1开始,Go的设计目标就是帮助开发者更容易、更高效地管理两类规模。
- 生产规模:用Go构建的软件系统的并发规模,比如这类系统并发关注点的数量、处理数据的量级、同时并发与之交互的服务的数量等。
- 开发规模:包括开发团队的代码库的大小,参与开发、相互协作的工程师的人数等。
Go设计者期望 Go 可以游刃有余地应对生产规模和开发规模变大带来的各种复杂问题。Go语言的演进方向是优化甚至消除 Go语言自身面对规模化问题时应对不好的地方,比如:Go 1.9引入类型别名(type alias)以应对大型代码仓库代码重构,Go 1.11引入go module机制以解决不完善的包依赖问题等。这种设计哲学的落地让 Go语言具有广泛的规模适应性:既可以被仅有5人的初创团队用于开发终端工具,也能够满足像Google这样的巨型公司大规模团队开发大规模网络服务程序的需要。
那么Go是如何解决工程领域规模化所带来的问题的呢?我们从语言、标准库和工具链三个方面来看一下。
语言
语法是编程语言的用户接口,它直接影响开发人员对于一门语言的使用体验。Go语言是一门简单的语言,简单意味着可读性好,容易理解,容易上手,容易修复错误,节省开发者时间,提升开发者间的沟通效率。但作为面向工程的编程语言,光有简单的设计哲学还不够,每个语言设计细节还都要经过“工程规模化”的考验和打磨,需要在细节上进行充分的思考和讨论。
- 从工程的安全性和可靠性角度考虑,选择使用大括号代码块结构;
- 重新设计编译单元和目标文件格式,实现Go源码快速构建,将大工程的构建时间缩短到接近于动态语言的交互式解释的编译时间;
- 不能导入没有使用过的包,否则程序将无法编译。这既可以充分保证Go程序的依赖树是精确的,也可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间。
- 去除包的循环依赖。循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建速度。
- 在处理依赖关系时,有时会通过允许一部分重复代码来避免引入较多依赖关系。
- 包路径是唯一的,而包名不必是唯一的。导入路径必须唯一标识要导入的包,而名称只是包的使用者对如何引用其内容的约定。包名不必是唯一的约定大大降低了开发人员给包起唯一名字的心智负担。
- 故意不支持默认函数参数。因为在规模工程中会导致函数拥有太多的参数,降低清晰度和可读性。
- 首字母大小写定义标识符可见性,这是Go的一个创新。它让开发人员通过名称即可知晓其可见性,而无须回到标识符定义的位置查找并确定其可见性,这提升了开发人员阅读代码的效率。
- 在语义层面,相对于C,Go做了很多改动,提升了语言的健壮性,比如去除指针算术,去除隐式类型转换等。
- 内置垃圾收集。这对于大型工程项目来说,大大降低了程序员在内存管理方面的负担,程序员使用GC感受到的好处超过了付出的成本,并且这些成本主要由语言实现者来承担。
- 内置并发支持,为网络软件带来了简单性,而简单又带来了健壮,这是大型工程软件开发所需要的。增加类型别名,支持大规模代码库的重构。
标准库
Go被称为“自带电池”(battery-included)的编程语言。“自带电池”原指购买了电子设备后,在包装盒中包含了电池,电子设备可以开箱即用,无须再单独购买电池。如果说一门编程语言“自带电池”,则说明这门语言标准库功能丰富,多数功能无须依赖第三方包或库,Go语言恰是这类编程语言。
由于诞生年代较晚,且目标较为明确,Go在标准库中提供了各类高质量且性能优良的功能包,其中的net/http、crypto/xx、encoding/xx等包充分迎合了云原生时代关于API/RPC Web服务的构建需求。Go开发者可以直接基于这些包实现满足生产要求的API服务,从而减轻对第三方包或库的依赖,降低工程代码依赖管理的复杂性,也降低开发人员学习第三方库的心智负担。
仅使用标准库来构建系统,这对于开发人员是很有吸引力的。在很多关于选用何种Go Web开发框架的调查中,选择标准库的依然占大多数,这也是Go社区显著区别于其他编程语言社区的一点。
Go语言目前在GUI、机器学习(Machine Learning)等开发领域占有的份额较低,这很可能与Go标准库没有内置这类包有关。
工具链
开发人员在做工程的过程中需要使用工具。而Go语言提供了十分全面、贴心的编程语言官方工具链,涵盖了编译、编辑、依赖获取、调试、测试、文档、性能剖析等的方方面面。
- 构建和运行:go build/go run
- 依赖包查看与获取:go list/go get/go mod xx
- 编辑辅助格式化:go fmt/gofmt
- 文档查看:go doc/godoc
- 单元测试/基准测试/测试覆盖率:go test
- 代码静态分析:go vet
- 性能剖析与跟踪结果查看:go tool pprof/go tool trace
- 升级到新Go版本API的辅助工具:go tool fix
- 报告Go语言bug:go bug
值得重点提及的是 gofmt 统一了 Go语言的编码风格,在其他语言开发者还在为代码风格争论不休的时候,Go开发者可以更加专注于领域业务。同时,相同的代码风格让以往困扰开发者的代码阅读、理解和评审工作变得容易了很多,至少Go开发者再也不会有那种因代码风格的不同而产生的陌生感。
结语
这里做一个简单的总结:
简单是Go语言贯穿语言设计和应用的主旨设计哲学。德国建筑大师路德维希·密斯·凡德罗将“少即是多”这一哲学理念应用到建筑设计当中后取得了非凡的成功,而Go语言则是这一哲学在编程语言领域为数不多的践行者。“少”绝不是目的,“多”才是其内涵。Go在语言层面的简单让Go收获了不逊于C++/Java等的表现力的同时,还获得了更好的可读性、更高的开发效率等在软件工程领域更为重要的元素。
“高内聚、低耦合”是软件开发领域亘古不变的管理复杂性的准则。Go在语言设计层面也将这一准则发挥到极致。Go崇尚通过组合的方式将正交的语法元素组织在一起来形成应用程序骨架,接口就是在这一哲学下诞生的语言精华。
不同于C、C++、Java等诞生于20世纪后段的面向单机的编语言,Go语言是面向未来的。Go设计者对硬件发展趋势做出了敏锐且准确的判断——多核时代是未来主流趋势,于是将并发作为语言的“一等公民”,提供了内置于语言中的简单并发原语——go(goroutine)、channel 和 select,大幅降低了开发人员在云计算多核时代编写大规模并发网络服务程序时的心智负担。Go 生来就肩负着解决面向软件工程领域问题的使命,我们看到的开箱即用的标准库、语言自带原生工具链以及开放的工具链生态的建立都是这一使命落地的结果,Go在面向工程领域的探索也引领着编程语言未来发展的潮流。
这篇博客是本系列博客的第一篇,主要内容来自于《Go 语言精进之路》一书中,希望通过阅读完上面的内容,让你我对 Go 语言有一个更加清晰的认知。在这里跟大家分享一下 Go语言之父的一些忠告。
Rob Pike 今年已经 68 岁了,大部分时候在澳大利亚生活,现在居住在悉尼新南威尔士州。
在最近的一次采访中,他总结了自己 40 多年开发经验说:
避免倦怠的最好方法是在支持你的环境中做你真正喜欢的事情
。他认为自己是幸运的,在贝尔实验室和谷歌都是如此。同时他也提醒我们:
如果对工作感到压力,应该随时休息或者改变方向
。
希望每一个人都能找到自己喜欢做的事情!!!