做任何事情要形成自己的方法体系,这样在做事情的时候才能游刃有余。前面文章我们简单介绍了一个简单的例子,说明了代码开发中应该如何保证程序的性能。今天我们将更加深入的介绍如何在代码层面提升程序的性能。并且我们总结为几种情况,这样在以后开发中就可以套用。另外,本节我们主要介绍的是代码级的性能优化,关于涉及操作系统甚至整个分布式大系统的性能优化我们另外单独介绍。
程序是运行在CPU之上的,因此在介绍性能优化之前我们有必要介绍一下CPU的内核结构。在前文中我们对CPU进行了简化处理(如图1),实际上CPU的结构非常复杂,毕竟一颗CPU由几十亿个晶体管组成的。
CPU通俗演义
CPU的作用很好理解,它就是一个数据加工部件。CPU就像一个大型的工厂,它将原料(数据)加工成半成品和成品;而内存则像一个大型的仓库。虽然CPU与内存都在机箱中,但CPU访问内存中的数据并不是非常方便,就好像工厂和大型仓库之间的距离,有几百公里。从仓库向工厂运算原材料需要用火车才行,运输一次材料可能要几个小时。
在这个大型工厂(CPU)里面有很多东西,最为重要的就是车间(CPU核)、生产线(ALU)、物料暂存区(寄存器)、工厂小仓库(缓存)等内容。为了更好理解上面这些内容的关系,这里做了一个简化的平面图。
工厂加工产品所需要的原材料需要从外面的大型仓库运输过来。由于从工厂外大型仓库到工厂的距离比较远,耗时比较长,因此总是有计划的,批量的将物料从工厂外的大仓库运输到工厂内的小仓库。工厂的车间突然需要一些原材料,那就只能让火车重新跑一趟了。
运输过来的原材料不能乱放,否则要是下雨刮风啥的不就损坏了。因此,原材料会被统一的放在工厂内的小仓库(CPU缓存)里面,各个车间根据需要从小仓库运输原材料到车间。
在车间有个暂存区(寄存器),用于存放从小仓库运过来的材料。当然暂存区除了放原材料之外,还会放一些半成品和成品。车间有车间的秩序,不能乱放,否则会出问题的。暂存区是很有必要的,要不然需要点材料就去仓库拿,那不得类似工人啊。
有了原材料之后,工人就可以将原材料放到生产线(ALU)上进行生产了。生产完成的成品又会放回暂存区,然后运输出去。暂存区和生产线都在车间里面,搬运原材料和成品都很快,几乎是一两分钟就可以完成。
关于流水线
为了提高产品生产速度,在一个车间里面通常是有多个生产线的。每一个生产线的大概流程是运输原材料、原材料预处理(比如撕掉包装或者切成小块等)、原材料加工和成品运回暂存区等步骤。CPU也是有类似的流水线的,任何一个指令都要经过读取指令、译码、执行和写回(寄存器或者内存)等。
以一个生产黄桃罐头的车间为例,在这个车间里面要同时生产罐头瓶、罐头瓶盖和糖水黄桃(这里是假设,实际工厂不是这样)。因此,也就有生产罐头瓶的流水线、生产罐头瓶盖的流水线和生产糖水黄桃的流水线。通常我们安排的流程是先生产罐头瓶和瓶盖,这样生产的糖水黄桃就可以装瓶完成成品了。但是,有的时候可能运送暂存区或者工厂的小仓库没有玻璃了,这样就没法生产罐头瓶。不过没关系,车间还是可以先生产糖水黄桃的,生产完之后先放到暂存区,等什么时候罐头瓶生产完之后在装瓶。
上面这个流程其实就是所谓的指令乱序。也就是CPU在执行指令的时候并不是按我们写代码的顺序执行的,而可能会打乱顺序。比如下面这段代码,由于两行代码之间没有任何依赖,因此在CPU中可能会先执行b=2,后执行a=1。
- int a = 1;
- int b = 2;
存储的金字塔结构
另外一个比较重要的知识点是需要知道软件开发涉及的存储金字塔。具体如图所示,其中寄存器、一级缓存、二级缓存和三级缓存是CPU内部的部件,然后是内存和磁盘。最后是远程存储,比如SAN、NAS或者对象存储或者云计算中的云盘等存储都属于远程存储。
通常来说越往金字塔底部,容量越大,但延迟也越大,性能越差。这里面有个特例,就是本地存储和远程存储,如果远程存储采用的介质与本地相同,则肯定远程存储性能要差一些。但当前有些分布式存储,通信链路采用RDMA,存储介质采用SSD,那么本地的机械磁盘就要比远程存储性能差了。
了解了这个结构之后,我们总结一下。其实性能问题总结起来就是一句话,尽量少的使用计算资源(比如不同的排序算法),尽量多的用金字塔顶部的部件存储要访问数据(比如文件系统的缓存)。
程序性能分析工具
正所谓:“欲善其事,必先利其器”。因此,要想进行性能优化,自然需要有相应的工具进行分析。本文仅针对Linux操作系统进行介绍,其它操作系统实在是不熟悉。在Linux操作系统下面,用的最多的性能分析工具恐怕非top莫属。
(1) top命令
top命令可以实时的观察进程的计算资源使用情况(CPU利用率)和整个系统的综合负载。如图是我们通过一个Python脚本模拟高负债程序,可以看到起CPU利用率已经达到100%。
top工具可以帮助我们分析高度消耗计算资源的程序的性能。另外还有其它性能分析工具,比如ps、vmstat、mpstat和prstat等等。工具比较多,限于篇幅问题,本文暂时就不做介绍了。
性能优化方法总结
有了前面的准备知识后,下面我们进入正题。本节内容总结了在程序代码级别常见的问题,并结合实例给出了解决方法,下面我们逐个分析一下。
1. 优化程序代码结构
这种问题的原因在于程序代码结构不合理,导致过度使用计算资源。如果往高大上的说,那就是算法不行。比如下面两段程序,前一段程序在for循环的条件判断中有一个strlen调用,用于判断字符串的长度。而后一段代码则将strlen移到条件判断外面。
如果字符串大的情况下,这两个程序的性能差异可能有百倍。这个主要是因为strlen函数其实是个循环判断,需要消耗大量的计算资源。
另外一种最为常见例子是关于排序算法的,比如冒泡排序的性能比快速排序差。因为两者计算量(时间复杂度)不同,所以算法的性能自然就不同。
2. 运算符合理选择
这部分也是针对计算资源消耗的优化。在介绍这部分内容之前,我们脑子里要有个概念。就是不同的运算(加减乘除)消耗的计算资源是不同的,其中加减、位运算和移位操作最低,可以认为是1,那么乘法则是3-4,而除法则大概是10-30左右。
了解上上面的内容之后,那我们在程序开发中就要尽量少用除法运算,因为它的性价比实在不高(太消耗计算资源)。有人可能会想怎么可能?有的时候就要用除法怎么办?下面我们看一个例子,这个例子是JDK中关于Hashmap的实现。
Hashmap是通过哈希表实现的,哈希表的概念这里就不啰嗦了。在查找或者存储的时候需要根据Key值取模,定位元素的位置。通常我们能想到的方法就是取模的运算符(计算量类似除法),但在Hashmap中却没有用取模运算符,而是用的位运算。这样,整个性能就会有十倍以上的提升,如下是它的代码。
- static int indexFor(int h, int length) {
- return h & (length-1);
- }
3. 减少对内存的访问
通过前面的准备知识我们知道内存的访问比寄存器慢100倍,因此在写代码的时候尽量减少对内存的访问。那么怎么减少对内存的访问呢?我们仍然看一个例子,比如一个简单的累加运算(这个例子比较极端)。前者是通过全局变量存储累加和,而后者是通过局部变量。
为了深入的了解两者差异,我们需要对程序进行反汇编,然后对比一下反汇编代码。对比上线代码可以看出前者每次计算都有访问多次内存(其中带圆括弧的汇编语句),而后者则将其转换成了寄存器访问。
虽然我们通常认为局部变量在函数栈中(内存空间),但实际上编译器在进行程序编译的时候会对代码进行优化,将局部变量优化为寄存器。因此,我们在实际开发的时候尽量使用局部变量,减少对内存的访问。
4. 减少对磁盘的访问
道理跟前面一个是一样的,还是那个存储金字塔。如果你的程序有很多对磁盘的访问,性能通常不会好到那去。通常的方法是使用内存作为缓存。在磁盘方面性能优化最经典的例子恐怕就是文件系统的页缓存了。也就是文件系统写入的数据不会马上写到磁盘,而是先写到缓存(内存)中。而读数据的时候则是通过预读机制提前将数据读入内存,文件系统从内存读数据,而不是从磁盘。由于内存的性能是机械磁盘的十万倍以上,因此文件系统的性能得以大大提高(这里有场景限制,我们后面再详细介绍)。
另外一个经典案例还是文件系统相关的,这个就是Linux的虚拟文件系统(VFS)。我们知道文件系统每个文件都对应着一个inode,而inode也是存储在磁盘上的。如果我们要打开一个文件,首先需要从磁盘找到inode,然后读取到内存,然后才能进行后续的读写操作。
在VFS中,在文件打开的时候,VFS会将inode放入一个内存中的哈希表中,而且在关闭文件的情况下并不释放。这样,当应用程序再次打开文件的时候就可以直接从内存找到该inode,而不用重新读磁盘了。
上面这些都是特例,大家要融会贯通,希望对大家的软件设计有所帮助。最后,性能优化本质,还是那一句话,尽量少的使用计算资源,尽量多的用金字塔顶部的部件存储要访问数据。