软件教练说:性能优化与性能设计,“相亲相爱”的一对
摘要:性能优化通常是在现有系统和代码基础上做改进,考验的是开发者反向修复的能力,而性能设计考验的是设计者的正向设计能力,但性能优化的方法可以指导性能设计,两者互补。
性能优化是指在不影响正确性的前提下,使程序运行得更快,它是一个非常广泛的话题。
优化有时候是为了降低成本,但有时候,性能能决定一个产品的成败,比如游戏服务器的团战玩法需要单服达到一定的同时在线人数才能支撑起这类玩法,而电信软件的性能往往是竞标的核心竞争力,性能关乎商业成败。
软件产品多种多样,影响程序执行效率的因素很多,因此,性能优化,特别是对不熟悉的项目做优化,不是一件容易的事。
性能优化可分为宏观和微观两个层面。宏观层面包括架构重构,而微观层面,则包括算法的优化,编译优化,工具分析,高性能编码等,这些方法是有可能独立于具体业务逻辑,因而有更加广泛的适应性,且更易于实施。
具体到性能优化的方法论,首先,应建立度量,你度量什么,你得到什么。所以,性能优化测试先行,须基于数据而不能凭空猜测,这是做优化的一个基本原则。搭建真实的压测环境,或者逼近真实环境,有时候是困难的,也可能非常耗费时间,但它依然是值得的。
有许多工具能帮助我们定位程序瓶颈,有些工具能做很友好的图形化展示,定位问题是解决问题的前置条件,但定位问题可能不是最难的,分析和优化才是最耗时的关键环节,修改之后,要再回归测试,验证是否如预期般有效。
什么是高性能程序?架构致广远、实现尽精微。
架构优化的关键是识别瓶颈,这类优化有很多套路:比如通过负载均衡做分布式改造,比如用多线程协程做并行化改造,比如用消息队列做异步化和解耦,比如用事件通知替代轮询,比如为数据访问增加缓存,比如用批处理+预取提升吞吐,比如IO与逻辑分离、读写分离等等。
架构调整和优化虽然收效很大,却因受限于各种现实因素,因而并不总是可行。
能不做的尽量不做、必须做的高效做是性能优化的一个根本法则,提升处理能力和降低计算量可视为性能优化的两个方向。
有时候,我们不得不从细节的维度去改进程序。通常,我们应该使用简单的数据结构和算法,但如有必要,就应积极使用更高效的结构和算法,不止逻辑结构,实现结构(存储)同样影响执行效率;分支预测、反馈优化、启发性以及基于机器学习编译优化的效果日益凸显;熟练掌握编程语言深刻理解标准库实现能帮助我们规避低性能陷阱;深入细节做代码微调甚至指令级优化有时候也能取得意想不到的效果。
有时候,我们需要做一些交换,比如用空间置换时间,比如牺牲一些通用性可读性换取高性能,我们只应当在非常必要的情况下才这么做,它是权衡的艺术。
1、架构优化
通常系统的throughput越大,latency就越高,但过高的latency不可接受,所以架构优化不是一味追求throughput,也需要关注latency,追求可接受latency下的高throughput。
负载均衡
负载均衡其实就是解决一个分活的问题,对应到分布式系统,一般在逻辑服的前面都会安放一个负载均衡器,比如NGINX就是经典的解决方案。负载均衡不限于分布式系统,对于多线程架构的服务器内部,也需要解决负载均衡的问题,让各个worker线程的负载均衡。
多线程、协程并行化
虽然硬件架构的复杂化对程序开发提出了更高的要求,但编写充分利用多CPU多核特性的程序能获得令人惊叹的收益,所以,在同样硬件规格下,基于多线程/协程的并行化改造依然值得尝试。
多线程不可避免要面临资源竞争的问题,我们的设计目标应该是充分利用硬件多执行核心的优势,减少等待,让多个执行流畅快的奔跑起来。
对于多线程模型,如果把每一个要干的活抽象为一个task,把干活的线程抽象为worker,那么,有两种典型的设计思路,一种是对task类型做出划分,让一类或者一个worker去干特定的task,另一种是让所有worker去干所有task。
第一种划分,能减少数据争用,编码实现也更简单,只需要识别有限的竞争,就能让系统工作的很好,缺点是任务的工作量很可能不同,有可能导致有些worker忙碌而另一些空闲。
第二种划分,优点是能均衡,缺点是编码复杂性高,数据竞争多。
有时候,我们会综合上述两种模式,比如让单独的线程去做IO(收发包)+反序列化(产生protocol task),然后启动一批worker线程去处理包,中间通过一个task queue去连接,这即是经典的生产者消费者模型。
协程是一种用户态的多执行流,它基于一个假设,即用户态的任务切换成本低于系统的线程切换。
通知替代轮询
轮询即不停询问,就像你每隔几分钟去一趟宿管那里查看是否有信件,而通知是你告诉宿管阿姨,你有信的时候,她打电话通知你,显然轮询耗费CPU,而通知机制效率更高。
添加缓存
缓存的理论依据是局部性原理。
一般系统的写入请求远少于读请求,针对写少读多的场景,很适合引入缓存集群。
在写数据库的时候同时写一份数据到缓存集群里,然后用缓存集群来承载大部分的读请求,因为缓存集群很容易做到高性能,所以,这样的话,通过缓存集群,就可以用更少的机器资源承载更高的并发。
缓存的命中率一般能做到很高,而且速度很快,处理能力也强(单机很容易做到几万并发),是理想的解决方案。
CDN本质上就是缓存,被用户大量访问的静态资源缓存在CDN中是目前的通用做法。
消息队列
消息队列、消息中间件是用来做写请求异步化,我们把数据写入MessageQueue就认为写入完成,由MQ去缓慢的写入DB,它能起到削峰填谷的效果。
消息队列也是解耦的手段,它主要用来解决写的压力。
IO与逻辑分离、读写分离
IO与逻辑分离,这个前面已经讲了。读写分离是一种数据库应对压力的惯用措施,当然,它也不仅限于DB。
批处理与数据预取
批处理是一种思想,分很多种应用,比如多网络包的批处理,是指把收到的包攒到一起,然后一起过一遍流程,这样,一个函数被多次调用,或者一段代码重复执行多遍,这样i-cache的局部性就很好,另外,如果这个函数或者一段里要访问的数据被多次访问,d-cache的局部性也能改善,自然能提升性能,批处理能增加吞吐,但通常会增大延迟。
另一个批处理思想的应用是日志落盘,比如一条日志大概写几十个字节,我们可以把它缓存起来,攒够了一次写到磁盘,这样性能会更好,但这也带来数据丢失的风险,不过通常我们可以通过shm的方式规避这个风险。
指令预取是CPU自动完成的,数据预取是一个很有技巧性的工作,数据预取的依据是预取的数据将在接下来的操作中用到,它符合空间局部性原理,数据预取可以填充流水线,降低访存等待,但数据预取会侵害代码,且并不总如预期般有效。
哪怕你不增加预取代码,硬件预取器也有可能帮你做预取,另外gcc也有编译选项,开启它会在编译阶段自动插入预取代码,手动增加预取代码需要小心处理,时机的选择很重要,最后一定要基于测试数据,另外,即使预取表现很好,但代码修改也有可能导致效果衰减,而且预取语句执行本身也有开销,只有预取的收益大于预取的开销,且CACHE-MISS很高才是值得的。
2、算法优化
数据量小的集合上遍历查找即可,但如果循环的次数过百,便需要考虑用更快的查找结构和算法替换蛮力遍历,哈希表,红黑树,二分查找很常用。
哈希(HASH)
哈希也叫散列,是把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值,也叫摘要。比如把一篇文章的内容通过散列生成64位的摘要,该过程不可逆。
这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值,但如果输出的位数足够,散列成相同输出的概率非常非常小。
字符串的比较有时会成为消耗较大的操作,虽然strcmp或者memcpy的实现用到了很多加速和优化技巧,但本质上它还是逐个比较的方式。
字符串比较的一个改进方案就是哈希,比较哈希值(通常是一个int64的整数)而非比较内容能快很多,但需要为字符串提前计算好哈希值,且需要额外的空间保存哈希值,另外,在哈希值相等的时候,还需要比较字符串,但因为冲突的概率极低,所以后续的字符串比较不会很多次。
这样不一定总是更高效,但它提供了另一个思路,你需要测试你的程序,再决定要不要这样做。
另一个哈希的用法是哈希表,哈希表的经典实现是提前开辟一些桶,通过哈希找到元素所在的桶(编号),如果冲突,再拉链解决冲突。
为了减少冲突经常需要开辟更多的桶,但更多的桶需要更大的存储空间,特别是元素数量不确定的时候,桶的数量选择变得两难,随着装载的元素变多,冲突加剧,在扩容的时候,将需要对已存在的元素重新哈希,这是很费的点。
哈希表的冲突极端情况下会退化成链表,当初设想的快速查找变得不再可行,HashMap是普通哈希表的改进版,结合哈希和二叉平衡搜索树。
另一个常用来做查找的结构是红黑树,它能确保最坏情况下,有logN的时间复杂度,但红黑树的查找过程需要沿着链走,不同结点内存通常不连续,CACHE命中性经常很差,红黑树的中序遍历结果是有序的,这是哈希表不具备的,另外,红黑树不存在哈希表那般预估容量难的问题。
基于有序数组的二分查找
二分查找的时间复杂度也是logN,跟红黑树一致,但二分查找的空间局部性更好,不过二分查找有约束,它只能在有序数组上进行,所以,如果你需要在固定的数据集合(比如配置数据)做查找,二分查找是个不错的选择。
跳表(Skip List)
跳表增加了向前指针,是一种多层结构的有序链表,插入一个值时有一定概率晋升到上层形成间接的索引。
跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。
跳表适合大量并发写的场景,可以认为是随机平衡的二叉搜索树,不存在红黑树的再平衡问题。Redis强大的ZSet底层数据结构就是哈希加跳表。
相比哈希表和红黑树,跳表用的不那么多。
数据结构的实现优化
我们通常只会讲数据的逻辑结构,但数据的实现(存储)结构也会影响性能。
数组在存储上一定是逻辑地址连续的,但链表不具有这样的特点,链表通过链域寻找临近节点,如果相邻节点在地址上发散,则沿着链域访问效率不高,所以实现上可以通过从单独的内存配置器分配结点(尽量内存收敛)来优化访问效率,同样的方法也适应红黑树、哈希表等其他结构。
排序
尽量对指针、索引、ID排序,而不要对对象本身排序,因为交换对象比交换地址/索引慢;求topN不要做全排序;非稳定排序能满足要求不要搞稳定排序。
延迟计算 & 写时拷贝
延迟计算和写时拷贝(COW)思想上是一样的,即可以通过把计算尽量推迟来减少计算开销。
我拿游戏服务器开发来举例,假设玩家的战斗力(fight)是通过等级,血量,称号等其他属性计算出来的,我们可以在等级、血量、称号变化的时候立即重算fight,但血量可能变化比较频繁,所以就会需要频繁重算战力。通过延迟计算,我们可以为战力添加一个dirtyFlag,在等级、血量、称号变化的时候设置dirtyFlag,等到真正需要用到战力的时候(GetFight函数)里判断dirtyFlag,如果dirtyFlag为true则重算战力并清dirtyFlag,如果dirtyFlag为false则直接返回fight值。
写时拷贝(COW)跟这个差不多,linux kernel在fork进程的时候,子进程会共享父进程的地址空间,只有在子进程对自身地址空间写的时候,才会clone一份出来,同样,string的设计也用到了类似的思想。
预计算
有些值可以提前计算出结果并保存起来,不用重复计算的尽量不重复计算,特别是循环内的计算,要避免重复的常量计算,C++甚至增加了一个constexpr的关键词。
增量更新
增量更新的原理不复杂,只做增量,只做DIFF,不做全量,这个思想有很多应用场景。
举个例子,游戏服务器每隔一段时间需要把玩家的属性(比如血量、魔法值等)同步到客户端,简单的做法是把所有属性打包一次性全发送过去,这样比较耗费带宽,可以考虑为每个属性编号,在发送的时候,只发送变化的属性。
在发送端,编码一个变化的属性的时候,需要发送一个属性编号+属性值的对子,接收端类似,先解出属性编号,再解出属性值,这种方式可能需要牺牲一点CPU换带宽。
3、代码优化
内存优化
(a)小对象分配器
C的动态内存分配是介于系统和应用程序的中间层,malloc/free本身体现的就是一种按需分配+复用的思想。
当你调用malloc向glibc的动态内存分配器ptmalloc申请6字节的内存,实际耗费的会大于6字节,6是动态分配块的有效载荷,动态内存分配器会为chunk添加首部和尾部,有时候还会加一下填充,所以,真正耗费的存储空间会远大于6字节,在我的机器上,通过malloc_usable_size发现申请6字节,返回的chunk,实际可用的size为24,加上首尾部就更多了。
但你真正申请(可用)的大小是6字节,可见,动态内存分配的chunk内有大量的碎片,这就是内碎片,而外碎片是存在chunk之间的,是另一个问题。
当你申请的size较大,有效载荷 / 耗费空间的比例会比较高,内碎片占比不高,但但size较小,这个占比就高,如果这种小size的chunk非常多,就会造成内存的极大浪费。
《C++设计新思维》一书中的loki库实现了一个小对象分配器,通过隐式链表的方式解决了这个问题,有兴趣的可以去看看。
(b)cached obj
《C++ Primer》实现了一个CachedObj类模板,任何需要拥有这种cached能力的类型都可以通过从CachedObj<T>派生而获得。
它的主要思想是为该种类型维护一个FreeList,每个节点就是一个Object,对象申请的时候,检查FreeList,如果FreeList不为空,则摘除头结点返回,如果FreeList为空,则new一批Object并串到FreeList,然后走FreeList不为空的分配流程,通过重载类的operator new和operator delete,达到对类的使用者透明的目的。
(c)内存分配和对象构建分离
c的malloc用来动态分配内存,free用来归还内存;C++的new做了3件事,通过operator new(本质上等同malloc)分配内存,在分配的内存上构建对象,返回对象指针;而delete干了两件事,调用析构函数,归还内存。
C++通过placement new可以分离内存分配和对象构建,结合显示的析构函数调用,达到自控的目的。
我优化过一个游戏项目,启动时间过长,记忆中需要几十秒(至少十几秒),分析后发现主要是因为游戏执行预分配策略(对象池),在启动的时候按最大容量创建怪和玩家,对象构建很重,大量对象构建耗时过长,通过分离内存分配和对象构建,把对象构建推迟到真正需要的时候,实现了服务的重启秒起。
(d)内存复用
编解码、加解密、序列化反序列化(marshal/unmarshal)的时候一般都需要动态申请内存,这种调用频次很高,可以考虑用静态内存,为了避免多线程竞争,可以用thread local。
当然你也可以改进静态内存策略,比如封装一个GetEncodeMemeory(size_t)函数,维护一个void* + size_t结构体对象(初始化为NULL+0),对比参数size跟对象的size成员,如果参数size<=对象size,直接返回对象大的void*指针,否则free掉void*指针,再按参数size分配一个更大的void*,并用参数size更新对象size。
cache优化
i-cache优化:i-cache的优化可以通过精简code path,简化调用关系,减少代码量,减少复杂度来实现。
具体措施包括,减少函数调用(就地展开、inline),利用分支预测,减少函数指针,可以考虑把code path上相关的函数定义在一起,把相关的函数定义到一个源文件,并让它们在源文件上临近,这样生成的object文件,在运行时加载后相关函数大概率也内存临近,当然编译器也一直在做这方面的努力,但我们写代码不应该依赖编译器优化,尽量去帮助编译器生成更高效的代码。
d-cache优化:d-cache优化包括改进数据结构和算法获取更好的数据访问时空局部性,比如二分查找就是d-cache友好算法。一个cache line一般是64B,如果数据跨越两个cache-line,则会导致load & store2次,所以,需要结合cache对齐,尽量让相关访问的数据在一个cache-line。
如果结构体过大,则各成员不仅可能在不同cache-line,甚至可能在不同page,所以应该避免结构体过大。
如果结构体的成员变量过多,一般而言对各成员的访问频次也会满足2-8定律,可以考虑把hot和cold的成员分开,重排结构体成员变量顺序,但这些骚操作我不建议在开始的时候用,因为说不定哪天又要增删成员,从而破坏苦心孤诣搭建的积木。
判断前置
判断前置指在函数中讲判断返回的语句前置,这样不至于忙活半天,你跟我说对不起不合适,玩儿呢?
在写多个判断的时候,把不满足可能性高的放在前面。
在写条件或的时候把为true的放在前面,在写条件与的时候把为false的放在前面。
另外,如果在循环里调用一个函数,而这个函数里检查某条件,不符合就返回,这种情况,可以考虑把检查放到调用函数的外面,这样不满足的话就不用陷入函数,当然,你也可以说,这样的操作违背软件工程,但看你想要什么,你不总是能够两全其美,对吧?
凑零为整与化整为零
凑零为整其实的思想在日志批处理里提了,不再展开。
化整为零体现了分而治之的思想,可以把一个大的操作,分摊开来,避免在做大操作的时候导致卡顿,从而让CPU占比更加平稳。
分频
之前我优化过一个游戏服务器,游戏服务器的逻辑线程是一个大循环,里面调用tick函数,tick函数里调用了所有需要check timer & do的事情,然后所有需要check timer & do的事情都塞进tick里。
改进:tick里调用了tick50ms、tick100ms、tick500ms,tick1000ms,tick5000ms,然后把需要check timer & do的逻辑根据精度要求塞到不同的tickXXms里去。
减法
- 减少冗余
- 减少拷贝、零拷贝
- 减少参数个数(寄存器参数、取决于ABI约定)
- 减少函数调用次数/层次
- 减少存储引用次数
- 减少无效初始化和重复赋值
循环优化
这方面的知识很多,感觉一下子讲不完,提几点,循环套循环要内大外小,尽量把逻辑提取到循环外。
- 提取与循环无关的表达式,尽量减少循环内不必要计算。
- 循环内尽量使用局部变量。
- 循环展开是一种程序变换,通过增加每次迭代计算的元素的数量,减少循环的迭代次数。
- 还有循环分块的骚操作。
防御性编程适可而止
有两个流派,一个是完全的不信任,即所有函数调用里都对参数判断,包括判空,有效性检查等,但这样做有几点不好:
- 第一,它只是貌似更安全,并不是真的更安全。
- 第二,它稀释代码浓度,淹没关键语句。
- 第三,如果通过返回值报告错误,则加重了调用者负担,调用者需要添加额外代码检查,不然更奇怪。
- 第四,重复判断空耗CPU。
- 第五,埋雷,把本该crash或者暴露的问题埋得更深。
但这种做法大行其道,它有一定的市场和道理。
另一个是界定边界,区分公开接口和内部实现,检查只在模块之间进行,就相当于进园区的时候,门卫会检查你证件,但之后,则不再检查。因为内部实现是受控的安全上下文,开发者应该完全cover住。
我主张防御性编程适可而止,一些著名的开源项目也不会做过多防御,比如linux kernel、NGINX、skynet等,但现实中,软件开发通常多人合作,每个开发者素质不一样,这就是客观现实,所以我也理解前一种做法。
release干净
开发过程中,我们会加很多诊断信息,比如我们可能接管内存分配,从而附加额外的首尾部,通过填写magic Num捕获异常或者内存越界,但这些信息应该只用于开发阶段的DEBUG需要,在release阶段应该通过预处理的方式删除掉。
日志分级其实也体现了这种思想,通常有两种做法,一个是定义级别变量,另一个是预处理,预处理干净,但需要重新编译生成image,而变量更灵活,但变量的比较还是有开销的。
不要忽视这些诊断调试信息的开销,牢记不必做的事情绝不做的原则。
慎用递归
递归的写法简单,理解起来也容易,但递归是函数调用,有栈帧建立撤销控制跳转的开销,另外也有爆栈的风险,在性能敏感关键路径,优先考虑用非递归版本。
4、编译优化与优化选项
- inline
- restrict
- LTO
- PGO
- 优化选项
5、其他优化
-
绑核
-
SIMD
- 锁与并发
- 锁的粒度
- 无锁编程
- Per-cpu data structure & thread local
- 内存屏障
- 异构优化/TCO优化
比如用GPGPU、FPGA、SmartNIC来offload原来cpu的任务,TCO优化指的是不以性能优化为单一指标,而是在满足性能条件下以综合成本为优化直播,当然异构也包括主动利用CPU的avx或者其他逻辑单元,这类优化往往编译器不能自动展开(@zrg)
常识和数据
CPU拷贝数据一般一秒钟能做到几百兆,当然每次拷贝的数据长度不同,吞吐不同。
一次函数执行如果耗费超过1000 cycles就比较大了(刨除调用子函数的开销)。
pthread_mutex_t是futex实现,不用每次都进入内核,首次加解锁大概耗时4000-5000 cycles左右,之后,每次加解锁大概120 cycles,O2优化的时候100 cycles,spinlock耗时略少。
lock内存总线+xchg需要50 cycles,一次内存屏障要50 cycles。
有一些无锁的技术,比如CAS,比如linux kernel里的kfifo,主要利用了整型回绕+内存屏障。
几个如何?
1. 如何定位CPU瓶颈?
CPU是通常大家最先关注的性能指标,宏观维度有核的CPU使用率,微观有函数的CPU cycle数,根据性能的模型,性能规格与CPU使用率是互相关联的,规格越高,CPU使用率越高,但是处理器的性能往往又受到内存带宽、Cache、发热等因素的影响,所以CPU使用率和规格参数之间并不是简单的线性关系,所以性能规格翻倍并不能简单地翻译成我们的CPU使用率要优化一倍。
至于CPU瓶颈的定位工具,最有名也是最有用的工具就是perf,它是性能分析的第一步,可以帮我们找到系统的热点函数。就像人看病一样,只知道症状是不够的,需要通过医疗机器进一步分析病因,才能对症下药。
所以我们通过性能分析工具PMU或者其他工具去进一步分析CPU热点的原因比如是指令数本身就比较多,还是Cache miss导致的等,这样在做性能优化的时候不会走偏。
优化CPU的目标就是让CPU运行不受阻碍。
2. 如何定位IO瓶颈?
系统IO的瓶颈可以通过CPU和负载的非线性关系体现出来。当负载增大时,系统吞吐量不能有效增大,CPU不能线性增长,其中一种可能是IO出现阻塞。
系统的队列长度特别是发送、写磁盘线程的队列长度也是IO瓶颈的一个间接指标。
对于网络系统来讲,我建议先从外部观察系统。所谓外部观察是指通过观察外部的网络报文交换,可以用tcpdump, wireshark等工具,抓包看一下。
比如我们优化一个RPC项目,它的吞吐量是10TPS,客户希望是100TPS。我们使用wireshark抓取TCP报文流,可以分析报文之间的时间戳,响应延迟等指标来判断是否是由网络引起来的。
然后可以通过netstat -i/-s选项查看网络错误、重传等统计信息。还可以通过iostat查看cpu等待IO的比例。IO的概念也可以扩展到进程间通信。
对于磁盘类的应用程序,我们最希望看到写磁盘有没有时延、频率如何。其中一个方法就是通过内核ftrace、perf-event事件来动态观测系统。比如记录写块设备的起始和返回时间,这样我们就可以知道磁盘写是否有延时,也可以统计写磁盘时间耗费分布。有一个开源的工具包perf-tools里面包含着iolatency, iosnoop等工具。
3. 如何定位IO瓶颈?
应用程序常用的IO有两种:Disk IO和网络IO。判断系统是否存在IO瓶颈可以通过观测系统或进程的CPU的IO等待比例来进行,比如使用mpstat、top命令。
系统的队列长度特别是发送、写磁盘线程的队列长度也是IO瓶颈的一个重要指标。
对于网络 IO来讲,我们可以先使用netstat -i/-s查看网络错误、重传等统计信息,然后使用sar -n DEV 1和sar -n TCP,ETCP 1查看网路实时的统计信息。ss (Socket Statistics)工具可以提供每个socket相关的队列、缓存等详细信息。
更直接的方法可以用tcpdump, wireshark等工具,抓包看一下。
对于Disk IO,我们可以通过iostat -x -p xxx来查看具体设备使用率和读写平均等待时间。如果使用率接近100%,或者等待时间过长,都说明Disk IO出现饱和。
一个更细致的观察方法就是通过内核ftrace、perf-event来动态观测Linux内核。比如记录写块设备的起始和返回时间,这样我们就可以知道磁盘写是否有延时,也可以统计写磁盘时间耗费分布。有一个开源的工具包perf-tools里面包含着iolatency, iosnoop等工具。
4.如何定位锁的问题?
大家都知道锁会引入额外开销,但锁的开销到底有多大,估计很多人没有实测过,我可以给一个数据,一般单次加解锁100 cycles,spinlock或者cas更快一点。
使用锁的时候,要注意锁的粒度,但锁的粒度也不是越小越好,太大会增加撞锁的概率,太小会导致代码更难写。
多线程场景下,如果cpu利用率上不去,而系统吞吐也上不去,那就有可能是锁导致的性能下降,这个时候,可以观察程序的sys cpu和usr cpu,这个时候通过perf如果发现lock的开销大,那就没错了。
如果程序卡住了,可以用pstack把堆栈打出来,定位死锁的问题。
5. 如何提⾼Cache利用率?
内存/Cache问题是我们常见的负载瓶颈问题,通常可利用perf等一些通用工具来辅助分析,优化cache的思想可以从两方面来着手,一个是增加局部数据/代码的连续性,提升cacheline的利用率,减少cache miss,另一个是通过prefetch,降低miss带来的开销。
通过对数据/代码根据冷热进行重排分区,可提升cacheline的有效利用率,当然触发false-sharing另当别论,这个需要根据运行trace进行深入调整了;说到prefetch,用过的人往往都有一种体会,现实效果比预期差的比较远,确实无论是数据prefetch还是代码prefetch,不确定性太大,指望编译器更靠谱点。
小结
性能优化是一项细致的工作,性能优化也是一个系统性工程。性能优化通常是在现有系统和代码基础上做改进,它并非推倒重来,考验的是开发者反向修复的能力,而性能设计考验的是设计者的正向设计能力,但性能优化的方法可以指导性能设计,两者互补。