Google 的 Go 语言团队会在 2018 年初宣布 Go 1.10 正式版的发布。10 这个二位数也预示着 Go 语言的进一步成熟。据说,Go 2 也被提上了日程。Go 2 将会是大神们对 Go 语言的一次彻底反思和改进。虽然现在细节还没有被暴露出来,但是这已经足以让 Gopher(Go 语言爱好者)们激动不已了。会有泛型支持吗?GC 会变革吗?详细调参会可行吗?各种猜测已经在各个论坛和群组里层出不穷了。
不过,饭要一口一口吃,肌肉要一点一点练。在憧憬未来之前,先让我们看看 Go 语言在 2017 年的表现。
首先,根据 Google Trends 的统计结果(https://trends.google.com/trends/explore?q=golang&hl=en-US),我们可以看到 Go 语言在过去一年中的流行程度是稳中有升。
图 1 Go 语言在 2017 年的流行趋势
初看起来,Go 语言在 2017 年表现得比较平淡。但是,让我们把时间线拉长。
图 2 Go 语言在过去 5 年间的流行趋势
你一定看到了,Go 语言在 2017 年的“上升”是对近年来的一种延续。有些编程语言会随着其擅长领域的爆红或遭冷而大起大落。但是 Go 语言不会。这就像它朴素的编程风格一样。它的使用范围很广,我几乎可以在任何场景下使用它。
Go 语言的适用范围一直在不断地扩大。经过广大开发者的共同努力,它已开始涉足在当前大热的数据科学和机器学习领域。虽然还只是开始,但是就像我在《Go 并发编程实战》第 2 版的扉页中说的那样,我“深信 Go 语言在人工智能时代和机器人时代也能大放异彩”。
更令人欣慰的是,中国的开发者对于 Go 语言的流行起着至关重要的作用。在过去的一年里,我们的热衷和贡献依然领跑全世界!
图 3 Go 语言在 2017 年的流行区域热图
回 顾
我在前年和去年分别写过两篇相关的文章——《解读 2015 之 Golang 篇:Golang 的全迸发时代》和《解读 2016 之 Golang 篇:极速提升,逐步超越》。大家可以把它们当做一个系列的记录参看。
类型别名
类型别名(type aliases)原本是要在 Go 1.8 发布时推出的。但是由于一些争议和实现上的问题,Go 团队把它推迟到了 Go 1.9。
这一特性其实是为开发者们的代码库重构准备的。代码重构是对代码的重新组织,以及这种重组与代码包之间的关系的重新思考和修改过程。代码重构的原因可能是代码的拆分、命名优化或者依赖关系清理,等等。但是不论原因是什么,我们的目的都是让代码变得更清楚、更易于使用,以及更容易扩展。
也许你有过这样的经历:在进行(范围比较广的)代码重构的过程中,原有代码已经改变。甚至,当你完成重构并想把代码合并回去的时候,原有代码已经面目全非。合并代码有时候比重构代码更加困难。
这就引出了一个比较先进的重构方法——渐进式代码重构。也就是说,同时计划重构的终极目标和阶段性目标。在达到完备状态之前,设置几个易实施并且可用的中间状态。纵观 Go 语言的代码更新和版本升级过程,我们不难发现 Go 团队对这种方法的合理运用。
然而,在渐进式代码重构的过程中我们也可能会遇到问题。例如,当你想把一个允许包外代码访问的类型迁移到另外一个包中时,该怎么做?简单来说,应该分为三步:1)在目的包中声明一个名称和功能都相同的新类型。2)检查所有可能引用原类型的地方,并把那些引用都指向新类型。3)删除原包中的那个类型。如果这个类型只被由你掌控的程序中引用,那么这种方式当然没问题。但是,如果该类型所在的是一个已被广泛依赖的底层代码包,那我劝你还是不要执行第 3 步了。除非你冒着被口水淹死的风险发布程序不兼容声明。可是不执行第 3 步就等于任由废弃代码在程序中蔓延。这也是很恶心的一件事。
讲了这么多,我要说的重点是:Go 的类型别名就是为我们解决这类两难的问题的。在类型别名真正问世之前,Go 团队自己在做上述重构时都不得不用上一些特殊的、脆弱的手段。
若使用类型别名的话,应该怎么做?如果我们要把 oldpkg.OldType 类型迁移到 newpkg 代码包中并将其改名为 NewType,那么最少用 2 步就可以完成:1)声明 newpkg.NewType 类型。2)把 oldpkg.OldType 类型的声明改为:
package oldpkg type OldType = newpkg.NewType
新的 oldpkg.OldType 类型声明可以使它与 newpkg.NewType 完全等价,并可以实现互换。也就是说,如果一个函数有一个 oldpkg.OldType 类型的参数声明,那么该函数就可以接受一个 newpkg.NewType 类型的参数值。
当然,为了不留废弃代码你仍然需要在某个时间删除掉 oldpkg.OldType。但是类型别名给了你和其他使用 oldpkg.OldType 的开发者一个可以游刃有余的必要条件。你们为重构代码而设置的中间状态都可以轻松达成。
举个例子,你在按照上述 2 步迁移完类型之后,可以马上把自己写的某个函数的声明由
package handler import oldpkg func HandleXXX(obj oldpkg.OldType){}
改为
package handler import newpkg func HandleXXX(obj newpkg.NewType){}
然而,在调用该函数的(其他人写的)程序那里,却可以不用做任何修改(虽然最后可能要修改)。代码 handler.HandleXXX(oldTypeVar) 仍然有效。其中的 oldTypeVar 是 oldpkg.OldType 类型的值。这就相当于可以让各方按照自己的节奏重构代码了。
再后面的情景可能是:你在你的代码包还是 1.0 版本的时候就发布声明说 oldpkg.OldType 类型即将在 2.0 版本中删除。而当你在开发 2.0 版本时,心安理得地删掉了 oldpkg.OldType。
实际上,类型别名只是 Go 团队为了让我们顺利实施大规模 Go 软件工程的举措之一。他们一直在致力于帮助开发者们高效地编写 Go 代码和利用 Go 代码包,并避免因不经意的重复造轮子而导致的代码膨胀。如果你真正用过 Go 语言,那么也一定能体会到它在代码依赖方面展现出的规范性和严谨性。
sync.Map
广大 gopher 们又迎来了一个提供并发安全性的高级数据结构——sync.Map。这个数据结构提供了一些常用的键、值存取操作方法,并保证了这些操作的原子性。同时,它也保证了存取的性能——算法复杂度依旧是 O(1) 的。相信很多人已经期盼了很久。不过请注意,它是 Go 语言标准库中的一员,而不是语言层面的东西。也正因为这一点,Go 对它的键类型和值类型并无程序编译期的类型检查。我们只能在程序运行期自行保证键、值类型的正确。
如果你一直关注 Go 语言,可能已经猜到它就是之前一直蛰伏在 golang.org/x/sync/syncmap 包中的那个 struct。现在它被纳入了标准库,并将会获得更多的底层优化的可能。
在 sync.Map 问世之前,我们如果需要并发安全的字典结构,那么就需要自行搭建。这其实也不是麻烦事,使用 sync.Mutex(互斥锁)或 sync.RWMutex(读写锁)在再加上原生的数据类型 map 就可以轻松办到。Github 网站上就有很多库提供了类似的数据结构。我在《Go 并发编程实战》第 2 版中也提供了一个实现较完整的并发安全字典。它的性能比同类的第三方数据结构还要好一些。因为它在很大程度上有效地避免了对锁的依赖。
大家应该都知道,使用锁就意味着要把一些并发的操作强制串行化。这对程序的性能是有很大的负面影响的,尤其是在有多个 CPU 核心的情况下。因此我们常说,能用原子操作就不要用锁。可惜前者只对一些基本数据结构提供支持。
不管是哪一种操作,它们在多个 CPU 核心面前都是相对低效的。因为只要是对共享状态(比如多个线程都可见的同一个变量)的存取就会涉及到状态的同步。在 CPU 层面,这种同步就是 cache 级别的,也可称之为 cache contention。你一定听说过 CPU 的 L1 cache 和 L2 cache。举个例子,如果在不同 CPU 中运行的线程同时在操作同一个锁(更确切地说是锁中的计数变量,具体可参看 sync.Mutex 的源码),那么它们会争相声明存在于自己的 cache 中的那个变量的值是唯一有效的(或者说是最新的)。一旦有一个线程声明成功,那么运行于其他 CPU 核心中的线程再想存取相同的变量就必须先从前者那里做同步。这个同步的耗时在 CPU 层面是很可观的。
那么,sync.Map 帮我们解决上述问题了吗?很遗憾,答案是没有完全解决。实际上,这根本就不是在应用层面(甚至操作系统层面)可以完全解决的问题。我们只能经过一轮又一轮的优化获得更高的性能,或者说逐渐逼近最高性能。
sync.Map 在内部使用了大量的原子操作存取键和值, 并利用两个 map 作为存储介质。
其中一个 map(字段 read)可被视作一个快照。这个快照也可被称为只读字典。它保存了在前一个操作周期结束时 sync.Map 值中包含的所有键值对。虽然其中的键所对应的值都可以被更改,但是键绝不会有增减。因此,这里的“只读”是对其中的键的集合而言的。如此一来,对只读字典的操作无需使用锁。实际上,在 sync.Map 的增、删、改、查方法中会首先尝试操作只读字典。
另一个 map(字段 dirty)中存有最新的键值对的集合。它也可被称为脏字典。新的键值对会首先被存储到该字典中。sync.Map 中所有的增、删、改、查方法在操作脏字典时都需要在锁(字段 mu)的保护下进行。
在达到当前操作周期的边界的时候,sync.Map 会把脏字典提升为只读字典,并把存储脏字典的字段设置为 nil。当再有新的键值对存入时,sync.Map 会对脏字典进行重新初始化,并把只读字典(前一个操作周期中的脏字典)中的所有键值对反灌入脏字典,最后把新的键值对也加入其中。在新的操作周期中,(新的)只读字典和(新的)脏字典依然分别代表着键值对集合的快照和最新版本。如此一来,通过两个字典之间的周期性同步,sync.Map 就实现了键值对操作的“快路径”和“慢路径”。“快路径”就意味着无锁化操作,而“慢路径”仅在“快路径”不通时才会被考虑。
顺便说一句,sync.Map 在判断当前操作周期的边界达到与否时依据的是一个记录着“在只读字典中未找到被查询键”这种情况的发生次数的计数值(字段 misses)。一旦这个计数值等于脏字典的长度,就意味着一个操作周期的结束。显然,操作周期会随着脏字典的增大而变长。
图 4 从键值对的流转方式看 sync.Map
上图从另外一个角度展现了 sync.Map 存取键值对的方式。
总体上讲,sync.Map 的内部实现就是如此。如果你想探究细节可以查看 Go 语言标准库代码包 sync 中的 map.go 文件。
通过对 sync.Map 的实现的理解,我们就可以分析出它的优势和劣势。显然,sync.Map 更适合于键的集合相对固定的场景。这时只有一些必要的原子操作会对性能有轻微的影响。更具体地讲,如果所有的键值对都在初始化 sync.Map 值时加入,之后仅有键值对的读取和更新而没有添加,那么 sync.Map 就会发挥出很高的性能。当然,如果在多个线程中同时对同一个键的值进行更新,那么还会存在由原子操作引发的竞争。这也会涉及到 cache contention。
另一方面,如果在使用过程中有非常多的新键存入的话,那么在极端情况下它的性能很可能会回退至 map + sync.Mutex/sync.RWMutex 的水平,甚至还会不如后者,别忘了其中还有很多原子操作。另外,请注意,sync.Map 所占用的空间比 map 要多。至于多多少,还要看实际的键值对获取情况。
以上就是我对 sync.Map 的简单剖析。希望可供大家在选择字典的并发安全策略时参考。
其他值得注意的改进 并行编译
Go 1.9 默认会并行地编译你的代码。不过前提是你的机器上多个 CPU 核心。其实在这之前并行编译也是存在的,只不过那时的粒度是代码包。也就是说,不同的代码包可被并行地编译。但是 Go 1.9 的并行编译粒度是函数级别的。这显然可以使编译更加迅捷。
关于 vendor 目录的处理
之前我们执行 go build ./... 命令的时候,当前目录内的 vendor 目录也会被编译。但是现在必须输入 go build ./vendor/... 才可以。对于其他的 Go 标准命令也是如此。我就为此烦恼过,所以很高兴看到这样的变化。请想象一下,当你在执行 go test ./... 的时候,Go 也会测试你依赖的那一坨代码包。在很多时候这根本就没有必要,白白浪费时间。
关于 GC
我们都知道,Go 的 GC 是并发的。但这只限于自动 GC。当我们手动触发 GC 时,Go 运行时系统依然会“Stop the world”(或者说停止内部调度)。值得庆幸的是,Go 1.9 为我们带来了对手动 GC 的并发支持!这涉及到了 runtime.GC、debug.SetGCPercent 和 debug.FreeOSMemory 函数。不过要注意,调用这些函数的 goroutine 是会被阻塞的,直到 GC 完成。
除此之外,GC 的性能又得到了进一步提升,尤其是在拥有庞大(大于 50GB)的堆内存的情况下。
单调的时间度量
在 Go 1.9 之前,标准库代码包 time 中的一些函数是通过读取计算机的系统时钟来实现功能的。这样做的好处是总与系统保持一致,而坏处是一旦系统时钟被篡改,它们也会无脑地跟着变更。这可能会让使用它们的 Go 程序产生错误,尤其是我们在依赖时间做一些任务的时候。请想象一下,千年虫或者闰秒调整那样的问题再次出现时会怎样。因此,Go 必须优雅地应对系统时钟重置。这在 Go 1.9 中得到了解决。
现在,包括 runtime、time、context 和 net 在内的代码包都使用了单调的时间度量。在必要时,系统时钟重置导致的时间度量错误会被自动修正。我们不会再为此困扰了。
除了上述的这些可喜的改进之后,Go 标准库也被大范围地更新了。比如,新的用于处理位操作的 math/bits 包。又比如,testing 包对帮助函数更加友好。还比如,runtime/pprof 包中的诸多改进。等等等等。
在开始撰写本文之前,Go 1.9.2 早已发布。在我的 Go 程序研发团队中有个规定:通常情况下,当小版本(比如 1.9)的第二个维护版本出来之后,我们就开始更新各种测试环境甚至生产环境上 Go 的版本。当然,我们的评估和试用在小版本的第一个版本发布时就开始了。这既可以避免因过于激进而踩坑,又可以尽早享用 Go 版本升级的红利。在这里供大家参考。
展 望Go 1.10+
先说说离我们最近的 Go 1.10。该版本的 beta 版已经发布。它又带来了众多改进,比如:更宽松的语法、更智能的标准命令、更完善的 Go 汇编支持、无限的最大 P 数量(GOMAXPROCS)设置,以及一如既往的性能提升和标准库改进。
注意,有两个标准库代码包的变更可能会导致你代码的必要修改。一个是 bytes 包,涉及到 Fields、FieldsFunc、Split 和 SplitAfter 函数。另一个是 net/url 包,主要涉及到 ResolveReference 函数。后者不会再想当然地去掉 URL 路径中的“多余”斜杠。这样可以避免 http.Client 在某些情况下的重定向错误。同时也是为了符合 RFC 3986 协议。
至于以上变更的细节,请大家参看 Go 1.10 的 release notes。
在 Go 即将满 10 岁之际,在 Gophercon 2017 大会上,Go 团队终于把 Go 2 的事情郑重地摆上了桌面。Go 语言的目标一直是帮助开发者们高效地完成现代软件的开发和部署。Go 2 的目标仍然如此。但更重要的是,Go 2 会修正 Go 1 中不利于实现前述目标的一些方面。据我个人推测,Go 2 的升级方式会介于 Java 和 Python 之间,同时在广大开发者可接受的范围内会更倾向于 Python。大家都知道,Java 的大版本升级过于保守,而 Python 的大版本升级则过于激进。
Go 2 可能会出现很大的变革,但就像 Java、Python 这些“白胡子”语言一样,大版本的升级必然会存在很多权衡和妥协,也必然会包含与 Go 1.x 的不兼容。到目前为止,Go 团队还没有公布任何细节。改革错误处理方式?增加不可变值?自定义泛型?一切都没有定数。但不论怎样,Go 团队会想尽一切办法让广大开发者平滑过渡到 Go 2 之上。让我们翘首期盼吧!
社区,永远的社区
国内使用 Go 语言的人依然在不断增长。其增长程度已经让一些在线教育公司敏锐地嗅到了机会。他们已经把发展 Go 语言相关课程作为了 2018 年的重点之一。
2017 年我对国内社区的最大感触就是地方性的 Go 语言组织越来越多了。许多省市内的 gopher 们都自行组织起来,一起加强交流、共同增进技能。就拿 12 月 16 日在深圳举办的 meetup 来说,当场人数也超过了 100。现场的技术氛围也很浓郁。大家都在积极地互通有无。可喜可贺!现在各地方的中小型 Go 语言技术聚会基本上都可以达到这个规模了。今年我在北京只组织了一场活动,有些遗憾。我在这里也检讨一下。
我去年呼吁各个使用 Go 语言的公司在 Github 上 Go 语言的 wiki(https://github.com/golang/go/wiki/GoUsers)中加入自己公司的主页链接。那时,China 那一栏下只有一家公司。如今增加到了 5 家。可是这仍然比我知道的公司少太多了。因此我在今年再呼吁一下。大家多向官方以及国际发声吧!这对我们是有好处的。
另外,我很久之前在 Github 上建立了一个国内卓越 Go 项目列表(https://github.com/GoHackers/awesome-go-China),也希望大家能把个人或者公司开源的项目加入其中。如此一来,我们就可以在进行 Go 框架和工具选型的时候有一个比较集中、方便的参考之地。同时也可以增进大家的交流。我们可以在 Github 上更有的放矢地为优秀项目贡献代码。另一方面,这也是吸引代码贡献者的另一个渠道。
从人才市场的方面看,国内招聘 Go 工程师的公司也越来越多了。就算在年底,各大 Go 语言微信群和 QQ 群里也能看到很多招聘启事。看来明年又是 Go 工程师们大展拳脚的一年。
我个人认为 Go 语言在当前以及可见的未来会更多的应用在如下几个热门领域中。首当其冲的当然是云计算。Go 不仅擅长 Web 系统、API 服务等应用层软件的开发,也可以用来开发中间件甚至基础设施。云计算中几乎所有的软件都可以用 Go 语言来开发,而且很有优势。实际上,在这个领域,可以说 Go 语言已经成为大家的首选语言了。这部分得益于 docker、kubernetes 等项目的持续火爆。其次是区块链。现在国内做区块链的公司有很多。这些公司也大都以 Go 语言为主力。虽然比特币(或者说加密数字货币)现在在国内的状态不容乐观。但是作为其核心技术的区块链却依然受到热捧。据我所知,很多 Go 工程师投身其中了,甚至有些国外的区块链创业公司已想在国内开设办公室了。
顺便说一句,对区块链技术有兴趣的 gopher 可以去研究下 hyperledger(https://cn.hyperledger.org/)这个项目。再次是数据科学和机器学习这两个领域。很多人会说“这都是 Python 的底盘啊,而且几乎不可动摇”。但是我要说的是,其实在这两个领域中 Python 也不是一家独大的,像 R、Julia、MATLAB、C++ 等语言都有自己的一席之地。对标 numpy,最近持续升温的 Gonum(https://github.com/gonum/gonum)很不错。另外,Tensorflow 框架也早早放出了 Go 语言版本的 API。当然我不是鼓吹大家在这些领域中使用 Go 语言。各个语言在不同领域都有各自的优势,重点是怎样提高生产力。但是我认为 Go 语言会在这些领域持续***并扩大优势。各位可以重点关注一下。
在这个面临着技术和商业大变革的时代,Go 语言是一项很好的技术资本,起码你应该让它作为你技术工具箱中的重要一员。如果你主攻后端技术但还不会 Go 语言,我建议你花几个小时入门一下,然后再花一些时间体会和理解它的编程哲学。我相信这对你的技术成长是很有好处的。
最后,如果你想融入一个国内的 Go 语言技术社区,不妨加入我发起的组织——GoHackers。它的前身是 Go 语言北京用户组(微信公众号:golang-beijing)。后者已经有 3 年左右的历史了。GoHackers 的微信公众号是“gohackers”,在开发者头条中的团队号是“GoHackers”。前者主要用于发布 Go 语言动向、国内活动预告以及相关招聘启事,后者主要用来分享优秀的国内外 Go 技术文章。
有疑问加站长微信联系(非本文作者)