Go语言高级编程

978-7-115-51036-5
作者: 柴树杉 曹春晖
译者:
编辑: 杨海玲

图书目录:

详情

本书从实践出发讲解Go语言编程的进阶知识。本书共6章,第1章简单回顾Go语言的发展历史;第2章和第3章系统介绍CGO编程和Go汇编语言的用法;第4章对RPC和Protobuf技术进行深入介绍,并讲述如何打造一个自己的RPC系统;第5章介绍工业级环境的Web系统的设计和相关技术;第6章介绍Go语言在分布式领域的一些编程技术。

图书摘要

版权信息

书名:Go语言高级编程

ISBN:978-7-115-51036-5

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    柴树杉 曹春晖

责任编辑 杨海玲

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书从实践出发讲解Go语言的进阶知识。本书共6章,第1章简单回顾Go语言的发展历史;第2章和第3章系统地介绍CGO编程和Go汇编语言的用法;第4章对RPC和Protobuf技术进行深入介绍,并讲述如何打造一个自己的RPC系统;第5章介绍工业级环境的Web系统的设计和相关技术;第6章介绍Go语言在分布式领域的一些编程技术。书中还涉及CGO和汇编方面的知识,其中CGO能够帮助读者继承优秀的软件遗产,而在深入学习Go运行时,汇编对于理解各种语法设计的底层实现是必不可少的知识。此外,本书还包含一些紧跟潮流的内容,介绍开源界流行的gRPC及其相关应用,并讲述Go Web框架中的基本实现原理和大型Web项目中的技术要点,引导读者对Go语言进行更深入的应用。

本书适合对Go语言的应用已经有一些心得,并希望能够深入理解底层实现原理或者是希望能够在Web开发方面结合Go语言来实现进阶学习的技术人员学习和参考。


互联网时代的来临,改变甚至颠覆了很多东西。从前,一台主机就能搞定一切;而在互联网时代,后台由大量分布式系统构成,任何单个后台服务器节点的故障都不会影响整个系统的正常运行。以七牛云、阿里云和腾讯云为代表的云厂商的出现和崛起,标志着云时代的到来。在云时代,掌握分布式编程已经成为软件工程师的基本技能,而基于Go语言构建的Docker、Kubernetes等系统正是将云时代推向顶峰的关键力量。

今天,Go语言已历经十年,最初的追随者也已经逐渐成长为Go语言资深用户。随着资深用户的不断积累,Go语言相关教程随之增加,在内容层面主要涵盖Go语言基础编程、Web编程、并发编程和内部源码剖析等诸多领域。

本书作者是国内第一批Go语言实践者和Go语言代码贡献者,创建了Go语言中国讨论组,并组织了早期Go语言相关中文文档的翻译工作。作者从2011年开始分享Go语言和C/C++语言混合编程技术。本书汇集了作者多年来学习和使用Go语言的经验,内容涵盖CGO特性、Go汇编语言、RPC实现、Protobuf插件实现、Web框架实现、分布式系统等高阶主题。其中,CGO特性实现了Go语言对C语言和C++语言混合编程的支持,使Go语言可以无缝继承C/C++世界数十年来积累的巨大软件资产。Go汇编语言更是提供了直接调用底层机器指令的方法,让我们可以最大限度地提升程序中热点代码的性能。

目前,国内互联网公司的新兴项目已经在逐渐向Go语言生态转移,大型分布式系统的开发实战经验也是大家关心的热点。这些高阶或前沿特性正是本书所关注的课题,在这些方面作者通过不断钻研和实践积累了很多宝贵经验。

总体来说,本书适合有一定Go语言经验,并想深入了解Go语言各种高级用法的开发人员。对于Go语言新手,建议在阅读本书前先阅读一些基础Go语言编程图书,例如D&K的The Go Programming Language

最后,感谢作者在Go语言领域的笔耕不辍和突出贡献,时代需要的正是这样对于新兴技术不断关注、钻研和推动的布道者。七牛云作为一家技术领先的科技公司,也将在这条布道者的道路上不断前进,为推动科技的发展、中国企业的云落地和行业的数字化转型贡献自己的力量。

许式伟,七牛云CEO

2019年5月于上海


说起Go语言,大家会不自觉地将其与C语言比较,普遍认为“Go = C + GC + Goroutine”,同时也将其称为“云计算时代的C语言”,在开发效率和运行效率之间取得了绝佳的平衡。Go语言既适应互联网应用的极速开发,又能在高并发、高性能的开发场景中如鱼得水。从近几年的发展趋势来看,Go语言已经成为云计算、云存储以及区块链时代最重要的编程语言。

我大概是从2010年开始接触Go语言,当时Go也刚开源没多久,相关的资料只有官方文档和源代码,通过一次偶然的机会,我认识了柴树杉,我们一起组织和翻译了Go的官方文档以及源代码注释,为Go语言在国内的推广做了微薄的贡献。

历经数年的发展,Go语言已今非昔比,在各领域都不乏成功案例。说到Go语言最先想到的开源项目就是Docker和Kubernetes,而在国内几乎所有的著名互联网公司都在使用Go。最早使用Go的七牛,以及头条、滴滴、美团、小米、链家等后起之秀都在使用Go语言重构,BAT更不用说,在这些公司的业务中,Go都能在某方面占有重要的位置。例如:百度的BFE(统一接入前端)使用Go语言重构,日请求量达千亿级,百度内部还针对Go语言单独开发了一系列开发工具,例如,GDP(Go Develop Platform)是百度Go业务开发平台,面向全百度的在线业务支撑平台。

我在读本书的时候,深深地体会到两位作者扎实的基本功和丰富的实战经验。本书面向想要深入了解Go语言各种高级用法的开发人员,适合有一定Go语言基础的人阅读。

本书的第1章是语言基础,主要介绍了Go语言的发展历史。作者从简单的“Hello, World”程序,详细分析了Go语言各个前辈的演变过程,从而帮读者更直观地了解Go语言的发展历程。还通过简单的生产者/消费者模型,通俗易懂地诠释了Go语言的并发编程哲学的口号:“Do not communicate by sharing memory;instead,share memory by communicating.”(不要通过共享内存来通信,而应通过通信来共享内存)。

第2章和第3章主要从CGO和汇编入手,详细讲解了如何通过Go语言来调用C/C++实现的类库,从而丰富Go语言的基础库。同时了解Go语言汇编可以更容易地理解Go语言中动态栈、接口等高级特性的实现原理。

随着微服务架构的盛行,各种RPC相关的架构也脱颖而出,第4章从Go语言标准库自带的RPC入手,一步步地实现了一个Watch(监视)功能的接口。除了标准库里面的RPC,这一章还详细讲解了谷歌推出的gRPC框架,并基于gRPC实现了一个双向流特性的发布和订阅系统。

第5章主要以典型的开源Web框架为例,深入解释Router(路由)和Middleware(中间件)的执行过程以及相关原理,通过熟读和理解这一章的内容,读者可以使用标准的HTTP库实现自己的轻量级Web框架。同时这一章也介绍了实际Web开发过程中的一些问题,以及在Go语言中如何面对并解决这些问题。

众所周知,Go语言在高并发、通信交互复杂、重业务逻辑的分布式系统中非常适用,具有开发体验好、服务稳定、性能高等优势。因此,本书最后的第6章通过解决分布式开发过程中的问题,来讲解Go语言在分布式开发过程中的实践。

最后,希望读者能通过本书了解Go语言的一些高级用法,并可以应用在自己的实际项目中。同时希望读者在享受Go语言开发带来的乐趣并获得收获的同时,能回馈融入社区,一起推动社区的建设和发展。

边江,百度资深工程师

2019年5月于北京


我从2016年就开始计划写作本书。2016年底因为开始学习The Go Programming Language临时搁置了写作。到了2018年决定重启,经过约半年的艰苦写作,2018年8月本书初稿终于完成。在本书初稿完成之际,Go 1.11也正式发布。Go 1.11开始对WebAssembly和模块提供支持,这两个改进将成为“后Go 1时代”最大的亮点。

其中WebAssembly是第一个Web汇编语言和虚拟机标准,Go语言对WebAssembly的支持是Go语言团队和GopherJS开源社区共同努力的成果。根据Ending定律,一切可编译为WebAssembly的,终将被编译为WebAssembly。由于篇幅和时间的原因,本书没有涉及Go语言和WebAssembly相关的主题。感兴趣的读者可以参考作者编写的《WebAssembly标准入门》,其中有专门章节讨论Go语言在WebAssembly平台的使用。

模块化也称为包依赖管理,是管理任何大型工程必备的工具。Go语言自发布10年来一直缺乏官方的模块化工具。同样在2018年,作为Go语言团队的技术领导人Russ Cox终于出手,重新设计了称为最小版本选择的包依赖管理的规则并提交了提案。模块化的特性已经被试验性地集成到Go 1.11中,并将在后续版本中逐渐转化为正式特性。模块化的特性将彻底解决大型Go语言工程的管理问题,至此Go 1除了缺少泛型等特性已经近乎完美。

在后Go 1时代过去之后将是新兴的Go 2时代!大约在2012年前后,作者曾乐观估计Go 2将在2020年前后到来,并可能带来大家期盼已久的泛型特性。最近官方已经发布了Go 2的设计草案,其中包含了令人惊喜的泛型特性和更好的错误处理流程等诸多改进。需要说明的是,官方已经通过博文表明Go 2将保持对Go 1软件资产的最大兼容。在本书即将出版之际,作者乐观预测Go 2将在2020年正式进入开发流程,并在2022年前后进入工业级生产环境使用,而Go 1将在2030年前后逐渐退出历史舞台。为了在Go 2到来时轻装上阵,我们更需要提前夯实在Go 1中尚未学习的基础知识,而本书正是在为此目标做准备。

本书第1章简单回顾Go语言的发展历史;第2章和第3章系统介绍CGO编程和Go汇编语言的用法;第4章对RPC和Protobuf技术进行深入介绍,并讲述如何打造一个自己的RPC系统;第5章介绍工业级环境的Web系统的设计和相关技术;最后的第6章介绍Go语言在分布式领域的一些编程技术。

最后,我们也是Go语言爱好者和学习者,虽然我们尽了最大努力,但是不足之处依然难免。欢迎大家提出改进意见。

柴树杉

2019年5月于武汉光谷


首先感谢“Go语言之父”和每一位为Go语言提交过代码的朋友。感谢fango(樊虹剑)的第一本以Go语言为主题的网络小说《胡文Go.ogle》和第一本中文Go语言图书《Go语言·云动力》,是你的分享带动了大家学习Go语言的热情。感谢韦光京对Windows平台支持CGO特性所做出的开创性工作,不然本书可能不会有专门讲解CGO的章节。感谢许式伟和谢孟军为Go语言在中国的推广所做出的巨大贡献。感谢为本书提交过Issue或PR的朋友(特别是fuwensun、lewgun等),你们的关注和支持是我们写作本书的最大动力。最后感谢人民邮电出版社的杨海玲编辑,没有她,本书就不可能出版。谢谢大家!


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供源代码下载,要获得以上配套资源,请在异步社区本书页面中点击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


我不知道,你过去10年为什么不快乐。但相信我,抛掉过去的沉重,使用Go语言,体会最初的快乐!

——469856321

搬砖民工也会建成自己的“罗马帝国”。

——小张

本章首先简要介绍Go语言的发展历史,并较详细地分析“Hello, World”程序在各个祖先语言中的演化过程。然后,对以数组、字符串和切片为代表的基础结构,以函数、方法和接口体现的面向过程和鸭子对象的编程,以及Go语言特有的并发编程模型和错误处理哲学做简单介绍。最后,针对macOS、Windows、Linux几个主流的开发平台,推荐几种较友好的Go语言编辑器和集成开发环境,因为好的工具可以极大地提高我们的效率。

Go语言最初由谷歌公司的Robert Griesemer、Ken Thompson和Rob Pike这3位技术大咖于2007年开始设计发明,设计新语言的最初动力来自对超级复杂的C++ 11特性的吹捧报告的鄙视,最终的目标是设计网络和多核时代的C语言。到2008年中期,在语言的大部分特性设计已经完成并开始着手实现编译器和运行时,Russ Cox作为主力开发者加入。到2009年,Go语言已经逐步趋于稳定。同年9月,Go语言正式发布并开源了代码。

Go语言很多时候被描述为“类C语言”,或者“21世纪的C语言”。从各种角度看,Go语言确实是从C语言继承了相似的表达式语法、控制流结构、基础数据类型、调用参数传值、指针等诸多编程思想,并彻底继承和发扬了C语言简单直接的暴力编程哲学等。图1-1给出的是The Go Programming Language中给出的Go语言的基因图谱,我们可以从中看到有哪些编程语言对Go语言产生了影响。

图1-1 Go语言基因图谱

首先看基因图谱的左边一支。可以明确看出Go语言的并发特性是由贝尔实验室的Hoare于1978年发布的CSP理论演化而来。其后,CSP并发模型在Squeak/Newsqueak和Alef等编程语言中逐步完善并走向实际应用,最终这些设计经验被消化并吸收到了Go语言中。业界比较熟悉的Erlang编程语言的并发编程模型也是CSP理论的另一种实现。

再看基因图谱的中间一支。中间一支主要包含了Go语言中面向对象和包特性的演化历程。Go语言中包和接口以及面向对象等特性则继承自Niklaus Wirth所设计的Pascal语言以及其后衍生的相关编程语言。其中包的概念、包的导入和声明等语法主要来自Modula-2编程语言,面向对象特性所提供的方法的声明语法等则来自Oberon编程语言。最终Go语言演化出了自己特有的支持鸭子面向对象模型的隐式接口等诸多特性。

最后是基因图谱的右边一支,这是对C语言的致敬。Go语言是对C语言最彻底的一次扬弃,不仅在语法上和C语言有着很多差异,最重要的是舍弃了C语言中灵活但是危险的指针运算。而且,Go语言还重新设计了C语言中部分不太合理运算符的优先级,并在很多细微的地方都做了必要的打磨和改变。当然,C语言中少即是多、简单直接的暴力编程哲学则被Go语言更彻底地发扬光大了(Go语言居然只有25个关键字,语言规范还不到50页)。

Go语言的其他特性零散地来自其他一些编程语言,例如,iota语法是从APL语言借鉴的,词法作用域与嵌套函数等特性来自Scheme语言(和其他很多编程语言)。Go语言中也有很多自己发明创新的设计。例如Go语言的切片为轻量级动态数组提供了有效的随机存取的性能,这可能会让人联想到链表的底层的共享机制。还有Go语言新发明的defer语句(Ken发明)也是神来之笔。

作为Go语言标志性的并发编程特性则来自贝尔实验室的Tony Hoare于1978年发表的鲜为外界所知的关于并发研究的基础文献:顺序通信进程(Communicating Sequential Processes,CSP)。在最初的CSP论文中,程序只是一组没有中间共享状态的并发运行的处理过程,它们之间使用通道进行通信和控制同步。Tony Hoare的CSP并发模型只是一个用于描述并发性基本概念的描述语言,它并不是一个可以编写可执行程序的通用编程语言。

CSP并发模型最经典的实际应用是来自爱立信公司发明的Erlang编程语言。不过在Erlang将CSP理论作为并发编程模型的同时,同样来自贝尔实验室的Rob Pike以及其同事也在不断尝试将CSP并发模型引入当时的新发明的编程语言中。他们第一次尝试引入CSP并发特性的编程语言叫Squeak(老鼠的叫声),是一个用于提供鼠标和键盘事件处理的编程语言,在这个语言中通道是静态创建的。然后是改进版的Newsqueak语言(新版老鼠的叫声),新提供了类似C语言语句和表达式的语法,还有类似Pascal语言的推导语法。Newsqueak是一个带垃圾回收机制的纯函数式语言,它再次针对键盘、鼠标和窗口事件管理。但是在Newsqueak语言中通道已经是动态创建的,通道属于第一类值,可以保存到变量中。然后是Alef编程语言(Alef也是C语言之父Ritchie比较喜爱的编程语言),Alef语言试图将Newsqueak语言改造为系统编程语言,但是因为缺少垃圾回收机制而导致并发编程很痛苦(这也是继承C语言手工管理内存的代价)。在Alef语言之后还有一个名为Limbo的编程语言(地狱的意思),这是一个运行在虚拟机中的脚本语言。Limbo语言是与Go语言最接近的祖先,它和Go语言有着最接近的语法。到设计Go语言时,Rob Pike在CSP并发编程模型的实践道路上已经积累了几十年的经验,关于Go语言并发编程的特性完全是信手拈来,新编程语言的到来也是水到渠成了。

图1-2展示了Go语言库早期代码库日志,可以看出最直接的演化历程(在Git中用git log --before={2008-03-03} --reverse命令查看)。

图1-2 Go语言开发日志

从早期提交日志中也可以看出,Go语言是从Ken Thompson发明的B语言、Dennis M. Ritchie发明的C语言逐步演化过来的,它首先是C语言家族的成员,因此很多人将Go语言称为21世纪的C语言。

图1-3给出的是Go语言中来自贝尔实验室特有并发编程基因的演化过程。

图1-3 Go语言并发演化历史

纵观整个贝尔实验室的编程语言的发展进程,从B语言、C语言、Newsqueak、Alef、Limbo语言一路走来,Go语言继承了来自贝尔实验室的半个世纪的软件设计基因,终于完成了C语言革新的使命。纵观这几年来的发展趋势,Go语言已经成为云计算、云存储时代最重要的基础编程语言。

按照惯例,介绍所有编程语言的第一个程序都是“Hello, World!”。虽然本书假设读者已经了解了Go语言,但是我们还是不想打破这个惯例(因为这个传统正是从Go语言的前辈C语言传承而来的)。下面的代码展示的Go语言程序输出的是中文“你好,世界!”。

package main

import "fmt"

func main() {
    fmt.Println("你好, 世界!")
}

将以上代码保存到hello.go文件中。因为代码中有非ASCII的中文字符,我们需要将文件的编码显式指定为无BOM的UTF8编码格式(源文件采用UTF8编码是Go语言规范所要求的)。然后进入命令行并切换到hello.go文件所在的目录。目前我们可以将Go语言当作脚本语言,在命令行中直接输入go run hello.go来运行程序。如果一切正常的话,应该可以在命令行看到输出“你好, 世界!”的结果。

现在,让我们简单介绍一下程序。所有的Go程序都由最基本的函数和变量构成,函数和变量被组织到一个个单独的Go源文件中,这些源文件再按照作者的意图组织成合适的package,最终这些package有机地组成一个完整的Go语言程序。其中,函数用于包含一系列的语句(指明要执行的操作序列),以及执行操作时存放数据的变量。我们这个程序中函数的名字是main。虽然Go语言对函数的名字没有太多的限制,但是main包中的main()函数默认是每一个可执行程序的入口。而package则用于包装和组织相关的函数、变量和常量。在使用一个package之前,我们需要使用import语句导入包。例如,我们这个程序中导入了fmt包(fmt是format的缩写,表示格式化相关的包),然后我们才可以使用fmt包中的Println()函数。

而双引号包含的“你好, 世界!”则是Go语言的字符串面值常量。和C语言中的字符串不同,Go语言中的字符串内容是不可变更的。在以字符串作为参数传递给fmt.Println()函数时,字符串的内容并没有被复制——传递的仅是字符串的地址和长度(字符串的结构在reflect.StringHeader中定义)。在Go语言中,函数参数都是以复制的方式(不支持以引用的方式)传递(比较特殊的是,Go语言闭包函数对外部变量是以引用的方式使用的)。

1.1节中简单介绍了Go语言的演化基因图谱,对其中来自贝尔实验室的特有并发编程基因做了重点介绍,最后引出了Go语言版的“Hello, World”程序。其实“Hello, World”程序是展示各种语言特性的最好的例子,是通向该语言的一个窗口。本节将沿着各个编程语言演化的时间轴(如图1-3所示),简单回顾一下“Hello, World”程序是如何逐步演化到目前的Go语言形式并最终完成它的使命的。

首先是B语言,B语言是“Go语言之父”——贝尔实验室的Ken Thompson早年间开发的一种通用的程序设计语言,设计目的是为了用于辅助UNIX系统的开发。但是由于B语言缺乏灵活的类型系统导致使用比较困难。后来,Ken Thompson的同事Dennis Ritchie以B语言为基础开发出了C语言,C语言提供了丰富的类型,极大地增强了语言的表达能力。到目前为止,C语言依然是世界上最常用的程序语言之一。而B语言自从被它取代之后,就只存在于各种文献之中,成为了历史。

目前见到的B语言版本的“Hello, World”,一般认为是来自Brian W. Kernighan编写的B语言入门教程(Go核心代码库中第一个提交者的名字正是Brian W. Kernighan),程序如下:

main() {
    extrn a, b, c;
    putchar(a); putchar(b); putchar(c);
    putchar('!*n');
}
a 'hell';
b 'o, w';
c 'orld';

由于B语言缺乏灵活的数据类型,只能分别以全局变量a/b/c来定义要输出的内容,并且每个变量的长度必须对齐到4字节(有一种写汇编语言的感觉)。然后通过多次调用putchar()函数输出字符,最后的'!*n'表示输出一个换行的意思。

总体来说,B语言简单,功能也比较有限。

C语言是由Dennis Ritchie在B语言的基础上改进而来,它增加了丰富的数据类型,并最终实现了用它重写UNIX的伟大目标。C语言可以说是现代IT行业最重要的软件基石,目前主流的操作系统几乎全部是由C语言开发的,许多基础系统软件也是C语言开发的。C系家族的编程语言占据统治地位达几十年之久,半个多世纪以来依然充满活力。

在Brian W. Kernighan于1974年左右编写的C语言入门教程中,出现了第一个C语言版本的“Hello, World”程序。这给后来大部分编程语言教程都以“Hello, World”为第一个程序提供了惯例。第一个C语言版本的“Hello, World”程序如下:

main()
{
    printf("hello, world");
}

关于这个程序,有几点需要说明:首先是main()函数因为没有明确返回值类型,所以默认返回int类型;其次printf()函数默认不需要导入函数声明即可以使用;最后main ()没有明确返回语句,但默认返回0。在这个程序出现时,C语言还远未标准化,我们看到的是早先的C语言语法:函数不用写返回值,函数参数也可以忽略,使用printf ()时不需要包含头文件等。

这个例子同样出现在了1978年出版的《C程序设计语言(第1版)》中,作者正是Brian W. Kernighan和Dennis M. Ritchie(简称K&R)。书中的“Hello, World”末尾增加了一个换行输出:

main()
{
    printf("hello, world\n");
}

这个例子在字符串末尾增加了一个换行,C语言的换行\n比B语言的换行'!*n'看起来要简洁了一些。

在K&R的教程面世10年之后的1988年,《C程序设计语言(第2版)》终于出版了。此时ANSI C语言的标准化草案已经初步完成,但正式版本的文档尚未发布。不过书中的“Hello, World”程序根据新的规范增加了#include <stdio.h>头文件包含语句,用于包含printf()函数的声明(新的C89标准中,仅是针对printf()函数而言,依然可以不用声明函数而直接使用)。

#include <stdio.h>

main()
{
    printf("hello, world\n");
}

然后到了1989年,ANSI C语言第一个国际标准发布,一般被称为C89。C89是流行最广泛的一个C语言标准,目前依然被大量使用。《C程序设计语言》也出版了新版本,并针对新发布的C89规范建议,给main()函数的参数增加了void输入参数说明,表示没有输入参数的意思。

#include <stdio.h>

main(void)
{
    printf("hello, world\n");
}

至此,C语言本身的进化基本完成。后面的C92/C99/C11都只是针对一些语言细节做了完善。因为各种历史因素,C89依然是使用最广泛的标准。

Newsqueak是Rob Pike发明的老鼠语言的第二代,是他用于实践CSP并发编程模型的战场。Newsqueak是新的Squeak语言的意思,其中squeak是老鼠“吱吱吱”的叫声,也可以看作是类似鼠标点击的声音。Squeak是一个提供鼠标和键盘事件处理的编程语言,Squeak语言的通道是静态创建的。改进版的Newsqueak语言则提供了类似C语言语句和表达式的语法和类似Pascal语言的推导语法。Newsqueak是一个带自动垃圾回收机制的纯函数式语言,它再次针对键盘、鼠标和窗口事件管理。但是在Newsqueak语言中通道是动态创建的,属于第一类值,因此可以保存到变量中。

Newsqueak类似脚本语言,内置了一个print()函数,它的“Hello, World”程序看不出什么特色:

print("Hello,", "World", "\n");

从上面的程序中,除了猜测print()函数可以支持多个参数,我们很难看到Newsqueak语言相关的特性。由于Newsqueak语言和Go语言相关的特性主要是并发和通道,因此,我们这里通过一个并发版本的“素数筛”算法来略窥Newsqueak语言的特性。“素数筛”的原理如图1-4所示。

图1-4 素数筛

Newsqueak语言并发版本的“素数筛”程序如下:

// 向通道输出从2开始的自然数序列
counter := prog(c:chan of int) {
    i := 2;
    for(;;) {
        c <-= i++;
    }
};

// 针对listen通道获取的数列,过滤掉是prime倍数的数
// 新的序列输出到send通道
filter := prog(prime:int, listen, send:chan of int) {
    i:int;
    for(;;) {
        if((i = <-listen)%prime) {
            send <-= i;
        }
    }
};

// 主函数
// 每个通道第一个流出的数必然是素数
// 然后基于这个新的素数构建新的素数过滤器
sieve := prog() of chan of int {
    c := mk(chan of int);
    begin counter(c);
    prime := mk(chan of int);
    begin prog(){
        p:int;
        newc:chan of int;
        for(;;){
            prime <-= p =<- c;
            newc = mk();
            begin filter(p, c, newc);
            c = newc;
        }
    }();
    become prime;
};

// 启动素数筛
prime := sieve();

其中counter()函数用于向通道输出原始的自然数序列,每个filter()函数对象则对应每一个新的素数过滤通道,这些素数过滤通道根据当前的素数筛将输入通道流入的数列筛选后重新输出到输出通道。mk(chan of int)用于创建通道,类似Go语言的make(chan int)语句;begin filter(p, c, newc)关键字启动素数筛的并发体,类似Go语言的go filter(p, c, newc)语句;become用于返回函数结果,类似return语句。

Newsqueak语言中并发体和通道的语法与Go语言已经比较接近了,后置的类型声明和Go语言的语法也很相似。

在Go语言出现之前,Alef语言是作者心中比较完美的并发语言,Alef语法和运行时基本是无缝兼容C语言。Alef语言中对线程和进程的并发体都提供了支持,其中proc receive(c)用于启动一个进程,task receive(c)用于启动一个线程,它们之间通过通道c进行通信。不过由于Alef缺乏内存自动回收机制,导致并发体的内存资源管理异常复杂。而且Alef语言只在Plan9系统中提供过短暂的支持,其他操作系统并没有实际可以运行的Alef开发环境。而且Alef语言只有《Alef语言规范》和《Alef编程向导》两个公开的文档,因此在贝尔实验室之外关于Alef语言的讨论并不多。

由于Alef语言同时支持进程和线程并发体,而且在并发体中可以再次启动更多的并发体,导致Alef的并发状态异常复杂。同时Alef没有自动垃圾回收机制(Alef保留的C语言灵活的指针特性,也导致自动垃圾回收机制实现比较困难),各种资源充斥于不同的线程和进程之间,导致并发体的内存资源管理异常复杂。Alef语言全部继承了C语言的语法,可以认为是增强了并发语法的C语言。图1-5给出的是Alef语言文档中展示的一个可能的并发体状态。

图1-5 Alef并发模型

Alef语言并发版本的“Hello, World”程序如下:

#include <alef.h>

void receive(chan(byte*) c) {
    byte *s;
    s = <- c;
    print("%s\n", s);
    terminate(nil);
}

void main(void) {
    chan(byte*) c;
    alloc c;
    proc receive(c);
    task receive(c);
    c <- = "hello proc or task";
    c <- = "hello proc or task";
    print("done\n");
    terminate(nil);
}

程序开头的#include <alef.h>语句用于包含Alef语言的运行时库。Receive ()是一个普通函数,用作程序中每个并发体的入口函数;main()函数中的alloc c语句先创建一个chan(byte*)类型的通道,类似Go语言的make(chan []byte)语句;然后分别以进程和线程的方式启动receive()函数;启动并发体之后,main()函数向c通道发送了两个字符串数据;而进程和线程状态运行的receive()函数会以不确定的顺序先后从通道收到数据后,分别打印字符串;最后每个并发体都通过调用terminate(nil)来结束自己。

Alef的语法和C语言基本保持一致,可以认为它是在C语言的语法基础上增加了并发编程相关的特性,可以看作是另一个维度的C++语言。

Limbo(地狱)是用于开发运行在小型计算机上的分布式应用的编程语言,它支持模块化编程、编译期和运行时的强类型检查、进程内基于具有类型的通信通道、原子性垃圾收集和简单的抽象数据类型。Limbo被设计为:即便是在没有硬件内存保护的小型设备上,也能安全运行。Limbo语言主要运行在Inferno系统之上。

Limbo语言版本的“Hello, World”程序如下:

implement Hello;

include "sys.m"; sys: Sys;
include "draw.m";

Hello: module
{
    init: fn(ctxt: ref Draw->Context, args: list of string);
};

init(ctxt: ref Draw->Context, args: list of string)
{
    sys = load Sys Sys->PATH;
    sys->print("hello, world\n");
}

从这个版本的“Hello, World”程序中,已经可以发现很多Go语言特性的雏形。第一句implement Hello;基本对应Go语言的包声明语句package Hello。然后是include "sys.m"; sys: Sys;include "draw.m";语句用于导入其他模块,类似Go语言的import "sys"import "draw"语句。Hello包模块还提供了模块初始化函数init(),并且函数的参数的类型也是后置的,不过Go语言的初始化函数是没有参数的。

贝尔实验室后来经历了多次动荡,包括Ken Thompson在内的Plan9项目原班人马最终加入了谷歌公司。在Limbo等前辈语言诞生10多年之后,在2007年底,Go语言3个最初的作者因为偶然的因素聚集到一起批斗C++(传说是C++语言的布道师在谷歌公司到处鼓吹C++11各种强大的特性彻底惹恼了他们),他们终于抽出了20%的自由时间创造了Go语言。最初的Go语言规范从2008年3月开始编写,最初的Go程序也是直接编译为C语言,然后再二次编译为机器码。到2008年5月,谷歌公司的领导们终于发现了Go语言的巨大潜力,从而开始全力支持这个项目(谷歌的创始人甚至还贡献了func关键字),让他们可以将全部工作时间投入到Go语言的设计和开发中。在Go语言规范初版完成之后,Go语言的编译器终于可以直接生成机器码了。

1.hello.go——2008年6月

下面是初期Go语言程序正式开始测试的版本:

package main

func main() int {
    print "hello, world\n";
    return 0;
}

其中内置的用于调试的print语句已经存在,不过是以命令的方式使用的。入口main()函数还和C语言中的main()函数一样返回int类型的值,而且需要return显式地返回值。每个语句末尾的分号也还存在。

2.hello.go——2008年6月27日

下面是2008年6月的Go代码:

package main

func main() {
    print "hello, world\n";
}

入口函数main()已经去掉了返回值,程序默认通过隐式调用exit(0)来返回。Go语言朝着简单的方向逐步进化。

3.hello.go——2008年8月11日

下面是2008年8月的代码:

package main

func main() {
    print("hello, world\n");
}

用于调试的内置的print由开始的命令改为普通的内置函数,使语法更加简单一致。

4.hello.go——2008年10月24日

下面是2008年10月的代码:

package main

import "fmt"

func main() {
    fmt.printf("hello, world\n");
}

作为C语言中招牌的printf ()格式化函数已经移植到了Go语言中,函数放在fmt包中(fmt是格式化单词format的缩写)。不过printf()函数名的开头字母依然是小写字母,采用大写字母表示导出的特性还没有出现。

5.hello.go——2009年1月15日

下面是2009年1月的代码:

package main

import "fmt"

func main() {
    fmt.Printf("hello, world\n");
}

Go语言开始采用是否大小写首字母来区分符号是否可以导出。大写字母开头表示导出的公共符号,小写字母开头表示包内部的私有符号。但需要注意的是,汉字中没有大小写字母的概念,因此以汉字开头的符号目前是无法导出的(针对该问题,中国用户已经给出相关建议,等Go 2之后或许会调整对汉字的导出规则)。

6.hello.go——2009年12月11日

下面是2009年12月的代码:

package main

import "fmt"

func main() {
    fmt.Printf("hello, world\n")
}

Go语言终于移除了语句末尾的分号。这是Go语言在2009年11月10日正式开源之后第一个比较重要的语法改进。从1978年C语言教程第一版引入的分号分隔的规则到现在,Go语言的作者们花了整整32年终于移除了语句末尾的分号。在这32年的演化过程中必然充满了各种八卦故事,我想这一定是Go语言设计者深思熟虑的结果(现在Swift等新的语言也是默认忽略分号的,可见分号确实并不是那么重要)。

在经过半个世纪的涅槃重生之后,Go语言不仅打印出了Unicode版本的“Hello, World”,而且可以方便地向全球用户提供打印服务。下面版本通过http服务向每个访问的客户端打印中文的“你好, 世界!”和当前的时间信息。

package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {
    fmt.Println("Please visit http://127.0.0.1:12345/")
    http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
        s := fmt.Sprintf("你好, 世界! -- Time: %s", time.Now().String())
        fmt.Fprintf(w, "%v\n", s)
        log.Printf("%v\n", s)
    })
    if err := http.ListenAndServe(":12345", nil); err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

这里我们通过Go语言标准库自带的net/http包,构造了一个独立运行的HTTP服务。其中http.HandleFunc("/", ...)针对根路径/请求注册了响应处理函数。在响应处理函数中,我们依然使用fmt.Fprintf ()格式化输出函数实现了通过HTTP协议向请求的客户端打印格式化的字符串,同时通过标准库的日志包在服务器端也打印相关字符串。最后通过http.ListenAndServe()函数调用来启动HTTP服务。

至此,Go语言终于完成了从单机单核时代的C语言到21世纪互联网时代多核环境的通用编程语言的蜕变。

在主流的编程语言中数组及其相关的数据结构是使用得最为频繁的,只有在它(们)不能满足时才会考虑链表、散列表(散列表可以看作是数组和链表的混合体)和更复杂的自定义数据结构。

Go语言中数组、字符串和切片三者是密切相关的数据结构。这3种数据类型,在底层原始数据有着相同的内存结构,在上层,因为语法的限制而有着不同的行为表现。首先,Go语言的数组是一种值类型,虽然数组的元素可以被修改,但是数组本身的赋值和函数传参都是以整体复制的方式处理的。Go语言字符串底层数据也是对应的字节数组,但是字符串的只读属性禁止了在程序中对底层字节数组的元素的修改。字符串赋值只是复制了数据地址和对应的长度,而不会导致底层数据的复制。切片的行为更为灵活,切片的结构和字符串结构类似,但是解除了只读限制。切片的底层数据虽然也是对应数据类型的数组,但是每个切片还有独立的长度和容量信息,切片赋值和函数传参时也是将切片头信息部分按传值方式处理。因为切片头含有底层数据的指针,所以它的赋值也不会导致底层数据的复制。其实Go语言的赋值和函数传参规则很简单,除闭包函数以引用的方式对外部变量访问之外,其他赋值和函数传参都是以传值的方式处理。要理解数组、字符串和切片这3种不同的处理方式的原因,需要详细了解它们的底层数据结构。

数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。数组的长度是数组类型的组成部分。因为数组的长度是数组类型的一部分,不同长度或不同类型的数据组成的数组都是不同的类型,所以在Go语言中很少直接使用数组(不同长度的数组因为类型不同无法直接赋值)。和数组对应的类型是切片,切片是可以动态增长和收缩的序列,切片的功能也更加灵活,但是要理解切片的工作原理还是要先理解数组。

我们先看看数组有哪些定义方式:

var a [3]int                       // 定义长度为3的int型数组,元素全部为0
var b = [...]int{1, 2, 3}          // 定义长度为3的int型数组,元素为1, 2, 3
var c = [...]int{2: 3, 1: 2}       // 定义长度为3的int型数组,元素为0, 2, 3
var d = [...]int{1, 2, 4: 5, 6}    // 定义长度为6的int型数组,元素为1, 2, 0, 0, 5, 6

第一种方式是定义一个数组变量的最基本的方式,数组的长度明确指定,数组中的每个元素都以零值初始化。

第二种方式是定义数组,可以在定义的时候顺序指定全部元素的初始化值,数组的长度根据初始化元素的数目自动计算。

第三种方式是以索引的方式来初始化数组的元素,因此元素的初始化值出现顺序比较随意。这种初始化方式和map[int]Type类型的初始化语法类似。数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用零值初始化。

第四种方式是混合了第二种和第三种的初始化方式,前面两个元素采用顺序初始化,第三个和第四个元素采用零值初始化,第五个元素通过索引初始化,最后一个元素跟在前面的第五个元素之后采用顺序初始化。

数组的内存结构比较简单。例如,图1-6给出的是一个[4]int{2,3,5,7}数组值对应的内存结构。

图1-6 数组布局

Go语言中数组是值语义。一个数组变量即表示整个数组,它并不是隐式地指向第一个元素的指针(例如C语言的数组),而是一个完整的值。当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。

var a = [...]int{1, 2, 3} // a是一个数组
var b = &a                // b是指向数组的指针

fmt.Println(a[0], a[1])   // 打印数组的前两个元素
fmt.Println(b[0], b[1])   // 通过数组指针访问数组元素的方式和通过数组类似

for i, v := range b {     // 通过数组指针迭代数组的元素
    fmt.Println(i, v)
}

其中b是指向数组a的指针,但是通过b访问数组中元素的写法和a是类似的。还可以通过for range来迭代数组指针指向的数组元素。其实数组指针类型除类型和数组不同之外,通过数组指针操作数组的方式和通过数组本身的操作类似,而且数组指针赋值时只会复制一个指针。但是数组指针类型依然不够灵活,因为数组的长度是数组类型的组成部分,指向不同长度数组的数组指针类型也是完全不同的。

可以将数组看作一个特殊的结构体,结构的字段名对应数组的索引,同时结构体成员的数目是固定的。内置函数len()可以用于计算数组的长度,cap()函数可以用于计算数组的容量。不过对数组类型来说,len()cap()函数返回的结果始终是一样的,都是对应数组类型的长度。

我们可以用for循环来迭代数组。下面常见的几种方式都可以用来遍历数组:

for i := range a {
    fmt.Printf("a[%d]: %d\n", i, a[i])
}
for i, v := range b {
    fmt.Printf("b[%d]: %d\n", i, v)
}
for i := 0; i < len(c); i++ {
    fmt.Printf("c[%d]: %d\n", i, c[i])
}

for range方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形,每轮迭代对数组元素的访问时可以省去对下标越界的判断。

for range方式迭代,还可以忽略迭代时的下标:

var times [5][0]int
for range times {
    fmt.Println("hello")
}

其中times对应一个[5][0]int类型的数组,虽然第一维数组有长度,但是数组的元素[0]int大小是0,因此整个数组占用的内存大小依然是0。不用付出额外的内存代价,我们就通过for range方式实现times次快速迭代。

数组不仅可以定义数值数组,还可以定义字符串数组、结构体数组、函数数组、接口数组、通道数组等:

// 字符串数组
var s1 = [2]string{"hello", "world"}
var s2 = [...]string{"你好", "世界"}
var s3 = [...]string{1: "世界", 0: "你好", }

// 结构体数组
var line1 [2]image.Point
var line2 = [...]image.Point{image.Point{X: 0, Y: 0}, image.Point{X: 1, Y: 1}}
var line3 = [...]image.Point{{0, 0}, {1, 1}}

// 函数数组
var decoder1 [2]func(io.Reader) (image.Image, error)
var decoder2 = [...]func(io.Reader) (image.Image, error){
    png.Decode,
    jpeg.Decode,
}

// 接口数组
var unknown1 [2]interface{}
var unknown2 = [...]interface{}{123, "你好"}

// 通道数组
var chanList = [2]chan int{}

我们还可以定义一个空的数组:

var d [0]int         // 定义一个长度为0的数组
var e = [0]int{}     // 定义一个长度为0的数组
var f = [...]int{}   // 定义一个长度为0的数组

长度为0的数组(空数组)在内存中并不占用空间。空数组虽然很少直接使用,但是可以用于强调某种特有类型的操作时避免分配额外的内存空间,例如用于通道的同步操作:

c1 := make(chan [0]int)
go func() {
    fmt.Println("c1")
    c1 <- [0]int{}
}()
<-c1

在这里,我们并不关心通道中传输数据的真实类型,其中通道接收和发送操作只是用于消息的同步。对于这种场景,我们用空数组作为通道类型可以减少通道元素赋值时的开销。当然,一般更倾向于用无类型的匿名结构体代替空数组:

c2 := make(chan struct{})
go func() {
    fmt.Println("c2")
    c2 <- struct{}{} // struct{}部分是类型,{}表示对应的结构体值
}()
<-c2

我们可以用fmt.Printf()函数提供的%T%#v谓词语法来打印数组的类型和详细信息:

fmt.Printf("b: %T\n", b)  // b: [3]int
fmt.Printf("b: %#v\n", b) // b: [3]int{1, 2, 3}

在Go语言中,数组类型是切片和字符串等结构的基础。以上对于数组的很多操作都可以直接用于字符串或切片中。

一个字符串是一个不可改变的字节序列,字符串通常是用来包含人类可读的文本数据。和数组不同的是,字符串的元素不可修改,是一个只读的字节数组。每个字符串的长度虽然也是固定的,但是字符串的长度并不是字符串类型的一部分。由于Go语言的源代码要求是UTF8编码,导致Go源代码中出现的字符串面值常量一般也是UTF8编码的。源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列。因为字节序列对应的是只读的字节序列,所以字符串可以包含任意的数据,包括字节值0。我们也可以用字符串表示GBK等非UTF8编码的数据,不过这时候将字符串看作是一个只读的二进制数组更准确,因为for range等语法并不能支持非UTF8编码的字符串的遍历。

Go语言字符串的底层结构在reflect.StringHeader中定义:

type StringHeader struct {
    Data uintptr
    Len  int
}

字符串结构由两个信息组成:第一个是字符串指向的底层字节数组;第二个是字符串的字节的长度。字符串其实是一个结构体,因此字符串的赋值操作也就是reflect.StringHeader结构体的复制过程,并不会涉及底层字节数组的复制。1.3.1节中提到的[2]string字符串数组对应的底层结构和[2]reflect.StringHeader对应的底层结构是一样的,可以将字符串数组看作一个结构体数组。

我们可以看看字符串"hello, world"本身对应的内存结构,如图1-7所示。

图1-7 字符串布局

分析可以发现,"hello, world"字符串底层数据和以下数组是完全一致的:

var data = [...]byte{
    'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd',
}

字符串虽然不是切片,但是支持切片操作,不同位置的切片底层访问的是同一块内存数据(因为字符串是只读的,所以相同的字符串面值常量通常对应同一个字符串常量):

s := "hello, world"
hello := s[:5]
world := s[7:]

s1 := "hello, world"[:5]
s2 := "hello, world"[7:]

字符串和数组类似,内置的len()函数返回字符串的长度。也可以通过reflect.StringHeader结构访问字符串的长度(这里只是为了演示字符串的结构,并不是推荐的做法):

fmt.Println("len(s):", (*reflect.StringHeader)(unsafe.Pointer(&s)).Len)   // 12
fmt.Println("len(s1):", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Len) // 5
fmt.Println("len(s2):", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Len) // 5

根据Go语言规范,Go语言的源文件都采用UTF8编码。因此,Go源文件中出现的字符串面值常量一般也是UTF8编码的(对于转义字符,则没有这个限制)。提到Go字符串时,一般都会假设字符串对应的是一个合法的UTF8编码的字符序列。可以用内置的print调试函数或fmt.Print()函数直接打印,也可以用for range循环直接遍历UTF8解码后的Unicode码点值。

下面的"hello,世界"字符串中包含了中文字符,可以通过打印转型为字节类型来查看字符底层对应的数据:

fmt.Printf("%#v\n", []byte("hello, 世界"))

输出的结果是:

[]byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, \
0x95, 0x8c}

分析可以发现,0xe4, 0xb8, 0x96对应中文“世”,0xe7, 0x95, 0x8c对应中文“界”。我们也可以在字符串面值中直接指定UTF8编码后的值(源文件中全部是ASCII码,可以避免出现多字节的字符)。

fmt.Println("\xe4\xb8\x96") // 打印“世”
fmt.Println("\xe7\x95\x8c") // 打印“界”

图1-8展示了“hello, 世界”字符串的内存结构布局。

图1-8 字符串布局

Go语言的字符串中可以存放任意的二进制字节序列,而且即使是UTF8字符序列也可能会遇到错误的编码。如果遇到一个错误的UTF8编码输入,将生成一个特别的Unicode字符'\uFFFD',这个字符在不同的软件中的显示效果可能不太一样,在印刷中这个符号通常是一个黑色六角形或钻石形状,里面包含一个白色的问号“�”。

下面的字符串中,我们故意损坏了第一字符的第二和第三字节,因此第一字符将会打印为“�”,第二和第三字节则被忽略,后面的“abc”依然可以正常解码打印(错误编码不会向后扩散是UTF8编码的优秀特性之一)。

fmt.Println("\xe4\x00\x00\xe7\x95\x8cabc") // �界abc

不过在for range迭代这个含有损坏的UTF8字符串时,第一字符的第二和第三字节依然会被单独迭代到,不过此时迭代的值是损坏后的0:

for i, c := range "\xe4\x00\x00\xe7\x95\x8cabc" {
    fmt.Println(i, c)
}
// 0 65533  // \uFFF,对应�
// 1 0      // 空字符
// 2 0      // 空字符
// 3 30028  // 界
// 6 97     // a
// 7 98     // b
// 8 99     // c

如果不想解码UTF8字符串,想直接遍历原始的字节码,可以将字符串强制转为[]byte字节序列后再进行遍历(这里的转换一般不会产生运行时开销):

for i, c := range []byte("世界abc") {
    fmt.Println(i, c)
}

或者是采用传统的下标方式遍历字符串的字节数组:

const s = "\xe4\x00\x00\xe7\x95\x8cabc"
for i := 0; i < len(s); i++ {
    fmt.Printf("%d %x\n", i, s[i])
}

Go语言除了for range语法对UTF8字符串提供了特殊支持外,还对字符串和[]rune类型的相互转换提供了特殊的支持。

fmt.Printf("%#v\n", []rune("世界"))              // []int32{19990, 30028}
fmt.Printf("%#v\n", string([]rune{'世', '界'}))  // 世界

从上面代码的输出结果可以发现[]rune其实是[]int32类型,这里的rune只是int32类型的别名,并不是重新定义的类型。rune用于表示每个Unicode码点,目前只使用了21个位。

字符串相关的强制类型转换主要涉及[]byte[]rune两种类型。每个转换都可能隐含重新分配内存的代价,最坏的情况下它们运算的时间复杂度都是O(n)。不过字符串和[]rune的转换要更为特殊一些,因为一般这种强制类型转换要求两个类型的底层内存结构要尽量一致,显然它们底层对应的[]byte[]int32类型是完全不同的内存结构,因此这种转换可能隐含重新分配内存的 操作。

下面分别用伪代码简单模拟Go语言对字符串内置的一些操作,这样对每个操作的处理的时间复杂度和空间复杂度都会有较明确的认识。

for range对字符串的迭代模拟实现如下:

func forOnString(s string, forBody func(i int, r rune)) {
    for i := 0; len(s) > 0; {
        r, size := utf8.DecodeRuneInString(s)
        forBody(i, r)
        s = s[size:]
        i += size
    }
}

for range迭代字符串时,每次解码一个Unicode字符,然后进入for循环体,遇到崩溃的编码并不会导致迭代停止。

[]byte(s)转换模拟实现如下:

func str2bytes(s string) []byte {
    p := make([]byte, len(s))
    for i := 0; i < len(s); i++ {
        c := s[i]
        p[i] = c
    }
    return p
}

模拟实现中新创建了一个切片,然后将字符串的数组逐一复制到切片中,这是为了保证字符串只读的语义。当然,在将字符串转换为[]byte时,如果转换后的变量没有被修改,编译器可能会直接返回原始的字符串对应的底层数据。

string(bytes)转换模拟实现如下:

func bytes2str(s []byte) (p string) {
    data := make([]byte, len(s))
    for i, c := range s {
        data[i] = c
    }

    hdr := (*reflect.StringHeader)(unsafe.Pointer(&p))
    hdr.Data = uintptr(unsafe.Pointer(&data[0]))
    hdr.Len = len(s)

    return p
}

因为Go语言的字符串是只读的,无法以直接构造底层字节数组的方式生成字符串。在模拟实现中通过unsafe包获取字符串的底层数据结构,然后将切片的数据逐一复制到字符串中,这同样是为了保证字符串只读的语义不受切片的影响。如果转换后的字符串在生命周期中原始的[]byte的变量不发生变化,编译器可能会直接基于[]byte底层的数据构建字符串。

[]rune(s)转换模拟实现如下:

func str2runes(s []byte) []rune {
    var p []int32
    for len(s) > 0 {
        r, size := utf8.DecodeRune(s)
        p = append(p, int32(r))
        s = s[size:]
    }
    return []rune(p)
}

因为底层内存结构的差异,所以字符串到[]rune的转换必然会导致重新分配[]rune内存空间,然后依次解码并复制对应的Unicode码点值。这种强制转换并不存在前面提到的字符串和字节切片转换时的优化情况。

string(runes)转换模拟实现如下:

func runes2string(s []int32) string {
    var p []byte
    buf := make([]byte, 3)
    for _, r := range s {
        n := utf8.EncodeRune(buf, r)
        p = append(p, buf[:n]...)
    }
    return string(p)
}

同样因为底层内存结构的差异,[]rune到字符串的转换也必然会导致重新构造字符串。这种强制转换并不存在前面提到的优化情况。

简单地说,切片(slice)就是一种简化版的动态数组。因为动态数组的长度不固定,所以切片的长度自然也就不能是类型的组成部分了。数组虽然有适用的地方,但是数组的类型和操作都不够灵活,因此在Go代码中数组使用得并不多。而切片则使用得相当广泛,理解切片的原理和用法是Go程序员的必备技能。

我们先看看切片的结构定义,即reflect.SliceHeader

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

由此可以看出切片的开头部分和Go字符串是一样的,但是切片多了一个Cap成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。图1-9给出了x := []int{2,3,5, 7,11}y := x[1:3]两个切片对应的内存结构。

图1-9 切片布局

让我们看看切片有哪些定义方式:

var (
    a []int                 // nil切片,和nil相等,一般用来表示一个不存在的切片
    b = []int{}             // 空切片,和nil不相等,一般用来表示一个空的集合
    c = []int{1, 2, 3}      // 有3个元素的切片,len和cap都为3
    d = c[:2]               // 有2个元素的切片,len为2,cap为3
    e = c[0:2:cap(c)]       // 有2个元素的切片,len为2,cap为3
    f = c[:0]               // 有0个元素的切片,len为0,cap为3
    g = make([]int, 3)      // 有3个元素的切片,len和cap都为3
    h = make([]int, 2, 3)   // 有2个元素的切片,len为2,cap为3
    i = make([]int, 0, 3)   // 有0个元素的切片,len为0,cap为3
)

和数组一样,内置的len()函数返回切片中有效元素的长度,内置的cap()函数返回切片容量大小,容量必须大于或等于切片的长度。也可以通过reflect.SliceHeader结构访问切片的信息(只是为了说明切片的结构,并不是推荐的做法)。切片可以和nil进行比较,只有当切片底层数据指针为空时切片本身才为nil,这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空,但是长度和容量不为0的情况,那么说明切片本身已经被损坏了(例如,直接通过reflect.SliceHeaderunsafe包对切片作了不正确的修改)。

遍历切片的方式和遍历数组的方式类似:

for i := range a {
    fmt.Printf("a[%d]: %d\n", i, a[i])
}
for i, v := range b {
    fmt.Printf("b[%d]: %d\n", i, v)
}
for i := 0; i < len(c); i++ {
    fmt.Printf("c[%d]: %d\n", i, c[i])
}

其实除了遍历之外,只要是切片的底层数据指针、长度和容量没有发生变化,对切片的遍历、元素的读取和修改就和数组一样。在对切片本身进行赋值或参数传递时,和数组指针的操作方式类似,但是只复制切片头信息(reflect.SliceHeader),而不会复制底层的数据。对于类型,和数组的最大不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。

如前所述,切片是一种简化版的动态数组,这是切片类型的灵魂。除构造切片和遍历切片之外,添加切片元素、删除切片元素都是切片处理中经常遇到的操作。

1.添加切片元素

内置的泛型函数append()可以在切片的尾部追加N个元素:

var a []int
a = append(a, 1)                  // 追加一个元素
a = append(a, 1, 2, 3)            // 追加多个元素,手写解包方式
a = append(a, []int{1,2,3}...)    // 追加一个切片,切片需要解包

不过要注意的是,在容量不足的情况下,append ()操作会导致重新分配内存,可能导致巨大的内存分配和复制数据的代价。即使容量足够,依然需要用append()函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。

除了在切片的尾部追加,还可以在切片的开头添加元素:

var a = []int{1,2,3}
a = append([]int{0}, a...)        // 在开头添加一个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加一个切片

在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制一次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。

由于append()函数返回新的切片,也就是它支持链式操作,因此我们可以将多个append ()操作组合起来,实现在切片中间插入元素:

var a []int
a = append(a[:i], append([]int{x}, a[i:]...)...)      // 在第i个位置插入x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...)  // 在第i个位置插入切片

每个添加操作中的第二个append ()调用都会创建一个临时切片,并将a[i:]的内容复制到新创建的切片中,然后将临时创建的切片再追加到a[:i]

copy()append()组合可以避免创建中间的临时切片,同样是完成添加元素的操作:

a = append(a, 0)      // 切片扩展一个空间
copy(a[i+1:], a[i:])  // a[i:]向后移动一个位置
a[i] = x              // 设置新添加的元素

第一句中的append()用于扩展切片的长度,为要插入的元素留出空间。第二句中的copy()操作将要插入位置开始之后的元素向后挪动一个位置。第三句真实地将新添加的元素赋值到对应的位置。操作语句虽然冗长了一点,但是相比前面的方法,可以减少中间创建的临时切片。

copy()append()组合也可以实现在中间位置插入多个元素(也就是插入一个切片):

a = append(a, x...)         // 为x切片扩展足够的空间
copy(a[i+len(x):], a[i:])   // a[i:]向后移动len(x)个位置
copy(a[i:], x)              // 复制新添加的切片

稍显不足的是,在第一句扩展切片容量的时候,扩展空间部分的元素复制是没有必要的。没有专门的内置函数用于扩展切片的容量,append()本质是用于追加元素而不是扩展容量,扩展切片容量只是append()的一个副作用。

2.删除切片元素

根据要删除元素的位置,有从开头位置删除、从中间位置删除和从尾部删除3种情况,其中删除切片尾部的元素最快:

a = []int{1, 2, 3}
a = a[:len(a)-1]   // 删除尾部1个元素
a = a[:len(a)-N]   // 删除尾部N个元素

删除开头的元素可以直接移动数据指针:

a = []int{1, 2, 3}
a = a[1:] // 删除开头1个元素
a = a[N:] // 删除开头N个元素

删除开头的元素也可以不移动数据指针,而将后面的数据向开头移动。可以用append()原地完成(所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化):

a = []int{1, 2, 3}
a = append(a[:0], a[1:]...) // 删除开头1个元素
a = append(a[:0], a[N:]...) // 删除开头N个元素

也可以用copy()完成删除开头的元素:

a = []int{1, 2, 3}
a = a[:copy(a, a[1:])] // 删除开头1个元素
a = a[:copy(a, a[N:])] // 删除开头N个元素

对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用append()copy()原地完成:

a = []int{1, 2, 3, ...}

a = append(a[:i], a[i+1:]...) // 删除中间1个元素
a = append(a[:i], a[i+N:]...) // 删除中间N个元素

a = a[:i+copy(a[i:], a[i+1:])]  // 删除中间1个元素
a = a[:i+copy(a[i:], a[i+N:])]  // 删除中间N个元素

删除开头的元素和删除尾部的元素都可以认为是删除中间元素操作的特殊情况。

3.切片内存技巧

在本节开头的数组部分我们提到过有类似[0]int的空数组,空数组一般很少用到。但是对于切片来说,len0但是cap容量不为0的切片则是非常有用的特性。当然,如果lencap都为0的话,则变成一个真正的空切片,虽然它并不是一个nil的切片。在判断一个切片是否为空时,一般通过len获取切片的长度来判断,一般很少将切片和nil做直接的比较。

例如下面的TrimSpace()函数用于删除[]byte中的空格。函数实现利用了长度为0的切片的特性,实现高效而且简洁。

func TrimSpace(s []byte) []byte {
    b := s[:0]
    for _, x := range s {
        if x != ' ' {
            b = append(b, x)
        }
    }
    return b
}

其实类似的根据过滤条件原地删除切片元素的算法都可以采用类似的方式处理(因为是删除操作,所以不会出现内存不足的情形):

func Filter(s []byte, fn func(x byte) bool) []byte {
    b := s[:0]
    for _, x := range s {
        if !fn(x) {
            b = append(b, x)
        }
    }
    return b
}

切片高效操作的要点是要降低内存分配的次数,尽量保证append()操作不会超出cap的容量,降低触发内存分配的次数和每次分配内存的大小。

4.避免切片内存泄漏

如前所述,切片操作并不会复制底层的数据。底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟垃圾回收器对底层数组的回收。

例如,FindPhoneNumber()函数加载整个文件到内存,然后搜索第一个出现的电话号码,最后结果以切片方式返回。

func FindPhoneNumber(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return regexp.MustCompile("[0-9]+").Find(b)
}

这段代码返回的[]byte指向保存整个文件的数组。由于切片引用了整个原始数组,导致垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然不是传统意义上的内存泄漏,但是可能会降低系统的整体性能。

要解决这个问题,可以将感兴趣的数据复制到一个新的切片中(数据的传值是Go语言编程的一个哲学,虽然传值有一定的代价,但是换取的好处是切断了对原始数据的依赖):

func FindPhoneNumber(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = regexp.MustCompile("[0-9]+").Find(b)
    return append([]byte{}, b...)
}

类似的问题在删除切片元素时可能会遇到。假设切片里存放的是指针对象,那么下面删除末尾的元素后,被删除的元素依然被切片底层数组引用,从而导致不能及时被垃圾回收器回收(这要依赖回收器的实现方式):

var a []*int{ ... }
a = a[:len(a)-1]    // 被删除的最后一个元素依然被引用,可能导致垃圾回收器操作被阻碍

保险的方式是先将指向需要提前回收内存的指针设置为nil,保证垃圾回收器可以发现需要回收的对象,然后再进行切片的删除操作:

var a []*int{ ... }
a[len(a)-1] = nil // 垃圾回收器回收最后一个元素内存
a = a[:len(a)-1]  // 从切片删除最后一个元素

当然,如果切片存在的周期很短的话,可以不用刻意处理这个问题。因为如果切片本身已经可以被垃圾回收器回收的话,切片对应的每个元素自然也就可以被回收了。

5.切片类型强制转换

为了安全,当两个切片类型[]T[]Y的底层原始切片类型不同时,Go语言是无法直接转换类型的。不过安全都是有一定代价的,有时候这种转换是有它的价值的——可以简化编码或者是提升代码的性能。例如在64位系统上,需要对一个[]float64切片进行高速排序,我们可以将它强制转换为[]int整数切片,然后以整数的方式进行排序(因为float64遵循IEEE 754浮点数标准特性,所以当浮点数有序时对应的整数也必然是有序的)。

下面的代码通过两种方法将[]float64类型的切片转换为[]int类型的切片:

// +build amd64 arm64

import "sort"

var a = []float64{4, 2, 5, 7, 2, 1, 88, 1}

func SortFloat64FastV1(a []float64) {
    // 强制类型转换
    var b []int = ((*[1 << 20]int)(unsafe.Pointer(&a[0])))[:len(a):cap(a)]

    // 以int方式给float64排序
    sort.Ints(b)
}

func SortFloat64FastV2(a []float64) {
    // 通过reflect.SliceHeader更新切片头部信息实现转换
    var c []int
    aHdr := (*reflect.SliceHeader)(unsafe.Pointer(&a))
    cHdr := (*reflect.SliceHeader)(unsafe.Pointer(&c))
    *cHdr = *aHdr

    // 以int方式给float64排序
    sort.Ints(c)
}

第一种强制转换是先将切片数据的开始地址转换为一个较大的数组的指针,然后对数组指针对应的数组重新做切片操作。中间需要unsafe.Pointer来连接两个不同类型的指针传递。需要注意的是,Go语言实现中非0大小数组的长度不得超过2 GB,因此需要针对数组元素的类型大小计算数组的最大长度范围([]uint8最大2 GB,[]uint16最大1 GB,依此类推,但是[]struct{}数组的长度可以超过2 GB)。

第二种转换操作是分别取两个不同类型的切片头信息指针,任何类型的切片头部信息底层都对应reflect.SliceHeader结构,然后通过更新结构体方式来更新切片信息,从而实现a对应的[]float64切片到c对应的[]int切片的转换。

通过基准测试,可以发现用sort.Ints对转换后的[]int排序的性能要比用sort.Float64s排序的性能高一点。不过需要注意的是,这个方法可行的前提是要保证[]float64中没有NaNInf等非规范的浮点数(因为浮点数中NaN不可排序,正0和负0相等,但是整数中没有这类情形)。

函数对应操作序列,是程序的基本组成元素。Go语言中的函数有具名和匿名之分:具名函数一般对应于包级的函数,是匿名函数的一种特例。当匿名函数引用了外部作用域中的变量时就成了闭包函数,闭包函数是函数式编程语言的核心。方法是绑定到一个具体类型的特殊函数,Go语言中的方法是依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的。Go语言通过隐式接口机制实现了鸭子面向对象模型。

在Go语言中,函数是第一类对象,可以将函数保存到变量中。函数主要有具名和匿名之分,包级函数一般都是具名函数,具名函数是匿名函数的一种特例。当然,Go语言中每个类型还可以有自己的方法,方法其实也是函数的一种。

// 具名函数
func Add(a, b int) int {
    return a+b
}

// 匿名函数
var Add = func(a, b int) int {
    return a+b
}

Go语言中的函数可以有多个参数和多个返回值,参数和返回值都是以传值的方式和被调用者交换数据。在语法上,函数还支持可变数量的参数,可变数量的参数必须是最后出现的参数,可变数量的参数其实是一个切片类型的参数。

// 多个参数和多个返回值
func Swap(a, b int) (int, int) {
    return b, a
}

// 可变数量的参数
// more对应[]int切片类型
func Sum(a int, more ...int) int {
    for _, v := range more {
        a += v
    }
    return a
}

当可变参数是一个空接口类型时,调用者是否解包可变参数会导致不同的结果:

func main() {
    var a = []interface{}{123, "abc"}

    Print(a...) // 123 abc
    Print(a)    // [123 abc]
}

func Print(a ...interface{}) {
    fmt.Println(a...)
}

第一个Print调用时传入的参数是a...,等价于直接调用Print(123, "abc")。第二个Print调用传入的是未解包的a,等价于直接调用Print([]interface{}{123, "abc"} )

不仅函数的参数可以有名字,也可以给函数的返回值命名:

func Find(m map[int]int, key int) (value int, ok bool) {
    value, ok = m[key]
    return
}

如果返回值命名了,可以通过名字来修改返回值,也可以通过defer语句在return语句之后修改返回值:

func Inc() (v int) {
    defer func(){ v++ } ()
    return 42
}

其中defer语句延迟执行了一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量v,这种函数我们一般称为闭包。闭包对捕获的外部变量并不是以传值方式访问,而是以引用方式 访问。

闭包的这种以引用方式访问外部变量的行为可能会导致一些隐含的问题:

func main() {
    for i := 0; i < 3; i++ {
        defer func(){ println(i) } ()
    }
}
// Output:
// 3
// 3
// 3

因为是闭包,在for迭代语句中,每个defer语句延迟执行的函数引用的都是同一个i迭代变量,在循环结束后这个变量的值为3,因此最终输出的都是3。

修复的思路是在每轮迭代中为每个defer语句的闭包函数生成独有的变量。可以用下面两种 方式:

func main() {
    for i := 0; i < 3; i++ {
        i := i // 定义一个循环体内局部变量i
        defer func(){ println(i) } ()
    }
}

func main() {
    for i := 0; i < 3; i++ {
        // 通过函数传入i
        // defer 语句会马上对调用参数求值
        defer func(i int){ println(i) } (i)
    }
}

第一种方法是在循环体内部再定义一个局部变量,这样每次迭代defer语句的闭包函数捕获的都是不同的变量,这些变量的值对应迭代时的值。第二种方式是将迭代变量通过闭包函数的参数传入,defer语句会马上对调用参数求值。两种方式都是可以工作的。不过一般来说,在for循环内部执行defer语句并不是一个好的习惯,此处仅为示例,不建议使用。

Go语言中,如果以切片为参数调用函数,有时候会给人一种参数采用了传引用的方式的假象:因为在被调用函数内部可以修改传入的切片的元素。其实,任何可以通过函数参数修改调用参数的情形,都是因为函数参数中显式或隐式传入了指针参数。函数参数传值的规范更准确说是只针对数据结构中固定的部分传值,例如字符串或切片对应结构体中的指针和字符串长度结构体传值,但是并不包含指针间接指向的内容。将切片类型的参数替换为类似reflect.SliceHeader结构体就能很好理解切片传值的含义了:

func twice(x []int) {
    for i := range x {
        x[i] *= 2
    }
}

type IntSliceHeader struct {
    Data []int
    Len  int
    Cap  int
}

func twice(x IntSliceHeader) {
    for i := 0; i < x.Len; i++ {
        x.Data[i] *= 2
    }
}

因为切片中的底层数组部分通过隐式指针传递(指针本身依然是传值的,但是指针指向的却是同一份的数据),所以被调用函数可以通过指针修改调用参数切片中的数据。除数据之外,切片结构还包含了切片长度和切片容量信息,这两个信息也是传值的。如果被调用函数中修改了LenCap信息,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是内置的append ()必须要返回一个切片的原因。

Go语言中,函数还可以直接或间接地调用自己,也就是支持递归调用。Go语言函数的递归调用深度在逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为Go语言运行时会根据需要动态地调整函数栈的大小。每个Goroutine刚启动时只会分配很小的栈(4 KB或8 KB,具体依赖实现),根据需要动态调整栈的大小,栈最大可以达到GB级(依赖具体实现,在目前的实现中,32位体系结构为250 MB,64位体系结构为1 GB)。在Go 1.4以前,Go的动态栈采用的是分段式的动态栈,通俗地说就是采用一个链表来实现动态栈,每个链表的节点内存位置不会发生变化。但是链表实现的动态栈对某些导致跨越链表不同节点的热点调用的性能影响较大,因为相邻的链表节点在内存位置一般不是相邻的,这会增加CPU高速缓存命中失败的概率。为了解决热点调用的CPU缓存命中率问题,Go 1.4之后改用连续的动态栈实现,也就是采用一个类似动态数组的结构来表示栈。不过连续动态栈也带来了新的问题:当连续栈动态增长时,需要将之前的数据移动到新的内存空间,这会导致之前栈中全部变量的地址发生变化。虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白Go语言中指针不再是固定不变的(因此不能随意将指针保存到数值变量中,Go语言的地址也不能随意保存到不在垃圾回收器控制的环境中,因此使用CGO时不能在C语言中长期持有Go语言对象的地址)。

因为Go语言函数的栈会自动调整大小,所以普通Go程序员已经很少需要关心栈的运行机制了。在Go语言规范中甚至故意没有讲到栈和堆的概念。我们无法知道函数参数或局部变量到底是保存在栈中还是堆中,我们只需要知道它们能够正常工作就可以了。看看下面这个例子:

func f(x int) *int {
    return &x
}

func g() int {
    x = new(int)
    return *x
}

第一个函数直接返回了函数参数变量的地址——这似乎是不可以的,因为如果参数变量在栈上,函数返回之后栈变量就失效了,返回的地址自然也应该失效了。但是Go语言的编译器和运行时比我们聪明得多,它会保证指针指向的变量在合适的地方。第二个函数,内部虽然调用new()函数创建了*int类型的指针对象,但是依然不知道它具体保存在哪里。对于有C/C++编程经验的程序员需要强调的是:不用关心Go语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。

方法一般是面向对象编程(Object-Oriented Programming,OOP)的一个特性,在C++语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。一个面向对象的程序会用方法来表达其属性对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。面向对象编程进入主流开发领域一般认为是从C++开始的,C++就是在兼容C语言的基础之上支持了类等面向对象的特性。然后Java编程则号称是纯粹的面向对象语言,因为Java中函数是不能独立存在的,每个函数都必然是属于某个类的。

面向对象编程更多的只是一种思想,很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。Go语言的祖先C语言虽然不是一个支持面向对象的语言,但是C语言的标准库中的File相关的函数也用到了面向对象编程的思想。下面我们实现一组C语言风格的File函数:

// 文件对象
type File struct {
    fd int
}

// 打开文件
func OpenFile(name string) (f *File, err error) {
    // ...
}

// 关闭文件
func CloseFile(f *File) error {
    // ...
}

// 读文件数据
func ReadFile(f *File, int64 offset, data []byte) int {
    // ...
}

其中OpenFile()类似于构造函数,用于打开文件对象,CloseFile()类似于析构函数,用于关闭文件对象,ReadFile()则类似于普通的成员函数,这3个函数都是普通函数。CloseFile()ReadFile()作为普通函数,需要占用包级空间中的名字资源。不过CloseFile()ReadFile()函数只是针对File类型对象的操作,这时候我们更希望这类函数和操作对象的类型紧密绑定在  一起。

Go语言中的做法是将函数CloseFile()ReadFile()的第一个参数移动到函数名的开头:

// 关闭文件
func (f *File) CloseFile() error {
    // ...
}

// 读文件数据
func (f *File) ReadFile(int64 offset, data []byte) int {
    // ...
}

这样的话,函数CloseFile()ReadFile()就成了File类型独有的方法了(而不是File对象方法)。它们也不再占用包级空间中的名字资源,同时File类型已经明确了它们的操作对象,因此方法名字一般简化为CloseRead

// 关闭文件
func (f *File) Close() error {
    // ...
}

// 读文件数据
func (f *File) Read(int64 offset, data []byte) int {
    // ...
}

将第一个函数参数移动到函数前面,从代码角度看虽然只是一个小的改动,但是从编程哲学角度看,Go语言已经是进入面向对象语言的行列了。我们可以给任何自定义类型添加一个或多个方法。每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。

方法由函数演变而来,只是将函数的第一个对象参数移动到了函数名前面了而已。因此我们依然可以按照原始的过程式思维来使用方法。通过称为方法表达式的特性可以将方法还原为普通类型的函数:

// 不依赖具体的文件对象
// func CloseFile(f *File) error
var CloseFile = (*File).Close

// 不依赖具体的文件对象
// func ReadFile(f *File, int64 offset, data []byte) int
var ReadFile = (*File).Read

// 文件处理
f, _ := OpenFile("foo.dat")
ReadFile(f, 0, data)
CloseFile(f)

在有些场景更关心一组相似的操作。例如,Read()读取一些数组,然后调用Close()关闭。此时的环境中,用户并不关心操作对象的类型,只要能满足通用的Read()Close()行为就可以了。不过在方法表达式中,因为得到的ReadFile()CloseFile()函数参数中含有File这个特有的类型参数,这使得File相关的方法无法与其他不是File类型但是有着相同Read()Close()方法的对象无缝适配。这种小困难难不倒Go语言程序员,我们可以通过结合闭包特性来消除方法表达式中第一个参数类型的差异:

// 先打开文件对象
f, _ := OpenFile("foo.dat")

// 绑定到了f对象
// func Close() error
var Close = func Close() error {
    return (*File).Close(f)
}

// 绑定到了f对象
// func Read(int64 offset, data []byte) int
var Read = func Read(int64 offset, data []byte) int {
    return (*File).Read(f, offset, data)
}

// 文件处理
Read(0, data)
Close()

这刚好是方法值也要解决的问题。我们用方法值特性可以简化实现:

// 先打开文件对象
f, _ := OpenFile("foo.dat")

// 方法值:绑定到了f对象
// func Close() error
var Close = f.Close

// 方法值:绑定到了f对象
// func Read(int64 offset, data []byte) int
var Read = f.Read

// 文件处理
Read(0, data)
Close()

Go语言不支持传统面向对象中的继承特性,而是以自己特有的组合方式支持了方法的继承。Go语言中,通过在结构体内置匿名的成员来实现继承:

import "image/color"

type Point struct{ X, Y float64 }

type ColoredPoint struct {
    Point
    Color color.RGBA
}

虽然我们可以将ColoredPoint定义为一个有3个字段的扁平结构的结构体,但是这里将Point嵌入ColoredPoint来提供XY这两个字段:

var cp ColoredPoint
cp.X = 1
fmt.Println(cp.Point.X) // "1"
cp.Point.Y = 2
fmt.Println(cp.Y)       // "2"

通过嵌入匿名的成员,不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法。我们一般会将Point看作基类,把ColoredPoint看作Point的继承类或子类。不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。

type Cache struct {
    m map[string]string
    sync.Mutex
}

func (p *Cache) Lookup(key string) string {
    p.Lock()
    defer p.Unlock()

    return p.m[key]
}

Cache结构体类型通过嵌入一个匿名的sync.Mutex来继承它的方法Lock()Unlock()。但是在调用p.Lock()p.Unlock()时,p并不是方法Lock()Unlock()的真正接收者,而是会将它们展开为p.Mutex.Lock()p.Mutex.Unlock()调用。这种展开是编译期完成的,并没有运行时代价。

在传统的面向对象语言(例如C++或Java)的继承中,子类的方法是在运行时动态绑定到对象的,因此基类实现的某些方法看到的this可能不是基类类型对应的对象,这个特性会导致基类方法运行的不确定性。而在Go语言通过嵌入匿名的成员来“继承”的基类方法,this就是实现该方法的类型的对象,Go语言中方法是编译时静态绑定的。如果需要虚函数的多态特性,我们需要借助Go语言接口来实现。

Go语言之父Rob Pike曾说过一句名言:“那些试图避免白痴行为的语言最终自己变成了白痴语言。”(Languages that try to disallow idiocy become themselves idiotic.)一般静态编程语言都有着严格的类型系统,这使得编译器可以深入检查程序员有没有作出什么出格的举动。但是,过于严格的类型系统却会使得编程太过烦琐,让程序员把时间都浪费在了和编译器的斗争中。Go语言试图让程序员能在安全和灵活的编程之间取得一个平衡。它在提供严格的类型检查的同时,通过接口类型实现了对鸭子类型的支持,使得安全动态的编程变得相对容易。

Go的接口类型是对其他类型行为的抽象和概括,因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让对象更加灵活和更具有适应能力。很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的鸭子类型。所谓鸭子类型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子。Go语言中的面向对象就是如此,如果一个对象只要看起来像是某种接口类型的实现,那么它就可以作为该接口类型使用。这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义。当使用的类型来自不受我们控制的包时这种设计尤其灵活有用。Go语言的接口类型是延迟绑定,可以实现类似虚函数的多态功能。

接口在Go语言中无处不在,在“Hello, World”的例子中,fmt.Printf()函数的设计就是完全基于接口的,它的真正功能由fmt.Fprintf()函数完成。用于表示错误的error类型更是内置的接口类型。在C语言中,printf只能将几种有限的基础数据类型打印到文件对象中。但是Go语言由于灵活的接口特性,fmt.Fprintf可以向任何自定义的输出流对象打印,可以打印到文件或标准输出,也可以打印到网络,甚至可以打印到一个压缩文件;同时,打印的数据也不仅局限于语言内置的基础类型,任意隐式满足fmt.Stringer接口的对象都可以打印,不满足fmt.Stringer接口的依然可以通过反射的技术打印。fmt.Fprintf()函数的签名如下:

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)

其中io.Writer是用于输出的接口,error是内置的错误接口,它们的定义如下:

type io.Writer interface {
    Write(p []byte) (n int, err error)
}

type error interface {
    Error() string
}

我们可以通过定制自己的输出对象,将每个字符转换为大写字符后输出:

type UpperWriter struct {
    io.Writer
}

func (p *UpperWriter) Write(data []byte) (n int, err error) {
    return p.Writer.Write(bytes.ToUpper(data))
}

func main() {
    fmt.Fprintln(&UpperWriter{os.Stdout}, "hello, world")
}

当然,我们也可以定义自己的打印格式来实现将每个字符转换为大写字符后输出的效果。对于每个要打印的对象,如果满足了fmt.Stringer接口,则默认使用对象的String()方法返回的结果打印:

type UpperString string

func (s UpperString) String() string {
    return strings.ToUpper(string(s))
}

type fmt.Stringer interface {
    String() string
}

func main() {
    fmt.Fprintln(os.Stdout, UpperString("hello, world"))
}

Go语言中,对于基础类型(非接口类型)不支持隐式的转换,我们无法将一个int类型的值直接赋值给int64类型的变量,也无法将int类型的值赋值给底层是int类型的新定义命名类型的变量。Go语言对基础类型的类型一致性要求可谓是非常的严格,但是Go语言对于接口类型的转换则非常灵活。对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。可以看下面的例子:

var (
    a io.ReadCloser = (*os.File)(f)     // 隐式转换,*os.File满足io.ReadCloser接口
    b io.Reader      = a                // 隐式转换,io.ReadCloser满足io.Reader接口
    c io.Closer      = a                // 隐式转换,io.ReadCloser满足io.Closer接口
    d io.Reader      = c.(io.Reader)    // 显式转换,io.Closer不满足io.Reader接口
)

有时候对象和接口之间太灵活了,需要人为地限制这种无意之间的适配。常见的做法是定义一个特殊方法来区分接口。例如runtime包中的Error接口就定义了一个特有的RuntimeError()方法,用于避免其他类型无意中适配了该接口:

type runtime.Error interface {
    error

    // RuntimeError is a no-op function but
    // serves to distinguish types that are run time
    // errors from ordinary errors: a type is a
    // run time error if it has a RuntimeError method.
    RuntimeError()
}

在Protobuf中,Message接口也采用了类似的方法,也定义了一个特有的ProtoMessage,用于避免其他类型无意中适配了该接口:

type proto.Message interface {
    Reset()
    String() string
    ProtoMessage()
}

不过这种做法只是“君子协定”,如果有人故意伪造一个proto.Message接口也是很容易的。再严格一点的做法是给接口定义一个私有方法。只有满足了这个私有方法的对象才可能满足这个接口,而私有方法的名字是包含包的绝对路径名的,因此只有在包内部实现这个私有方法才能满足这个接口。测试包中的testing.TB接口就是采用类似的技术:

type testing.TB interface {
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    ...

    // A private method to prevent users implementing the
    // interface and so future additions to it will not
    // violate Go 1 compatibility.
    private()
}

不过这种通过私有方法禁止外部对象实现接口的做法也是有代价的:首先是这个接口只能在包内部使用,外部包在正常情况下是无法直接创建满足该接口对象的;其次,这种防护措施也不是绝对的,恶意的用户依然可以绕过这种保护机制。

1.4.2节中讲到,通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。我们可以通过嵌入匿名的testing.TB接口来伪造私有方法,因为接口方法是延迟绑定,所以编译时私有方法是否真的存在并不重要。

package main

import (
    "fmt"
    "testing"
)

type TB struct {
    testing.TB
}

func (p *TB) Fatal(args ...interface{}) {
    fmt.Println("TB.Fatal disabled!")
}

func main() {
    var tb testing.TB = new(TB)
    tb.Fatal("Hello, playground")
}

我们在自己的TB结构体类型中重新实现了Fatal()方法,然后通过将对象隐式转换为testing.TB接口类型(因为内嵌了匿名的testing.TB对象,所以是满足testing.TB接口的),再通过testing.TB接口来调用自己的Fatal()方法。

这种通过嵌入匿名接口或嵌入匿名指针对象来实现继承的做法其实是一种纯虚继承,继承的只是接口指定的规范,真正的实现在运行的时候才被注入。例如,可以模拟实现一个gRPC的插件:

type grpcPlugin struct {
    *generator.Generator
}

func (p *grpcPlugin) Name() string { return "grpc" }

func (p *grpcPlugin) Init(g *generator.Generator) {
    p.Generator = g
}

func (p *grpcPlugin) GenerateImports(file *generator.FileDescriptor) {
    if len(file.Service) == 0 {
        return
    }

    p.P(`import "google.golang.org/grpc"`)
    // ...
}

构造的grpcPlugin类型对象必须满足generate.Plugin接口:

type Plugin interface {
    // Name identifies the plugin.
    Name() string
    // Init is called once after data structures are built but before
    // code generation begins.
    Init(g *Generator)
    // Generate produces the code generated by the plugin for this file,
    // except for the imports, by calling the generator's methods
    // P, In, and Out.
    Generate(file *FileDescriptor)
    // GenerateImports produces the import declarations for this file.
    // It is called after Generate.
    GenerateImports(file *FileDescriptor)
}

generate.Plugin接口对应的grpcPlugin类型的GenerateImports()方法中使用的p.P(...)函数,却是通过Init()函数注入的generator.Generator对象实现。这里的generator.Generator对应一个具体类型,但是如果generator.Generator是接口类型,我们甚至可以传入直接的实现。

Go语言通过几种简单特性的组合,就轻易实现了鸭子面向对象和虚拟继承等高级特性,真的是不可思议。

在早期,CPU都是以单核的形式顺序执行机器指令。Go语言的祖先C语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。

随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的CPU频率基本被锁定在了3 GHz附近。单核CPU发展的停滞,为多核CPU的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。

常见的并行编程有多种模型,主要有多线程、消息传递等。从理论上来看,多线程和基于消息的并发编程是等价的。由于多线程并发模型可以自然对应到多核的处理器,主流的操作系统因此也都提供了系统级的多线程支持,同时从概念上讲多线程似乎也更直观,因此多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对基于消息的并发编程模型支持则相对较少,Erlang语言是支持基于消息传递并发编程模型的代表者,它的并发体之间不共享内存。Go语言是基于消息并发模型的集大成者,它将基于CSP模型的并发编程内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,与Erlang不同的是,Go语言的Goroutine之间是共享内存的。

Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真实的Go语言的实现中,Goroutine和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了Go语言并发编程质的飞跃。

首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是2 MB),这个栈主要用来保存函数递归调用时的参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程是一个巨大的浪费;二是对于少数需要巨大栈空间的线程又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是无法兼得的。相反,一个Goroutine会以一个很小的栈启动(可能是2 KB或4 KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1 GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个Goroutine。

Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作原理和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个runtime.GOMAXPROCS变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目。

在Go语言中启动一个Goroutine不仅和调用函数一样简单,而且Goroutine之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。

所谓的原子操作就是并发编程中“最小的且不可并行化”的操作。通常,如果多个并发体对同一个共享资源进行的操作是原子的话,那么同一时刻最多只能有一个并发体对该资源进行操作。从线程角度看,在当前线程修改共享资源期间,其他线程是不能访问该资源的。原子操作对多线程并发编程模型来说,不会发生有别于单线程的意外情况,共享资源的完整性可以得到保证。

一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护。当然,如果仅仅是想模拟粗粒度的原子操作,可以借助于sync.Mutex来实现:

import (
    "sync"
)

var total struct {
    sync.Mutex
    value int
}

func worker(wg *sync.WaitGroup) {
    defer wg.Done()

    for i := 0; i <= 100; i++ {
        total.Lock()
        total.value += i
        total.Unlock()
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    go worker(&wg)
    go worker(&wg)
    wg.Wait()

    fmt.Println(total.value)
}

worker的循环中,为了保证total.value += i的原子性,我们通过sync.Mutex加锁和解锁来保证该语句在同一时刻只被一个线程访问。对多线程模型的程序而言,进出临界区前后进行加锁和解锁都是必需的。如果没有锁的保护,total的最终值将由于多线程之间的竞争而可能不正确。

用互斥锁来保护一个数值型的共享资源麻烦且效率低下。标准库的sync/atomic包对原子操作提供了丰富的支持。我们可以重新实现上面的例子:

import (
    "sync"
    "sync/atomic"
)

var total uint64

func worker(wg *sync.WaitGroup) {
    defer wg.Done()

    var i uint64
    for i = 0; i <= 100; i++ {
        atomic.AddUint64(&total, i)
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    go worker(&wg)
    go worker(&wg)
    wg.Wait()
}

atomic.AddUint64 ()函数调用保证了total的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。

原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。

type singleton struct {}

var (
    instance    *singleton
    initialized uint32
    mu          sync.Mutex
)

func Instance() *singleton {
    if atomic.LoadUint32(&initialized) == 1 {
        return instance
    }

    mu.Lock()
    defer mu.Unlock()

    if instance == nil {
        defer atomic.StoreUint32(&initialized, 1)
        instance = &singleton{}
    }
    return instance
}

我们将通用的代码提取出来,就成了标准库中sync.Once的实现:

type Once struct {
    m    Mutex
    done uint32
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 {
        return
    }

    o.m.Lock()
    defer o.m.Unlock()

    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

基于sync.Once重新实现单件(singleton)模式:

var (
    instance *singleton
    once     sync.Once
)

func Instance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

sync/atomic包对基本数值类型及复杂对象的读写都提供了原子操作的支持。atomic. Value原子对象提供了Load()Store()两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}类型,因此可以用于任意的自定义复杂类型。

var config atomic.Value // 保存当前配置信息

// 初始化配置信息
config.Store(loadConfig())

// 启动一个后台线程,加载更新后的配置信息
go func() {
    for {
        time.Sleep(time.Second)
        config.Store(loadConfig())
    }
}()

// 用于处理请求的工作者线程始终采用最新的配置信息
for i := 0; i < 10; i++ {
    go func() {
        for r := range requests() {
            c := config.Load()
            // ...
        }
    }()
}

这是一个简化的生产者消费者模型:后台线程生成最新的配置信息;前台多个工作者线程获取最新的配置信息。所有线程共享配置信息资源。

如果只是想简单地在线程之间进行数据同步的话,原子操作已经为编程人员提供了一些同步保障。不过这种保障有一个前提:顺序一致性的内存模型。要了解顺序一致性,先看一个简单的例子:

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    for !done {}
    print(a)
}

我们创建了setup线程,用于对字符串a的初始化工作,初始化完成之后设置done标志为truemain()函数所在的主线程中,通过for !done {}检测done变为true时,认为字符串初始化工作完成,然后进行字符串的打印工作。

但是Go语言并不保证在main()函数中观测到的对done的写入操作发生在对字符串a的写入操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件,setup线程对done的写入操作甚至无法被main线程看到,main()函数有可能陷入死循环中。

在Go语言中,同一个Goroutine线程内部,顺序一致性的内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性的内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序(CPU也会对一些指令进行乱序执行)。

因此,如果在一个Goroutine中顺序执行a = 1; b = 2;这两个语句,虽然在当前的Goroutine中可以认为a = 1;语句先于b = 2;语句执行,但是在另一个Goroutine中b = 2;语句可能会先于a = 1;语句执行,甚至在另一个Goroutine中无法看到它们的变化(可能始终在寄存器中)。也就是说在另一个Goroutine看来, a = 1; b = 2;这两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。例如,下面这个程序:

func main() {
    go println("你好, 世界")
}

根据Go语言规范,main()函数退出时程序结束,不会等待任何后台线程。因为Goroutine的执行和main()函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印、能否打印都是未知的。

用前面的原子操作并不能解决问题,因为我们无法确定两个原子操作之间的顺序。解决问题的办法就是通过同步原语来给两个事件明确排序:

func main() {
    done := make(chan int)

    go func(){
        println("你好, 世界")
        done <- 1
    }()

    <-done
}

<-done执行时,必然要求done <- 1也已经执行。根据同一个Goroutine依然满足顺序一致性规则,可以判断当done <- 1执行时,println(" 你好 , 世界 ")语句必然已经执行完成了。因此,现在的程序确保可以正常打印结果。

当然,通过sync.Mutex互斥量也是可以实现同步的:

func main() {
    var mu sync.Mutex

    mu.Lock()
    go func(){
        println("你好, 世界")
        mu.Unlock()
    }()

    mu.Lock()
}

可以确定,后台线程的mu.Unlock()必然在println(" 你好 , 世界 ")完成后发生(同一个线程满足顺序一致性),main()函数的第二个mu.Lock()必然在后台线程的mu.Unlock()之后发生(sync.Mutex保证),此时后台线程的打印工作已经顺利完成了。

1.4.1节中我们已经简单介绍过程序的初始化顺序,这是属于Go语言面向并发的内存模型的基础规范。

Go程序的初始化和执行总是从main.main()函数开始的。但是如果main包里导入了其他的包,则会按照顺序将它们包含到main包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入,那么在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其他的包,则先将其他的包包含进来,然后创建和初始化这个包的常量和变量。再调用包里的init()函数,如果一个包有多个init()函数,实现可能是以文件名的顺序调用,那么同一个文件内的多个init()是以出现的顺序依次调用的(init()不是普通函数,可以定义多个,但是不能被其他函数调用)。最终,在main包的所有包常量、包变量被创建和初始化,并且只有在init()函数被执行后,才会进入main.main()函数,程序开始正常执行。图1-10给出的是Go程序函数启动顺序的示意图。

图1-10 包初始化流程

要注意的是,在main.main()函数执行之前所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。如果某个init()函数内部用go关键字启动了新的Goroutine,那么新的Goroutine和main.main()函数是并发执行的。

因为所有的init()函数和main()函数都是在主线程完成,它们也是满足顺序一致性模型的。

go语句会在当前Goroutine对应函数返回前创建新的Goroutine。例如:

var a string

func f() {
    print(a)
}

func hello() {
    a = "hello, world"
    go f()
}

执行go f()语句创建Goroutine和hello()函数是在同一个Goroutine中执行,根据语句的书写顺序可以确定Goroutine的创建发生在hello()函数返回之前,但是新创建Goroutine对应的f()的执行事件和hello()函数返回的事件则是不可排序的,也就是并发的。调用hello ()可能会在将来的某一时刻打印“hello, world”,也很可能是在hello()函数执行完成后才打印。

通道(channel)是在Goroutine之间进行同步的主要方法。在无缓存的通道上的每一次发送操作都有与其对应的接收操作相匹配,发送和接收操作通常发生在不同的Goroutine上(在同一个Goroutine上执行两个操作很容易导致死锁)。无缓存的通道上的发送操作总在对应的接收操作完成前发生

var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "你好, 世界"
    done <- true
}

func main() {
    go aGoroutine()
    <-done
    println(msg)
}

可保证打印出“你好, 世界”。该程序首先对msg进行写入,然后在done通道上发送同步信号,随后从done接收对应的同步信号,最后执行println()函数。

若在关闭通道后继续从中接收数据,接收者就会收到该通道返回的零值。因此在这个例子中,用close( done )关闭通道代替done <- true依然能保证该程序产生相同的行为。

var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "你好, 世界"
    close(done)
}

func main() {
    go aGoroutine()
    <-done
    println(msg)
}

对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。

基于上面这个规则可知,交换两个Goroutine中的接收和发送操作也是可以的(但是很危险):

var done = make(chan bool)
var msg string

func aGoroutine() {
    msg = "hello, world"
    <-done
}
func main() {
    go aGoroutine()
    done <- true
    println(msg)
}

也可保证打印出“hello, world”。因为main线程中done <- true发送完成前,后台线程<-done接收已经开始,这保证msg = "hello, world"被执行了,所以之后println(msg)msg已经被赋值过了。简而言之,后台线程首先对msg进行写入,然后从done中接收信号,随后main线程向done发送对应的信号,最后执行println()函数完成。但是,若该通道为带缓存的(例如,done = make(chan bool, 1)),main线程的done <- true接收操作将不会被后台线程的<-done接收操作阻塞,该程序将无法保证打印出“hello, world”。

对于带缓存的通道,对于通道中的第K个接收完成操作发生在第K+C个发送操作完成之前,其中C是管道的缓存大小。如果将<em>C</em>设置为0自然就对应无缓存的通道,也就是第K个接收完成在第K个发送完成之前。因为无缓存的通道只能同步发1个,所以也就简化为前面无缓存通道的规则:对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前

我们可以根据控制通道的缓存大小来控制并发执行的Goroutine的最大数目,例如:

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func() {
            limit <- 1
            w()
            <-limit
        }()
    }
    select{}
}

最后一句select{}是一个空的通道选择语句,该语句会导致main线程阻塞,从而避免程序过早退出。还有for{}<-make(chan int)等诸多方法可以达到类似的效果。因为main线程被阻塞了,如果需要程序正常退出的话,可以通过调用os.Exit(0)实现。

前面我们已经分析过,下面代码无法保证正常打印结果,实际的运行效果也很大概率上不能正常输出结果。

func main() {
    go println("你好, 世界")
}

如果刚接触Go语言,可能希望通过加入一个随机的休眠时间来保证正常的输出:

func main() {
    go println("hello, world")
    time.Sleep(time.Second)
}

因为主线程休眠了1秒,所以这个程序很大概率上是可以正常输出结果的。因此,很多人会觉得这个程序已经没有问题了。但是这个程序是不稳健的,依然有失败的可能性。我们先假设程序是可以稳定输出结果的。因为Go线程的启动是非阻塞的,main线程显式休眠了1秒退出导致程序结束,我们可以近似地认为程序总共执行了1秒多时间。现在假设println()函数内部实现休眠的时间大于main线程休眠的时间,就会导致矛盾:后台线程既然先于main线程完成打印,那么执行时间肯定是小于main线程执行时间的。当然这是不可能的。

严谨的并发程序的正确性不应该是依赖于CPU的执行速度和休眠时间等不靠谱的因素的。严谨的并发也应该是可以静态推导出结果的:根据线程内顺序一致性,结合通道或sync事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。

解决同步问题的思路是相同的:使用显式的同步。

Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.R Hoare在1978年提出的通信顺序进程(Communicating Sequential Process,CSP)。CSP有着精确的数学模型,并实际应用在了Hoare参与设计的T9000通用计算机上。从Newsqueak、Alef、Limbo到现在的Go语言,对于对CSP有着20多年实战经验的Rob Pike来说,他更关注的是将CSP应用在通用编程语言上产生的潜力。作为Go并发编程核心的CSP理论的核心概念只有一个:同步通信。关于同步通信的话题我们在前文已经讲过,本节我们将简单介绍Go语言中常见的并发模式。

首先要明确一个概念:并发不是并行。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如,GPU中对图像处理都会有大量的并行运算。为了更好地编写并发程序,从设计之初Go语言就注重如何在编程语言层级上设计一个简洁安全高效的抽象模型,让程序员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些烦琐的操作分散精力。

在并发编程中,对共享资源的正确访问需要精确地控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过通道传递(实际上多个独立执行的线程很少主动共享资源)。在任意给定的时刻,最好只有一个Goroutine能够拥有该资源。数据竞争从设计层面上就被杜绝了。为了提倡这种思考方式,Go语言将其并发编程哲学化为一句口号:“不要通过共享内存来通信,而应通过通信来共享内存。”(Do not communicate by sharing memory; instead, share memory by communicating.)

这是更高层次的并发编程哲学(通过通道来传值是Go语言推荐的做法)。虽然像引用计数这类简单的并发问题通过原子操作或互斥锁就能很好地实现,但是通过通道来控制访问能够让你写出更简洁正确的程序。

先以在一个新的Goroutine中输出“你好, 世界”,main等待后台线程输出工作完成之后退出的简单的并发程序作为热身。

并发编程的核心概念是同步通信,但是同步的方式却有多种。先以大家熟悉的互斥量sync.Mutex来实现同步通信。根据文档,我们不能直接对一个未加锁状态的sync.Mutex进行解锁,这会导致运行时异常。下面这种方式并不能保证正常工作:

func main() {
    var mu sync.Mutex

    go func(){
        fmt.Println("你好, 世界")
        mu.Lock()
    }()

    mu.Unlock()
}

因为mu.Lock()mu.Unlock()并不在同一个Goroutine中,所以也就不满足顺序一致性内存模型。同时它们也没有其他的同步事件可以参考,这两个事件不可排序也就是可以并发的。因为可能是并发的事件,所以main()函数中的mu.Unlock()很有可能先发生,而这个时刻mu互斥对象还处于未加锁的状态,因而会导致运行时异常。

下面是修复后的代码:

func main() {
    var mu sync.Mutex

    mu.Lock()
    go func(){
        fmt.Println("你好, 世界")
        mu.Unlock()
    }()

    mu.Lock()
}

修复的方式是在main()函数所在线程中执行两次mu.Lock(),当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,main()函数的阻塞状态驱动后台线程继续向前执行。当后台线程执行到mu.Unlock()时解锁,此时打印工作已经完成了,解锁会导致main()函数中的第二个mu.Lock()阻塞状态取消,此时后台线程和主线程再没有其他的同步事件参考,它们退出的事件将是并发的:在main()函数退出导致程序退出时,后台线程可能已经退出了,也可能没有退出。虽然无法确定两个线程退出的时间,但是打印工作是可以正确完成的。

使用sync.Mutex互斥锁同步是比较低级的做法。我们现在改用无缓存通道来实现同步:

func main() {
    done := make(chan int)

    go func(){
        fmt.Println("你好, 世界")
        <-done
    }()

    done <- 1
}

根据Go语言内存模型规范,对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。因此,后台线程<-done接收操作完成之后,main线程的done <- 1发送操作才可能完成(从而退出main、退出程序),而此时打印工作已经完成了。

上面的代码虽然可以正确同步,但是对通道的缓存大小太敏感:如果通道有缓存,就无法保证main()函数退出之前后台线程能正常打印了。更好的做法是将通道的发送和接收方向调换一下,这样可以避免同步事件受通道缓存大小的影响:

func main() {
    done := make(chan int, 1) // 带缓存通道

    go func(){
        fmt.Println("你好, 世界")
        done <- 1
    }()

    <-done
}

对于带缓存的通道,对通道的第K个接收完成操作发生在第K+C个发送操作完成之前,其中C是通道的缓存大小。虽然通道是带缓存的,但是main线程接收完成是在后台线程发送开始但还未完成的时刻,此时打印工作也是已经完成的。

基于带缓存通道,我们可以很容易将打印线程扩展到N个。下面的例子是开启10个后台线程分别打印:

func main() {
    done := make(chan int, 10) // 带10个缓存

    // 开N个后台打印线程
    for i := 0; i < cap(done); i++ {
        go func(){
            fmt.Println("你好, 世界")
            done N个后台线程完成
    for i := 0; i < cap(done); i++ {
        

对于这种要等待N个线程完成后再进行下一步的同步操作有一个简单的做法,就是使用sync.WaitGroup来等待一组事件:

func main() {
    var wg sync.WaitGroup

    // 开N个后台打印线程
    for i := 0; i < 10; i++ {
        wg.Add(1)

        go func() {
            fmt.Println("你好, 世界")
            wg.Done()
        }()
    }

    // 等待N个后台线程完成
    wg.Wait()
}

其中wg.Add(1)用于增加等待事件的个数,必须确保在后台线程启动之前执行(如果放到后台线程之中执行则不能保证被正常执行到)。当后台线程完成打印工作之后,调用wg.Done()表示完成一个事件。main()函数的wg.Wait()是等待全部的事件完成。

并发编程中最常见的例子就是生产者/消费者模型,该模型主要通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。简单地说,就是生产者生产一些数据,然后放到成果队列中,同时消费者从成果队列中来取这些数据。这样就让生产和消费变成了异步的两个过程。当成果队列中没有数据时,消费者就进入饥饿的等待中;而当成果队列中数据已满时,生产者则面临因产品积压导致CPU被剥夺的问题。

Go语言实现生产者和消费者并发很简单:

// 生产者:生成factor整数倍的序列
func Producer(factor int, out chan<- int) {
    for i := 0; ; i++ {
        out <- i*factor
    }
}

// 消费者
func Consumer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}
func main() {
    ch := make(chan int, 64) // 成果队列

    go Producer(3, ch) // 生成3的倍数的序列
    go Producer(5, ch) // 生成5的倍数的序列
    go Consumer(ch)    // 消费生成的队列

    // 运行一定时间后退出
    time.Sleep(5 * time.Second)
}

我们开启了两个Producer生产流水线,分别用于生成3和5的倍数的序列。然后开启一个Consumer消费者线程,打印获取的结果。我们通过在main()函数休眠一定的时间来让生产者和消费者工作一定时间。正如1.6.1节中说的,这种靠休眠方式是无法保证稳定的输出结果的。

我们可以让main()函数保存阻塞状态不退出,只有当用户输入Ctrl+C时才真正退出程序:

func main() {
    ch := make(chan int, 64) // 成果队列

    go Producer(3, ch) // 生成3的倍数的序列
    go Producer(5, ch) // 生成5的倍数的序列
    go Consumer(ch)    // 消费生成的队列

    // Ctrl+C 退出
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    fmt.Printf("quit (%v)\n", <-sig)
}

这个例子中有两个生产者,并且两个生产者之间无同步事件可参考,它们是并发的。因此,消费者输出的结果序列的顺序是不确定的,这并没有问题,生产者和消费者依然可以相互配合工作。

发布/订阅(publish-subscribe)模型通常被简写为pub/sub模型。在这个模型中,消息生产者成为发布者(publisher),而消息消费者则成为订阅者(subscriber),生产者和消费者是M : N的关系。在传统生产者/消费者模型中,是将消息发送到一个队列中,而发布/订阅模型则是将消息发布给一个主题。

为此,我们构建了一个名为pubsub的发布/订阅模型支持包:

// Package pubsub implements a simple multi-topic pub-sub library.
package pubsub

import (
    "sync"
    "time"
)

type (
    subscriber chan interface{}          // 订阅者为一个通道
    topicFunc  func(v interface{}) bool  // 主题为一个过滤器
)

// 发布者对象
type Publisher struct {
    m            sync.RWMutex                // 读写锁
    buffer       int                         // 订阅队列的缓存大小
    timeout      time.Duration               // 发布超时时间
    subscribers  map[subscriber]topicFunc    // 订阅者信息
}

// 构建一个发布者对象,可以设置发布超时时间和缓存队列的长度
func NewPublisher(publishTimeout time.Duration, buffer int) *Publisher {
    return &Publisher{
        buffer:      buffer,
        timeout:     publishTimeout,
        subscribers: make(map[subscriber]topicFunc),
    }
}

// 添加一个新的订阅者,订阅全部主题
func (p *Publisher) Subscribe() chan interface{} {
    return p.SubscribeTopic(nil)
}

// 添加一个新的订阅者,订阅过滤器筛选后的主题
func (p *Publisher) SubscribeTopic(topic topicFunc) chan interface{} {
    ch := make(chan interface{}, p.buffer)
    p.m.Lock()
    p.subscribers[ch] = topic
    p.m.Unlock()
    return ch
}

// 退出订阅
func (p *Publisher) Evict(sub chan interface{}) {
    p.m.Lock()
    defer p.m.Unlock()

    delete(p.subscribers, sub)
    close(sub)
}

// 发布一个主题
func (p *Publisher) Publish(v interface{}) {
    p.m.RLock()
    defer p.m.RUnlock()

    var wg sync.WaitGroup
    for sub, topic := range p.subscribers {
        wg.Add(1)
        go p.sendTopic(sub, topic, v, &wg)
    }
    wg.Wait()
}

// 关闭发布者对象,同时关闭所有的订阅者通道
func (p *Publisher) Close() {
    p.m.Lock()
    defer p.m.Unlock()

    for sub := range p.subscribers {
        delete(p.subscribers, sub)
        close(sub)
    }
}

// 发送主题,可以容忍一定的超时
func (p *Publisher) sendTopic(
    sub subscriber, topic topicFunc, v interface{}, wg *sync.WaitGroup,
) {
    defer wg.Done()
    if topic != nil && !topic(v) {
        return
    }

    select {
    case sub <- v:
    case <-time.After(p.timeout):
    }
}

下面的例子中,有两个订阅者分别订阅了全部主题和含有"golang"的主题:

import "path/to/pubsub"

func main() {
    p := pubsub.NewPublisher(100*time.Millisecond, 10)
    defer p.Close()

    all := p.Subscribe()
    golang := p.SubscribeTopic(func(v interface{}) bool {
        if s, ok := v.(string); ok {
            return strings.Contains(s, "golang")
        }
        return false
    })

    p.Publish("hello,  world!")
    p.Publish("hello, golang!")

    go func() {
        for  msg := range all {
            fmt.Println("all:", msg)
        }
    } ()

    go func() {
        for  msg := range golang {
            fmt.Println("golang:", msg)
        }
    } ()

    // 运行一定时间后退出
    time.Sleep(3 * time.Second)
}

在发布/订阅模型中,每条消息都会传送给多个订阅者。发布者通常不会知道,也不关心哪一个订阅者正在接收主题消息。订阅者和发布者可以在运行时动态添加,它们之间是一种松散的耦合关系,这使得系统的复杂性可以随时间的推移而增长。在现实生活中,像天气预报之类的应用就可以应用这种并发模式。

很多用户在适应了Go语言强大的并发特性之后,都倾向于编写最大并发的程序,因为这样似乎可以提供最高的性能。在现实中我们行色匆匆,但有时却需要我们放慢脚步享受生活,并发的程序也是一样:有时候我们需要适当地控制并发的程度,因为这样不仅可给其他的应用/任务让出/预留一定的CPU资源,也可以适当降低功耗缓解电池的压力。

在Go语言自带的godoc程序实现中有一个vfs的包对应虚拟的文件系统,在vfs包下面有一个gatefs的子包,gatefs子包的目的就是为了控制访问该虚拟文件系统的最大并发数。gatefs包的应用很简单:

import (
    "golang.org/x/tools/godoc/vfs"
    "golang.org/x/tools/godoc/vfs/gatefs"
)

func main() {
    fs := gatefs.New(vfs.OS("/path"), make(chan bool, 8))
    // ...
}

其中vfs.OS("/path")基于本地文件系统构造一个虚拟的文件系统,然后gatefs.New基于现有的虚拟文件系统构造一个并发受控的虚拟文件系统。并发数控制的原理在1.5节已经讲过,就是通过带缓存通道的发送和接收规则来实现最大并发阻塞:

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func() {
            limit <- 1
            w()
            <-limit
        }()
    }
    select{}
}

不过gatefs对此做一个抽象类型gate,增加了enter()leave()方法分别对应并发代码的进入和离开。当超出并发数目限制的时候,enter()方法会阻塞直到并发数降下来为止。

type gate chan bool

func (g gate) enter() { g <- true }
func (g gate) leave() { <-g }

gatefs包装的新的虚拟文件系统就是将需要控制并发的方法增加了对enter()leave()的调用而已:

type gatefs struct {
    fs vfs.FileSystem
    gate
}

func (fs gatefs) Lstat(p string) (os.FileInfo, error) {
    fs.enter()
    defer fs.leave()
    return fs.fs.Lstat(p)
}

我们不仅可以控制最大的并发数目,而且可以通过带缓存通道的使用量和最大容量比例来判断程序运行的并发率。当通道为空时可以认为是空闲状态,当通道满了时可以认为是繁忙状态,这对于后台一些低级任务的运行是有参考价值的。

采用并发编程的动机有很多:并发编程可以简化问题,例如一类问题对应一个处理线程会更简单;并发编程还可以提升性能,在一个多核CPU上开两个线程一般会比开一个线程快一些。其实对提升性能而言,并不是程序运行速度快就表示用户体验好,很多时候程序能快速响应用户请求才是最重要的,当没有用户请求需要处理的时候才合适处理一些低优先级的后台任务。

假设我们想快速地搜索“golang”相关的主题,我们可能会同时打开必应、谷歌或百度等多个检索引擎。当某个搜索最先返回结果后,就可以关闭其他搜索页面了。因为受网络环境和搜索引擎算法的影响,某些搜索引擎可能很快返回搜索结果,某些搜索引擎也可能等到他们公司倒闭也没有完成搜索。我们可以采用类似的策略来编写这个程序:

func main() {
    ch := make(chan string, 32)

    go func() {
        ch <- searchByBing("golang")
    }()
    go func() {
        ch <- searchByGoogle("golang")
    }()
    go func() {
        ch <- searchByBaidu("golang")
    }()

    fmt.Println(<-ch)
}

首先,创建了一个带缓存通道,通道的缓存数目要足够大,保证不会因为缓存的容量引起不必要的阻塞。然后开启了多个后台线程,分别向不同的搜索引擎提交搜索请求。当任意一个搜索引擎最先有结果之后,都会马上将结果发到通道中(因为通道带了足够的缓存,这个过程不会阻塞)。但是最终只从通道取第一个结果,也就是最先返回的结果。

通过适当开启一些冗余的线程,尝试用不同途径去解决同样的问题,最终以赢者为王的方式提升了程序的相应性能。

在1.2节中,为了演示Newsqueak的并发特性,给出了并发版本素数筛的实现。并发版本的素数筛是一个经典的并发例子,通过它可以更深刻地理解Go语言的并发特性。“素数筛”的原理如图1-5所示。

我们需要先生成最初的2, 3, 4,…自然数序列(不包含开头的0、1):

// 返回生成自然数序列的通道: 2, 3, 4, ...
func GenerateNatural() chan int {
    ch := make(chan int)
    go func() {
        for i := 2; ; i++ {
            ch <- i
        }
    }()
    return ch
}

GenerateNatural()函数内部启动一个Goroutine生产序列,返回对应的通道。

然后为每个素数构造一个筛子:将输入序列中是素数倍数的数提出,并返回新的序列,是一个新的通道。

// 通道过滤器: 删除能被素数整除的数
func PrimeFilter(in <-chan int, prime int) chan int {
    out := make(chan int)
    go func() {
        for {
            if i := <-in; i%prime != 0 {
                out <- i
            }
        }
    }()
    return out
}

PrimeFilter()函数也是内部启动一个Goroutine生产序列,返回过滤后序列对应的通道。

现在可以在main()函数中驱动这个并发的素数筛了:

func main() {
    ch := GenerateNatural() // 自然数序列: 2, 3, 4, ...
    for i := 0; i < 100; i++ {
        prime := <-ch // 新出现的素数
        fmt.Printf("%v: %v\n", i+1, prime)
        ch = PrimeFilter(ch, prime) // 基于新素数构造的过滤器
    }
}

先是调用GenerateNatural()生成最原始的从2开始的自然数序列。然后开始一个100次迭代的循环,希望生成100个素数。在每次循环迭代开始的时候,通道中的第一个数必定是素数,我们先读取并打印这个素数。然后基于通道中剩余的数列,并以当前取出的素数为筛子过滤后面的素数。不同的素数筛对应的通道是串联在一起的。

素数筛展示了一种优雅的并发程序结构。但是因为每个并发体处理的任务粒度太细微,程序整体的性能并不理想。对于细粒度的并发程序,CSP模型中固有的消息传递的代价太高了(多线程并发模型同样要面临线程启动的代价)。

有时候需要通知Goroutine停止它正在干的事情,特别是当它工作在错误的方向上的时候。Go语言并没有提供一个直接终止Goroutine的方法,因为这样会导致Goroutine之间的共享变量处在未定义的状态上。但是如果想要退出两个或者任意多个Goroutine怎么办呢?

Go语言中不同Goroutine之间主要依靠通道进行通信和同步。要同时处理多个通道的发送或接收操作,需要使用select关键字(这个关键字和网络编程中的select()函数的行为类似)。当select()有多个分支时,会随机选择一个可用的通道分支,如果没有可用的通道分支,则选择default分支,否则会一直保持阻塞状态。

基于select()实现的通道的超时判断:

select {
case v := <-in:
    fmt.Println(v)
case <-time.After(time.Second):
    return // 超时
}

通过selectdefault分支实现非阻塞的通道发送或接收操作:

select {
case v := <-in:
    fmt.Println(v)
default:
    // 没有数据
}

通过select来阻止main()函数退出:

func main() {
    // 做一些处理
    select{}
}

当有多个通道均可操作时,select会随机选择一个通道。基于该特性我们可以用select实现一个生成随机数序列的程序:

func main() {
    ch := make(chan int)
    go func() {
        for {
            select {
            case ch <- 0:
            case ch <- 1:
            }
        }
    }()

    for v := range ch {
        fmt.Println(v)
    }
}

我们通过selectdefault分支可以很容易实现一个Goroutine的退出控制:

func worker(cannel chan bool) {
    for {
        select {
        default:
            fmt.Println("hello")
            // 正常工作
        case <-cannel:
            // 退出
        }
    }
}

func main() {
    cannel := make(chan bool)
    go worker(cannel)

    time.Sleep(time.Second)
    cannel <- true
}

但是通道的发送操作和接收操作是一一对应的,如果要停止多个Goroutine,那么可能需要创建同样数量的通道,这个代价太大了。其实我们可以通过close()关闭一个通道来实现广播的效果,所有从关闭通道接收的操作均会收到一个零值和一个可选的失败标志。

func worker(cannel chan bool) {
    for {
        select {
        default:
            fmt.Println("hello")
            // 正常工作
        case <-cannel:
            // 退出
        }
    }
}

func main() {
    cancel := make(chan bool)

    for i := 0; i < 10; i++ {
        go worker(cancel)
    }

    time.Sleep(time.Second)
    close(cancel)
}

我们通过close()来关闭cancel通道,向多个Goroutine广播退出的指令。不过这个程序依然不够稳健:当每个Goroutine收到退出指令退出时一般会进行一定的清理工作,但是退出的清理工作并不能保证被完成,因为main线程并没有等待各个工作Goroutine退出工作完成的机制。我们可以结合sync.WaitGroup来改进:

func worker(wg *sync.WaitGroup, cannel chan bool) {
    defer wg.Done()

    for {
        select {
        default:
            fmt.Println("hello")
        case <-cannel:
            return
        }
    }
}

func main() {
    cancel := make(chan bool)

    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go worker(&wg, cancel)
    }

    time.Sleep(time.Second)
    close(cancel)
    wg.Wait()
}

现在每个工作者并发体的创建、运行、暂停和退出都是在main()函数的安全控制之下了。

在Go 1.7发布时,标准库增加了一个context包,用来简化对于处理单个请求的多个Goroutine之间与请求域的数据、超时和退出等操作,官方有博客文章对此做了专门介绍。我们可以用context包来重新实现前面的线程安全退出或超时的控制:

func worker(ctx context.Context, wg *sync.WaitGroup) error {
    defer wg.Done()

    for {
        select {
        default:
            fmt.Println("hello")
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go worker(ctx, &wg)
    }

    time.Sleep(time.Second)
    cancel()

    wg.Wait()
}

当并发体超时或main主动停止工作者Goroutine时,每个工作者都可以安全退出。

Go语言是带内存自动回收特性的,因此内存一般不会泄漏。在前面素数筛的例子中,GenerateNaturalPrimeFilter()函数内部都启动了新的Goroutine,当main()函数不再使用通道时,后台Goroutine有泄漏的风险。我们可以通过context包来避免这个问题,下面是改进的素数筛实现:

// 返回生成自然数序列的通道: 2, 3, 4, ...
func GenerateNatural(ctx context.Context) chan int {
    ch := make(chan int)
    go func() {
        for i := 2; ; i++ {
            select {
            case <- ctx.Done():
                return
            case ch <- i:
            }
        }
    }()
    return ch
}

// 通道过滤器:删除能被素数整除的数
func PrimeFilter(ctx context.Context, in <-chan int, prime int) chan int {
    out := make(chan int)
    go func() {
        for {
            if i := <-in; i%prime != 0 {
                select {
                case <- ctx.Done():
                    return
                case out <- i:
                }
            }
        }
    }()
    return out
}

func main() {
    // 通过Context控制后台Goroutine状态
    ctx, cancel := context.WithCancel(context.Background())

    ch := GenerateNatural(ctx) // 自然数序列:2, 3, 4, ...
    for i := 0; i < 100; i++ {
        prime := <-ch // 新出现的素数
        fmt.Printf("%v: %v\n", i+1, prime)
        ch = PrimeFilter(ctx, ch, prime) // 基于新素数构造的过滤器
    }

    cancel()
}

main()函数完成工作前,通过调用cancel()来通知后台Goroutine退出,这样就避免了Goroutine的泄漏。

并发是一个非常大的主题,这里只展示几个非常基础的并发编程的例子。官方文档也有很多关于并发编程的讨论,国内也有专门讨论Go语言并发编程的书籍。读者可以根据自己的需求查阅相关的文献。

错误处理是每个编程语言都要考虑的一个重要话题。在Go语言的错误处理中,错误是软件包API和应用程序用户界面的一个重要组成部分。

在程序中总有一部分函数总是要求必须能够成功地运行。例如,strconv.Itoa将整数转换为字符串,从数组或切片中读写元素,从map读取已经存在的元素等。这类操作在运行时几乎不会失败,除非程序中有bug,或遇到灾难性的、不可预料的情况,例如运行时的内存溢出。如果真的遇到真正异常情况,只要简单终止程序就可以了。

排除异常的情况,如果程序运行失败仅被认为是几个预期的结果之一。对于那些将运行失败看作是预期结果的函数,它们会返回一个额外的返回值,通常是最后一个来传递错误信息。如果导致失败的原因只有一个,那么额外的返回值可以是一个布尔值,通常被命名为ok。例如,当从一个map查询一个结果时,可以通过额外的布尔值判断是否成功:

if v, ok := m["key"]; ok {
    return v
}

但是导致失败的原因通常不止一种,很多时候用户希望了解更多的错误信息。如果只是用简单的布尔类型的状态值将不能满足这个要求。在C语言中,默认采用一个整数类型的errno来表达错误,这样就可以根据需要定义多种错误类型。在Go语言中,syscall.Errno就是对应C语言中errno类型的错误。在syscall包中的接口,如果有返回错误的话,底层也是syscall.Errno错误类型。

例如,通过syscall包的接口来修改文件的模式时,如果遇到错误可以通过将err强制断言为syscall.Errno错误类型来处理:

err := syscall.Chmod(":invalid path:", 0666)
if err != nil {
    log.Fatal(err.(syscall.Errno))
}

还可以进一步地通过类型查询或类型断言来获取底层真实的错误类型,这样就可以获取更详细的错误信息。不过一般情况下我们并不关心错误在底层的表达方式,只需要知道它是一个错误就可以了。当返回的错误值不是nil时,我们可以通过调用error接口类型的Error()方法来获得字符串类型的错误信息。

在Go语言中,错误被认为是一种可以预期的结果,而异常则是一种非预期的结果,发生异常可能表示程序中存在bug或发生了其他不可控的问题。Go语言推荐使用recover()函数将内部异常转为错误处理,这使得用户可以真正地关心业务相关的错误处理。

如果某个接口简单地将所有普通的错误当作异常抛出,将会使错误信息杂乱且没有价值。就像在main()函数中直接捕获全部一样,是没有意义的:

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Fatal(r)
        }
    }()

    ...
}

捕获异常不是最终的目的。如果异常不可预测,直接输出异常信息是最好的处理方式。

让我们演示一个文件复制的例子:函数需要打开两个文件,然后将其中一个文件的内容复制到另一个文件:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }

    written, err = io.Copy(dst, src)
    dst.Close()
    src.Close()
    return
}

上面的代码虽然能够工作,但是隐藏一个bug。如果第一个os.Open ()调用成功,但是第二个os.Create ()调用失败,那么会在没有释放src文件资源的情况下返回。虽然我们可以通过在第二个返回语句前添加src.Close()调用来修复这个bug,但是当代码变得复杂时,类似的问题将很难被发现和修复。我们可以通过defer语句来确保每个被正常打开的文件都能被正常关闭:

func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }
    defer src.Close()

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }
    defer dst.Close()

    return io.Copy(dst, src)
}

defer语句可以让我们在打开文件时马上思考如何关闭文件。不管函数如何返回,文件关闭语句始终会被执行。同时defer语句可以保证,即使io.Copy发生了异常,文件依然可以安全地关闭。

前文说到,Go语言中的导出函数一般不抛出异常,一个未受控的异常可以看作是程序的bug。但是对于那些提供类似Web服务的框架而言,它们经常需要接入第三方的中间件。因为对于第三方的中间件是否存在bug而抛出异常,Web框架本身是不能确定的。为了提高系统的稳定性,Web框架一般会通过recover来防御性地捕获所有处理流程中可能产生的异常,然后将异常转为普通的错误返回。

让我们以JSON解析器为例,说明recover ()的使用场景。考虑到JSON解析器的复杂性,即使某个语言解析器目前工作正常,也无法肯定它没有漏洞。因此,当某个异常出现时,我们不会选择让解析器崩溃,而是会将panic异常当作普通的解析错误,并附加额外信息提醒用户报告此错误。

func ParseJSON(input string) (s *Syntax, err error) {
    defer func() {
        if p := recover(); p != nil {
            err = fmt.Errorf("JSON: internal error: %v", p)
        }
    }()
    // 开始解析工作
}

标准库中的json包,在内部递归解析JSON数据的时候如果遇到错误,会通过抛出异常的方式来快速跳出深度嵌套的函数调用,然后由最外一级的接口通过recover()捕获panic,然后返回相应的错误信息。

Go语言库的实现习惯:即使在包内部使用了panic,在导出函数时也会被转化为明确的错误值。

有时候为了方便上层用户理解,底层实现者会将底层的错误重新包装为新的错误类型返回给上层用户:

if _, err := html.Parse(resp.Body); err != nil {
    return nil, fmt.Errorf("parsing %s as HTML: %v", url,err)
}

上层用户在遇到错误时,很容易从业务层面理解错误发生的原因。但是鱼和熊掌总是很难兼得,在上层用户获得新的错误类型的同时,也丢失了底层最原始的错误类型(只剩下错误描述信息了)。

为了记录这种错误类型在包装的变迁过程中的信息,我们一般会定义一个辅助的WrapError()函数,用于包装原始的错误,同时保留完整的原始错误类型。为了问题定位的方便,同时也为了能记录错误发生时的函数调用状态,我们很多时候希望在出现致命错误的时候保存完整的函数调用信息。同时,为了支持RPC等跨网络的传输,可能需要将错误序列化为类似JSON格式的数据,然后再从这些数据中将错误解码恢复出来。

为此,我们可以定义自己的包,包里面是下面的错误类型:

type Error interface {
    Caller() []CallerInfo
    Wraped() []error
    Code() int
    error

    private()
}

type CallerInfo struct {
    FuncName string
    FileName string
    FileLine int
}

其中Error为接口类型,是error接口类型的扩展,用于给错误增加调用栈信息,同时支持错误的多级嵌套包装,支持错误码格式。为了使用方便,我们可以定义以下的辅助函数:

func New(msg string) error
func NewWithCode(code int, msg string) error

func Wrap(err error, msg string) error
func WrapWithCode(code int, err error, msg string) error

func FromJson(json string) (Error, error)
func ToJson(err error) string

New()用于构建新的错误类型,和标准库中errors.New ()函数类似,但是增加了出错时的函数调用栈信息。FromJson用于从JSON字符串编码的错误中恢复错误对象。NewWithCode()则是构造一个带错误码的错误,同时也包含出错时的函数调用栈信息。Wrap()WrapWithCode()则是错误二次包装函数,用于将底层的错误包装为新的错误,但是保留原始的底层错误信息。这里返回的错误对象都可以直接调用json.Marshal()将错误编码为JSON字符串。

我们可以这样使用包装函数:

import (
    "github.com/chai2010/errors"
)

func loadConfig() error {
    _, err := ioutil.ReadFile("/path/to/file")
    if err != nil {
        return errors.Wrap(err, "read failed")
    }

    // ...
}

func setup() error {
    err := loadConfig()
    if err != nil {
        return errors.Wrap(err, "invalid config")
    }

    // ...
}

func main() {
    if err := setup(); err != nil {
        log.Fatal(err)
    }

    // ...
}

上面的例子中,错误被进行了两层包装。我们可以这样遍历原始错误经历了哪些包装流程:

    for i, e := range err.(errors.Error).Wraped() {
        fmt.Printf("wraped(%d): %v\n", i, e)
    }

同时也可以获取每个包装错误的函数调用堆栈信息:

    for i, x := range err.(errors.Error).Caller() {
        fmt.Printf("caller:%d: %s\n", i, x.FuncName)
    }

如果需要将错误通过网络传输,可以用errors.ToJson(err)编码为JSON字符串:

// 以JSON字符串方式发送错误
func sendError(ch chan<- string, err error) {
    ch <- errors.ToJson(err)
}

// 接收JSON字符串格式的错误
func recvError(ch <-chan string) error {
    p, err := errors.FromJson(<-ch)
    if err != nil {
        log.Fatal(err)
    }
    return p
}

对于基于HTTP协议的网络服务,还可以给错误绑定一个对应的HTTP状态码:

err := errors.NewWithCode(404, "http error code")

fmt.Println(err)
fmt.Println(err.(errors.Error).Code())

在Go语言中,错误处理也有一套独特的编码风格。检查某个子函数是否失败后,我们通常将处理失败的逻辑代码放在处理成功的代码之前。如果某个错误会导致函数返回,那么成功时的逻辑代码不应放在else语句块中,而应直接放在函数体中。

f, err := os.Open("filename.ext")
if err != nil {
    // 失败的情形,马上返回错误
}

// 正常的处理流程

Go语言中大部分函数的代码结构几乎相同,首先是一系列的初始检查,用于防止错误发生,之后是函数的实际逻辑。

Go语言中的错误是一种接口类型。接口信息中包含了原始类型和原始的值。只有当接口的类型和原始的值都为空的时候,接口的值才对应nil。其实,当接口中类型为空的时候,原始值必然也是空的;反之,当接口对应的原始值为空的时候,接口对应的原始类型并不一定为空。

在下面的例子中,试图返回自定义的错误类型,当没有错误的时候返回nil

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // 将总是返回非nil错误
}

但是,最终返回的结果其实并非nil,而是一个正常的错误,错误的值是一个MyError类型的空指针。下面是改进的returnsError

func returnsError() error {
    if bad() {
        return (*MyError)(err)
    }
    return nil
}

因此,在处理错误返回值的时候,没有错误的返回值最好直接写为nil

Go语言作为一个强类型语言,不同类型之间必须要显式地转换(而且必须有相同的基础类型)。但是,Go语言中interface是一个例外:非接口类型到接口类型,或者接口类型之间的转换都是隐式的。这是为了支持鸭子类型,当然会牺牲一定的安全性。

panic()支持抛出任意类型的异常(而不仅是error类型的错误),recover()函数调用的返回值和panic()函数的输入参数类型一致,它们的函数签名如下:

func panic(interface{})
func recover() interface{}

Go语言函数调用的正常流程是函数执行返回语句返回结果,在这个流程中是没有异常的,因此在这个流程中执行recover()异常捕获函数始终返回nil。另一种是异常流程:当函数调用panic()抛出异常时,函数将停止执行后续的普通语句,但是之前注册的defer()函数调用仍然保证会被正常执行,然后再返回到调用者。对于当前函数的调用者,因为处理异常状态还没有被捕获,所以和直接调用panic()函数的行为类似。在异常发生时,如果在defer()中执行recover()调用,它可以捕获触发panic()时的参数,并且恢复到正常的执行流程。

在非defer语句中执行recover()调用是初学者常犯的错误:

func main() {
    if r := recover(); r != nil {
        log.Fatal(r)
    }

    panic(123)

    if r := recover(); r != nil {
        log.Fatal(r)
    }
}

上面程序中两个recover()调用都不能捕获任何异常。在第一个recover()调用执行时,函数必然是在正常的非异常执行流程中,这时候recover()调用将返回nil。发生异常时,第二个recover()调用将没有机会被执行到,因为panic()调用会导致马上执行已经注册defer的函数后返回。

其实,对recover()函数的调用有着更严格的要求:我们必须在defer()函数中直接调用recover()。如果defer()中调用的是recover()函数的包装函数的话,异常的捕获工作将失败!例如,有时候我们可能希望包装自己的MyRecover()函数,在内部增加必要的日志信息然后再调用recover(),这是错误的做法:

func main() {
    defer func() {
        // 无法捕获异常
        if r := MyRecover(); r != nil {
            fmt.Println(r)
        }
    }()
    panic(1)
}

func MyRecover() interface{} {
    log.Println("trace...")
    return recover()
}

同样,在嵌套的defer()函数中调用recover(),也会导致无法捕获异常:

func main() {
    defer func() {
        defer func() {
            // 无法捕获异常
            if r := recover(); r != nil {
                fmt.Println(r)
            }
        }()
    }()
    panic(1)
}

两层嵌套的defer()函数中直接调用recover()和一层defer()函数中调用包装的MyRecover()函数一样,都是经过了两个函数帧才到达真正的recover()函数,这个时候Goroutine对应的上一级栈帧中已经没有异常信息。

如果直接在defer语句中调用MyRecover()函数,就又可以正常工作了:

func MyRecover() interface{} {
    return recover()
}

func main() {
    // 可以正常捕获异常
    defer MyRecover()
    panic(1)
}

但是,如果defer语句直接调用recover()函数,依然不能正常捕获异常:

func main() {
    // 无法捕获异常
    defer recover()
    panic(1)
}

必须要和有异常的栈帧只隔一个栈帧,recover()函数才能正常捕获异常。换言之,recover()函数捕获的是祖父一级调用函数栈帧的异常(刚好可以跨越一层defer()函数)!

当然,为了避免recover()调用者不能识别捕获到的异常,应该避免用nil为参数抛出异常:

func main() {
    defer func() {
        if r := recover(); r != nil { ... }
        // 虽然总是返回nil,但是可以恢复异常状态
    }()

    // 警告: 以nil为参数抛出异常
    panic(nil)
}

当希望将捕获到的异常转为错误时,如果希望忠实返回原始的信息,需要针对不同的类型分别处理:

func foo() (err error) {
    defer func() {
        if r := recover(); r != nil {
            switch x := r.(type) {
            case string:
                err = errors.New(x)
            case error:
                err = x
            default:
                err = fmt.Errorf("Unknown panic: %v", r)
            }
        }
    }()

    panic("TODO")
}

基于这个代码模板,我们甚至可以模拟出不同类型的异常。通过定义不同类型的保护接口,我们就可以区分异常的类型了:

func main {
    defer func() {
        if r := recover(); r != nil {
            switch x := r.(type) {
            case runtime.Error:
                // 这是运行时错误类型异常
            case error:
                // 普通错误类型异常
            default:
                // 其他类型异常
            }
        }
    }()

    // ...
}

不过这样做就与Go语言简单直接的编程哲学背道而驰了。

本书定位在Go语言进阶图书,因此读者需要有一定的Go语言基础。如果对Go语言不太了解,作者推荐通过以下资料开始学习Go语言。首先是安装Go语言环境,然后通过go tool tour命令打开A Tour of Go教程学习。在学习A Tour of Go教程的同时,可以阅读Go语言官方团队出版的The Go Programming Language一书。在学习的同时可以尝试用Go语言解决一些小问题,如果遇到要查阅API的时候可以通过godoc命令打开自带的文档查询。Go语言本身不仅包含了所有的文档,也包含了所有标准库的实现代码,这是第一手的最权威的Go语言资料。我们认为此时读者应该已经可以熟练使用Go语言了。


相关图书

Rust游戏开发实战
Rust游戏开发实战
仓颉编程快速上手
仓颉编程快速上手
Go语言编程指南
Go语言编程指南
JUnit实战(第3版)
JUnit实战(第3版)
Kafka实战
Kafka实战
PyQt编程快速上手
PyQt编程快速上手

相关文章

相关课程