在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
1.在高并发队列任务分发的场景中Ring Buffer比Channel的性能更好
2.defer功能不是静态编译时提供的,而是运行时提供的,所以使用defer会产生一些额外的性能开销(了解就好,该用还是得用)
3.encoding/json序列化是通过反射机制实现的,性能很差,可以使用ffjson生成encode/decode代码来提升性能。如果可能,使用MsgPack替代JSON,因为MsgPack性能更好。
4.在栈中创建对象比在堆中创建对象性能高,所以少用new来创建对象。需要创建大量临时对象的场景可以使用sync.Pool已减少GC的压力。
5.在性能要求特别高的并发访问同一个对象的场景中,可以通过增加padding的方式避免false sharing,提升CPU cache的命中率,从而提升性能。
6.考虑使用无锁的数据结构减少锁对并发性能造成的影响
7.只对关键节点进行优化,权衡优化和开发成本
以下为正文内容:
Go高性能编程技巧
到现在为止,我已经忘记了我在写什么,但我确定这篇文章是关于Go语言的。这主要是一篇,关于运行速度,而不是响应速度的文章——这两种速度是有区别的。我曾经和很多聪明的人一起工作。我们很多人都对性能问题很痴迷,我们之前所做的是尝试逼近能够预期的(性能)的极限。应用引擎有一些非常严格的性能要求,所以我们才会做出改变。自从使用了Go语言之后,我们已经学习到了很多提升性能以及让Go在系统编程中正常运转的方法。
Go的简单性和并发模型使其成为开发后端系统有吸引力的选择,但更大的问题是它如何应对延迟敏感的应用场景?是否值得牺牲语言的简洁性使其速度更快?让我们来一起看一下Go语言性能优化的几个方面:语言特性、内存管理、并发,并根据这些做出合适的优化决策。所有这里介绍的测试代码都可以在GitHub上找到(https://github.com/tylertreat/go-benchmarks)。 ChannelsChannel在Go语言中受到了很多的关注,因为它是一个方便的并发工具,但是了解它对性能的影响也很重要。在大多数场景下它的性能已经“足够好”了,但是在某些延时敏感的场景中,它可能会成为瓶颈。Channel并不是什么黑魔法。在Channel的底层实现中,使用的还是锁。在没有锁竞争的单线程应用中,它能工作的很好,但是在多线程场景下,性能会急剧下降。我们可以很容易的使用无锁队列ring buffer来替代channel的功能。
第一个性能测试对比了单线程buffer channel和ring buffer(一个生产者和一个消费者)。先看看单核心的情况(GOMAXPROCS = 1)
正如你所看到的,ring buffer大约能快6倍(如果你不熟悉Go的性能测试工具,中间的数字表示执行次数,最后一个数组表示每次执行花费的时间)。接下来,我们再看下调整GOMAXPROCS = 8的情况。
ring buffer快了近三倍。 Channel通常用于给worker分配任务。在下面的测试中,我们对比一下多个reader读取同一个channel或者ring buffer的情况。设置GOMAXPROCS = 1 测试结果表明channel在单核程应用中性能表现尤其的好。
然而,ring buffer在多核心的情况下速度更快些:
最后,我们来看看多个reader和多个writer的场景。从下面的对比同样能够看到ring buffer在多核心时更好些。
ring buffer只使用CAS操作达到线程安全。我们可以看到,在决定选择channel还是ring buffer时很大程度上取决于系统的核数。对于大多数系统, GOMAXPROCS> 1,所以无锁的ring buffer往往是一个更好的选择。Channel在多核心系统中则是一个比较糟糕的选择。 deferdefer是提高可读性和避免资源未释放的非常有用的关键字。例如,当我们打开一个文件进行读取时,我们需要在结束读取时关闭它。如果没有defer关键字,我们必须确保在函数的每个返回点之前关闭文件。
这样很容易出错,因为很容易在任何一个return语句前忘记关闭文件。defer则通过一行代码解决了这个问题。
乍一看,人们会认为defer明可能会被编译器完全优化掉。如果我只是在函数的开头使用了defer语句,编译器确实可以通过在每一个return语句之前插入defer内容来实现。但是实际情况往往更复杂。比如,我们可以在条件语句或者循环中添加defer。第一种情况可能需要编译器找到应用defer语句的条件分支. 编译器还需要检查panic的情况,因为这也是函数退出执行的一种情况。通过静态编译提供这个功能(defer)似乎(至少从表面上看)是不太可能的。 derfer并不是一个零成本的关键字,我们可以通过性能测试来看一下。在下面的测试中,我们对比了一个互斥锁在循环体中加锁后,直接解锁以及使用defer语句解锁的情况。
使用defer几乎慢了5倍。平心而论,77ns也许并不那么重要,但是在一个循环中它确实对性能产生了影响。通常要由开发者在性能和代码的易读性上做权衡。优化从来都是需要成本的。 Refection与JSONReflection通常是缓慢的,应当避免在延迟敏感的服务中使用。JSON是一种常用的数据交换格式,但Go的encoding/json库依赖于反射来对json进行序列化和反序列化。使用ffjson,我们可以通过使用代码生成的方式来避免反射的使用,下面是性能对比。
(ffjson)生成的JSON序列化和反序列化比基于反射的标准库速度快38%左右。当然,如果我们对编解码的性能要求真的很高,我们应该避免使用JSON。。MessagePack 是序列化代码一个更好的选择。在这次测试中我们使用msgp库跟JSON的做了对比。
这里的差异是显着的。即使是跟(ffjson)生成的代码相比,MessagePack仍然快很多。 如果我们真的很在意微小的优化,我们还应该避免使用interface类型, 它需要在序列化和反序列化时做一些额外的处理。在一些动态调用的场景中,运行时调用也会增加一些额外 开销。编译器无法将这些调用替换为内联调用。
我们再看看调用查找,即把一个interface变量转换为它真实的类型。这个测试调用了同一个struct的同一个方法。区别在于第二个变量是一个指向结构体的一个指针。
排序是一个更加实际的例子,很好的显示了性能差异。在这个测试中,我们比较排序1,000,000个结构体和1,000,000个指向相同结构体的interface。对结构体进行排序比对interface进行排序快92%。
总之,如果可能的话避免使用JSON。如果确实需要用JSON,生成序列化和反序列化代码。一般来说,最好避免依靠反射和interface,而是编写使用的具体类型。不幸的是,这往往导致很多重复的代码,所以最好以抽象的这个代码生成。再次,权衡得失。 内存管理Go实际上不暴露堆或直接堆栈分配给用户。事实上,“heap”和“stack”这两个词没有出现在Go语言规范的任何地方。这意味着有关栈和堆东西只在技术上实现相关。实际上,每个goroutine确实有着自己堆和栈。编译器确实难逃分析,以确定对象是在栈上还是在堆中分配。 不出所料的,避免堆分配可以成为优化的主要方向。通过在栈中分配空间(即多使用A{}的方式创建对象,而不是使用new(A)的方式),我们避免了昂贵的malloc调用,如下面所示的测试。
自然,通过指针传值比通过对象传世要快,因为前者需要复制唯一的一个指针,而后者需要复制整个对象。下面测试结果中的差异几乎是可以忽略的,因为这个差异很大程度上取决于被拷贝的对象的类型。注意,可能有一些编译器对对这个测试进行一些编译优化。
然而,heap空间分配的最大的问题在于GC(垃圾回收)。如果我们生成了很多生命周期很短的对象,我们会触发GC工作。在这种场景中对象池就派上用场了。在下面的测试用,我们比较了使用堆分配与使用 sync.Pool 的情况。对象池提升了5倍的性能。
需要指出的是,Go的sysc.Pool在垃圾回收过程中也会被回收。使用sync.Pool的作用是复用垃圾回收操作之间的内存。我们也可以维护自己的空闲对象列表使对象不被回收,但这样可能就让垃圾回收失去了应有的作用。Go的pprof工具在分析内存使用情况时非常有用的。在盲目做内存优化之前一定要使用它来进行分析。 False Sharing当性能真的很重要时,你必须开始硬件层次的思考。著名的一级方程式车手杰基·斯图尔特曾经说过,“要成为一个赛车手你不必成为一名工程师,但你必须有机械知识。”深刻理解一辆汽车的内部工作原理可以让你成为一个更好的驾驶员。同样,理解计算机如何工作可以使你成为一个更好的程序员。例如,内存如何布局的?CPU缓存如何工作的?硬盘如何工作的? 内存带宽仍然是现代CPU的有限资源,因此缓存就显得极为重要,以防止性能瓶颈。现在的多核处理器把数据缓存在cache line中,大小通常为64个字节,以减少开销较大的主存访问。为了保证cache的一致性,对内存的一个小小的写入都会让cache line被淘汰。对相邻地址的读操作就无法命中对应的cache line。这种现象叫做false sharing. 当多个线程访问同一个cache line中的不同数据时这个问题就会变得很明显。 想象一下,Go语言中的一个struct是如何在内存中存储的,我们用之前的ring buffer 作为一个示例,结构体可能是下面这样:
queue和dequeue字段分别用于确定生产者和消费者的位置。这些字段的大小都是8byte,同时被多个线程并发访问和修改来实现队列的插入和删除操作,因为这些字段在内存中是连续存放的,它们仅仅使用了16byte的内存,它们很可能被存放在同一个cache line中。因此修改其中的任何一个字段都会导致其它字段缓存被淘汰,也就意味着接下来的读取操作将会变慢。也就是说,在ring buffer中添加和删除元素会导致很多的CPU缓存失效。 我们可以给结构体的字段直接增加padding.每一个padding都跟一个CPU cache line一样大,这样就能确保ring buffer的字段被缓存在不同的cache line中。下面是修改后的结构体:
实际运行时会有多少区别呢?跟其他的优化一样,优化效果取决于实际场景。它跟CPU的核数、资源竞争的数量、内存的布局有关。虽然有很多的因素要考虑,我们还是要用数据来说话。我们可以用添加过padding和没有padding的ring buffer来做一个对比。 首先,我们测试一个生产者和一个消费者的情况,它们分别运行在一个gorouting中.在这个测试中,两者的差别非常小,只有不到15%的性能提升:
但是,当我们有多个生产者和多个消费者时,比如各100个,区别就会得更加明显。在这种情况下,填充的版本快了约36%。
False sharing是一个非常现实的问题。根据并发和内存争的情况,添加Padding以减轻其影响。这些数字可能看起来微不足道的,但它已经起到优化作用了,特别是在考虑到在时钟周期的情况下。 无锁无锁的数据结构对充分利用多核心是非常重要的。考虑到Go致力于高并发的使用场景,它不鼓励使用锁。它鼓励更多的使用channel而不是互斥锁。 这就是说,标准库确实提供了常用的内存级别的原子操作, 如atomic包。它提供了原子比较并交换,原子指针访问。然而,使用原子包在很大程度上是不被鼓励的:
实现无锁有多困难?是不是只要用一些CAS实现就可以了?在了解了足够多的知识后,我认识到这绝对是一把双刃剑。无锁的代码实现起来可能会非常复杂。非线程安全的包并不易用。而且,编写线程安全的无锁代码非常有技巧性并且很容易出错。像ring buffer这样简单的无锁的数据结构维护起来还相对简单,但是其它场景下就很容易出问题了。 Ctrie是我写的一篇无锁的数据结构实现的介绍,尽管理论上很容易理解,但事实上实现起来很复杂。在高并发环境中调试无锁的代码是一件非常痛苦的事情。如果一开始没有写对,你将花费大量的时间去修正错误。 但是显而易见,编写复杂的无锁算法是有意义的,否则为什么还会有人这么做呢?Ctrie跟同步map或者跳跃表比起来,插入操作更耗时一些,因为寻址操作变多了。Ctrie真正的优势是内存消耗,跟大多的Hash表不同,它总是一系列在tree中的keys。另一个性能优势就是它可以在常量时间内完成线性快照。我们对比了在100个并发的条件下对synchronized map 和Ctrie进行快照:
在特定的访问模式下,无锁数据结构可以在多线程系统中提供更好的性能。例如,NATS消息队列使用基于synchronized map的数据结构来完成订阅匹配。如果使用无锁的Ctrie,吞吐量会提升很多。下图中的耗时中,蓝线表示使用基于锁的数据结构的实现,红线表示无锁的数据结构的实现 在特定的场景中避免使用锁可以带来很好的性能提升。从ring buffer和channel的对比中就可以看出无锁结构的明显优势。然而,我们需要在编码的复杂程度以及获得的好处之间进行权衡。事实上,有时候无锁结构并不能提供任何实质的好处。 优化的注意事项正如我们从上面的讨论所看到的,性能优化总是有成本的。认识和理解优化方法仅仅是第一步。更重要的是理解应该在何时何处取使用它们。引用 C. A. R. Hoare的一句名言,它已经成为了适用所有编程人员的经典格言:
但这句话的观点不是反对优化,而是让我们学会在速度之间进行权衡——算法的速度、响应速度、维护速度以及系统速度。这是一个很主观的话题,而且没有一个简单的标准。过早进行优化是错误的根源吗?我是不是应该先实现功能,然后再优化?或者是不是根本就不需要优化?没有标准答案。有时候先实现功能再提升速度也是可以的。 不过,我的建议是只对关键的路径进行优化。你在关键路径上走的越远,你优化的回报就会越低以至于近乎是在浪费时间。能够对性能是否达标做出正确的判断是很重要的。不要在此之外浪费时间。要用数据驱动——用经验说话,而不是出于一时兴起。还有就是要注重实际。给一段时间不是很敏感的代码优化掉几十纳秒是没有意义的。比起这个还有更多需要优化的地方。 总结如果你已经读到了这里,恭喜你,但你可能还有一些问题搞错了。我们已经了解到在软件中我们实际上有两种速度——响应速度和执行速度。用户想要的是第一种,而开发者追求的是第二种,CTO则两者都要。目前第一种速度是最重要的,只要你想让用户用你的产品。第二种速度则是需要你排期和迭代来实现的。它们经常彼此冲突。 也许更耐人寻味的是,我们讨论了一些可以让Go提升一些性能并让它在低延时系统中更可用的方法。Go语言是为简洁而生的,但是这种简洁有时候是有代价的。就跟前面两种速度的权衡一样,在代码的可维护性以及代码性能上也需要权衡。速度往往意味着牺牲代码的简洁性、更多的开发时间和后期维护成本。要明智的做出选择。
最后附上原文地址:So You Wanna Go Fast? |
请发表评论