要说写代码是每位程序员的使命,那么写优秀的代码则是每位程序员的底线。本文作者分享基于 Go 语言的代码重构,使得性能提升 23 倍的快速方法。
以下为译文:
几周前,我读了一篇名为“Go 语言中的好代码与差代码”(https://medium.com/@teivah/good-code-vs-bad-code-in-golang-84cb3c5da49d)的文章,作者一步步地向我们介绍了一个实际业务用例的重构。
文章的主旨是利用 Go 语言的特性将“差代码”转换成“好代码”,即更加符合惯例和更易读的代码。但是它也坚持性能是项目重要的方面。这就引起了我探索的好奇心:让我们深入看看!
1
这篇文章里的程序基本上就是读取输入文件,然后解析每一行并存储到内存的对象中。
作者不仅在 Github 上发布了他的代码(https://github.com/teivah/golang-good-code-bad-code),还写了个性能测试程序。这真是个好主意,鼓励大家调整代码并用如下命令重现测量结果:
每次执行所需的微秒数(越小越好)
基于此,在我的机器上测出“好代码”速度提升了 16%。那么我们可以进一步提高吗?
以我的经验看来,代码质量和性能间的相互关系非常有趣。如果你成功地重构代码,让代码更清晰,更进一步分离,那么最终代码速度会加快,因为它不会像以前一样徒劳无功地执行不相关的指令,而且一些可能的优化会凸显出来,且易于实现。
另一方面,如果进一步追求性能,那就不得不放弃简单性并诉诸黑科技。实际上你只减少了几毫秒,但是代码的质量会受到影响,会变得晦涩难懂、脆弱且缺乏灵活性。
简单性先是上升,继而下降
你需要权衡利弊:应该进行到什么程度?
为了正确地确定性能的优先级,最有价值的策略是找到瓶颈,然后集中精力改善。可以使用分析工具来做!例如 Pprof(https://blog.golang.org/profiling-go-programs) 和 Trace(https://making.pusher.com/go-tool-trace/):
一个非常大CPU使用图
彩虹追踪:许多小任务
追踪结果证明所有的 CPU 内核都得到了利用,乍一看似乎不错。但是它显示了几千个很小的彩色计算片段,还有一些空白表示内核闲置。让我们放大一点:
3毫秒的窗口
实际上,每个内核都有大量闲置的时间,并且在多个微型任务间不断切换。看起来任务的粒度并不理想,从而导致大量上下文切换,还有同步引起的资源争抢。
我们用数据冲突检测器检查下同步是否正确(如果同步都不正确,那问题就不只是性能了):
很好!看起来没问题,没有遇到数据冲突。
“好代码”中的并发策略是把输入中的每一行交给单独的 Go 例程,以便利用多核。这是合理的直觉,因为 Go 例程以轻量和廉价著称。那么并发能带来多少好处呢?让我们比较一下使用单一 Go 例程顺序执行的代码(仅需在调用行解析函数的时候,删掉关键字go)。
每次执行所需的微秒数(越小越好)
哎呀,实际上不用并行的代码速度更快。这意味着启动go例程的开销超过了同时使用多核所节省的时间。
现在我们放弃并发,转而使用顺序执行,那么下一步自然是不要使用通道来传递结果,以节省开销。我们用一个裸分片来代替。
每次执行所需的微秒数(越小越好)
仅仅通过简化代码,删除并发,现在“好代码”版本将速度提高了40%。
使用单个go例程的时候,一段时间内仅有1个CUP在工作
现在让我们看看Pprof图形都调用了哪个函数。
找到瓶颈
我们目前的版本的状况是:86%的时间真正用在了解析消息上,这非常好。我们立刻注意到43%的时间用在了匹配正则表达式上:调用(*Regexp).FindAll。
虽然从原始文本中抽取数据时,正则表达式非常方便,而且很灵活,但是它们也有弊端,例如需要耗费内存和运行时间。正则表达式很强大,但是在很多情况下是杀鸡用牛刀。
在我们的程序中,文本模式为:
主要是为了识别以“-”开头的“命令”,而且一行可能有多个命令。我们可以用bytes.Split做一些略微的调整。让我们用Split替换代码中的正则表达式:
每次执行所需的微秒数(越小越好)
哇,这一改速度又提高了40%!
现在 CPU 的图如下所示:
没有正则表达式的巨大开销了。5个不同的函数中的内存分配占用了40%的时间,还说得过去。很有意思的是现在21%的时间被bytes.Trim占据了。
这个函数调用让我很感兴趣:我们可以改善它吗?
bytes.Trim需要一个“cutset string”作为参数(用于分隔符),但我们的分隔符只是一个空格而已。这就是个可以引入一些复杂性来提高性能的例子:实现自己定义的“trim”函数来代替标准库。自定义的“trim”仅处理单个分隔符字节。
每次执行所需的微秒数(越小越好)
哈哈,又快了20%。目前的版本的速度是最初“差代码”的4倍,虽然我们只用到了机器的一个CPU内核。相当可观!
2
早些时候,我们在处理每行输入的级别放弃了并发,但是我们仍然可以在更粗的力度上使用并发提高性能。 例如,如果每个文件在各自的go例程中进行处理,那么在我的工作站上处理6千个文件(6千个消息)的速度要比串行更快:
每次执行所需的微秒数(越小越好,紫色代表并发)
速度提高了66%(也就是提到了3倍),看起来不错,但是想到它使用了我所有12个CPU内核,那么这个结果“也没有那么好”。这可能意味着,使用新的优化代码,处理单个文件仍然是一项“小任务”,go例程和同步的开销不可忽略。
有趣的是,如果将消息数量从6千增加到12万,对于串行版本的性能没有影响,而且还会降低“每个消息1个例程”版本的性能。这是因为启动大量go例程是可能的,有时也很有用,但它确实给go的运行时间调度带来了一些压力。
我们可以通过仅创建几个工作进程(例如12个持续运行的go例程)来进一步缩短执行时间(虽然达不到12倍,但还是会加快速度),每个go例程处理消息的一个子集:
每次执行所需的微秒数(越小越好,紫色代表并发)
与串行版本相比,针对大量消息进行改进后的并发减少了79%的执行时间。 请注意,只有在确实需要处理大量文件时,此策略才有意义。
最佳地利用所有CPU内核的代码由几个go例程组成,每个go例程负责处理一定量的数据,在处理完成之前不进行任何通信和同步。
一种常见的启发式方法就是选择与可用CPU核心数量相等的进程(go例程),但它并不总是最佳选择,因为每个任务的情况都不一样。 例如,如果任务是从文件系统读取数据或发出网络请求,那么从性能的角度来看,go例程多于CPU核心数量是完全正确的。
每次执行所需的微秒数(越小越好,紫色代表并发)
现在,解析代码的效率很难再通过局部改进来提高了。执行时间中的主要部分是小对象的分配和垃圾回收(例如消息结构),这是合理的,因为我们知道内存管理操作相对较慢。 对分配策略的进一步优化......权当是留给高手们的一个练习吧。
3
使用完全不同的算法也会可以大幅提高速度。
这时,我从 Rob Pike 的《Lexical Scanning in Go》演讲中获得了灵感。构建自定义语法分析其和自定义解析器。 这只是一个原型(我没有实现所有的极端情况),它不如原始算法直观,并且正确实现错误处理可能会很棘手。 但是,它的速度比前一个版本提高了30%。
每次执行所需的微秒数(越小越好,紫色代表并发)
好了,与最初的代码相比,速度提高了 23 倍。
4
领取专属 10元无门槛券
私享最新 技术干货