我为什么不再喜欢 Go 了

2021-12-20

2 年前我发表了一篇题为我为什么要把 Go 作为主力语言的文章。彼时我与 Go 初相识,满满都是蜜月期的欣喜,甚至不惜以留下一篇黑历史为代价,狠狠地对其进行了吹捧。时至今日再次翻及往事,看着字里行间让人难以直视的尬吹,我恨不得凿个地洞钻进去......当然,直面历史,深刻反省才是好的,于是本文孕育而生,主要目的是为了对前述那篇文章进行批判,看看当时年少无知的我犯下了哪些错误,同时再讲讲自己写 Go 这 2 年的一些新感悟。

那么有没有一门既有动态语言的特性,又能在运行时有良好性能,甚至拥有很好的多线程表现的语言存在呢?结果我认为是肯定的,这门语言就是 Go。

很遗憾,说的这几个我现在都很难认同了,后面再详细展开。

所以第一个要提到的便是 Go 的语法设计非常简洁,一共只有 25 个关键字,虽然没有类的存在,但是 Go 通过 Interface 实现了抽象程序行为的特性,其思想有点类似多态的概念,使用起来也十分的灵活。

所谓「大道智减至简」,现在看来简洁似乎并没有给 Go 带来好写的一面。他确实让「统一语言习惯」这件事变容易了,但是这真的是好事吗?没有泛型(其实有了,但至少还没普及)甚至让排序这件事都需要先 Len/Swap/Less 一把梭,体验实在是一言难尽。同时看着 Rust 等其他语言在表达能力上的丰富,各种闭包,各种链式调用浑然天成。再看看自己只能 for range 乖乖循环遍历 slice 的苦逼样子,说不羡慕只能是假的。

以及 interface 这个设计本身也是让人又爱又恨,爱在简单的抽象让程序写起来没有那么复杂,基于行为定义的接口某种意义上也更符合写「面向过程」代码时的直觉;然而恨就在维护和阅读 interface 实在是太蛋疼了,每次读代码企图学习某些功能的内部实现时,看到参数传入的是一个 interface 我就心凉了一半,你不知道这个 interface 实际在运行路径上会具体传入哪个实现,能做的只有再忍痛读调用前的代码并祈祷不要再遇到更多的 interface。然后你会暮然回首感慨:这 TM 跟动态语言有什么区别?有时候我看着 TiDB 在拿到一条 SQL 语句的 AST 以后,还要用 switch 语句一个一个区分不同类型 Node 的处理逻辑,我会想这一切到底是怎么变成这样的呢?

尽管 Go 是一门静态的强类型编译语言,但是 Go 也提供了一些类似动态语言的特性。例如使用类型推导来减少代码的工作量。(并不是偷懒)

确实不是偷懒,但我也不知道一个类型推导在那个时候怎么就成了我眼中动态语言的特性了,也许这就是又菜又爱吹吧,好想从这个世界上消失

Go 语言中的并发程序有两大法宝。即 goroutine 和 channel,其基于一种名为「顺序通信进程 Communicating Sequential Processes 」的现代并发编程模型而来,在这种编程模型中值会在不同的运行实例 goroutine 中通过 channel 来传递。

接触 Rust 后学到了一个词:零成本抽象,大意是你获得某样高层的抽象特性所需要付出的代价(几乎)为零。在 Go 里面,goroutine 显然不是一个零成本抽象,即便它只围绕了 go 这样一个简单的关键字,但在使用 goroutine 这件事情上所要实际付出的代价,有时候也许让 Go 引以为傲的协程并发并没有那么美好。

TSO 是 TiDB 中很重要的一个模块,用于给事务提供满足线性一致性的单增时间戳(我的前两篇博文有其源代码解析和原理介绍,感兴趣的可以一读)。所以它几乎是每一条 SQL 上的 Hot Path,其性能也对 TiDB 的性能产生了至关重要的影响。我们在测试 TSO 性能的过程中对正在处理大量 TSO 请求的 PD 节点(TSO 服务的提供者)进行了 Profile,火焰图如下。

TSO 火焰图

可以看到实际 TSO 的生成计算逻辑,只占据了整个堆栈不到 2% 的调用时间,要知道与此同时 PD 的 CPU 几乎是已经被吃满了的。在火焰图里,Golang 的 Runtime 调度占据了大量的 CPU。原因也不难理解,大量不同的 gRPC 请求被同时发送到了 PD 节点,但由于 TSO 计算过程和原理很简单,所以每一个请求的实际计算并不会占用很长时间,于是大量的任务切换夺走了仅剩的 CPU 性能。为了对比协程/线程切换这件事实际给负载带来的影响,我们用一个提供了几乎一样功能的 Rust 版本 PD 进行了测试,得益于可以手动设置的 gRPC 线程池大小,当我们将线程池大小设置为 1 时,对比 Golang 版本的 PD,CPU 消耗降低了几乎 80%,同时并没有严重影响性能(甚至在延迟等表现上有所提高)。

对于这样的问题,独立出来一个专门的线程池给 TSO 服务是一个比较直观且符合测试结果的方法。但很遗憾,Golang 并不能提供给我们这样简单的机会,也几乎没有关于 goroutine 的并发参数的调整能够帮到我们。我们最后只能诉诸于对 TSO 请求进行 Batch 和转发来降低 PD leader 节点高 CPU 占用的方法来曲线救国。goroutine 确实提供了足够简单的抽象让我们去实现并发,但这也从来不是完全没有代价的多线程银弹罢了。

最后需要再次强调,Go 是一门静态的强类型编译语言,这也注定了其性能和效率非 Python 这样的解释型语言所能比拟。Python 非常适合敏捷开发,即快速写出具有许多高级功能的程序,但并不总是能够提供大型项目所需的高性能。而 C 可以创建高性能的可执行文件,但是添加功能会花费更多时间。Go 被称为 21 世纪的 C 语言,不得不说其确实具有一定两全其美的特性。

关于 Go 的性能问题,前例也仅仅是一个引子,TiDB 在早期版本(甚至现在)也还在 GC 等 Runtime 问题上被有所牵制。Go 的语言特性也让我们在许多与并发资源相关的工作上开展没有那么顺利。

这大致就是 Go 相较于 Python 给我的感受。虽然我也是才开始接触 Go,上文所提也仅是 Go 语言特性的冰山一角,还有许多诸如数据类型、包管理和方法等语言特性还未涉及,但我相信这些灵活好用的特性足以支撑起我成为 Go 拥簇的选择,希望 Go 能够日益完善的发展下去,我也能伴随着 Go 的进化一同成长成为一个合格的 Gopher。

Go 也在一路迭代,甚至在 1.17 版本才引入基于寄存器的函数传参这样史诗级更新,TiDB 也享受了这种语言升级带来的红利(难道不是应该的吗)。现在泛型也呼之欲出,也许在不久的将来就能稳定下来且惠及标准库实现,让我们见到全新的 Go。

此时再回看当年的我在文章一开始写下的这段话:

这不学不要紧,一展开对 Go 的了解,我便狠狠地喜欢上了这门语言(可能和一见钟情的感觉类似)。也出于此,我想写一篇博客来谈一谈我在与 Go 接触的第一印象中,到底是什么吸引住了我,以至于我想要把 Go 从今往后作为自己的主力语言。

现在的我很喜欢一句话:Cheerleading any kind of inanimate object is silly。我也在 brupst 的项目介绍里这样写道:「我们无意参加各类语言之争,也不倡导说出「Rust 是世界上最好的语言」这种言论 (如果你发自真心这么觉得,倒也不是不可以) ,我们只希望能在 Rust 发展的道路上尽自身一份绵薄之力,并让其优势惠及更多有趣的灵魂和创意。同时宣扬开源精神,让这个世界变得更好!」,私以为一个成熟的程序员不应该花费精力参与到这种类似宗教战争一样的运动中去,没有哪个木匠会为了「扳手好用还是锤子好用」这样的事情而与另一个木匠争执不休。我觉得语言某种意义上来说也是同理。所以说这种所谓的「作为主力语言」还是算了(无非是没机会写其他的语言罢了)。每种语言都有优势和不足,能够掌握多种语言在各种领域游刃有余,在重要的时刻能够正确地选择最适合自己的瑞士军刀也许才是修炼之道,Go 语言修仙?

Tagged in : Golang