这次去 Gopher China 和不少老朋友见了个面,还有不少在微信上认识已久,一直没见过面的网友。同时也和各个公司的一线开发们聊了聊,互相交流了彼此使用 Go 时的一些心得和痛点。
综合近期了解的一些相关分享,我把到目前为止见到的,不那么常见的,各方对 Go 的优化和 hack 集中在这篇文章里。因为考虑到一些公司情况比较特殊,所以本文中列出的点就不标记是哪个公司做的了。未来他们觉得时机成熟应该也会自己出来做一些分享。
网络方面
当前 Go 的网络抽象在效率上有些低,每一个连接至少要有一个 goroutine 来维护,有些协议实现可能有两个。因此 goroutine 总数 = 连接数 * 1 or 连接数 * 2。当连接数超过 10w 时,goroutine 栈本身带来的内存消耗就有几个 GB。
大量的 goroutine 也会给调度和 GC 均带来很大压力。Go 底层的网络库和加密库效率也不是很高,所以在同类应用上和 C++ 等语言有较大性能差距(内存、吞吐量)。
社区有不少使用裸调 Epoll 实现优化的库,就不给他们打广告了。由于用户的 syscall.EpollWait 是运行在一个没有任何优先级的 goroutine 中,当 CPU idle 较低时,系统整体的延迟不可控,比标准库的延迟还要高很多。之前个人做过相关的测试,核心数的增加也会使系统相应的延迟大幅上升。
接下来不同的公司的优化思路就走向了两个方向:
修改 runtime,在 runtime 增加用户 Epoll 函数的回调。类似 runtime 自己实现的 netpoll 那样。这种方式会导致 Go 本身难以升级,必须跟着有 hack 的版本走。当遇到 Go 的 bug 时会比较尴尬。
把 c 实现的网络库当作基础组件,把 Go 实现的业务逻辑作为业务嫁接在 c 库之上。
Go 语言的强项就是在网络编程上,现在却逼得大家为了优化都需要去做对 runtime 的 hack,甚至嫁接其它语言,这不能不说有点悲哀。还是希望未来官方能够有更好的底层基础工具来支持这种超高连接数的场景。
cgo
因为 c 历史悠久,所以对 cgo 的使用是难以避免的,个人见到比较多的,比如做国际化需求必须要用到的 icu 库,只能用 cgo 来调。或者做 cv 的 opencv,也只能用 cgo。或者国密场景,也只能用 cgo。
但从 go 调用到 c,有一次栈切换,成本有些高,所以有公司实现了从 go 切换到 c 不需要切换栈的神奇调用方式,同时在栈上打标记,让 GC 只扫描 Go 的栈而不扫描 c 的栈。
go-plugin
官方提供的 go plugin 还是比较难用的,比如要求编译版本必须统一;加载后无法卸载等。
现在有公司基于 .got 表,实现了比官方的 plugin 更灵活的热加载,热卸载的动态库。
(这条我不太懂,所以只是听说,有问题欢迎指出
汇编优化
Go 语言的编译后端都是 Go 自己实现的,没有借助以往的平台,如 LLVM。
有人将 C 语言编写的等价代码用较高的优化级别,如 clang -o3,编译为高度优化的汇编,再翻译为 plan9 汇编,整合成函数供 Go 应用调用,这样相当于在 Go 里享受了 llvm 平台的后端优化成果。
runtime 修改
除了前面提到的网络编程时,epollwait 需要高优先级的 goroutine,在其它一些涉及到任务分发,任务处理的应用程序中也需要类似的高优先级 goroutine。
所以有公司直接在 runtime 提供了接口,让用户可以通过接口来创建特殊优先级的 goroutine。
除了暴露接口外,对 runtime 的实现代码也是要做不少修改的。
通过 SSA 进行的静态检查
我们知道,当前社区的 golangci-lint 大多是用 Go 内置的编译前端来完成的,在编译后的 ast 上做一些逻辑,来提示用户代码中可能存在的问题。
在一些较底层的编程场景,希望能够消灭所有的堆分配,所以他们通过检查生成的 SSA 中是否有 newobject 的调用来辅助进行代码的优化。
垃圾回收
Go 语言的垃圾回收没有分代,分代会涉及到不同代际之间的对象移动,从 Go 官方历史上的分享来看,Go 的开发者们对 non-moving 比较执著。因为如果堆上对象会 move 的话,需要在读对象时开启 read barrier,几乎所有场景都是读多写少的场景,这样会极大影响程序性能,所以官方现在在分代的研发上处于停滞状态。
国内某公司在之前官方分代 GC CL 的基础上实现了分代垃圾回收,不过因为看不到代码,不太清楚他们最终在生产环境是否能够达到较好的性能改善。
总结
未来如果发现更多值得一聊的优化的话,也许会写个续。
本篇中的内容都是简单的介绍,没有太多细节,如果感兴趣的读者比较多的话,我们后续把每一点都展开来讲讲~