性能在软件工程诞生时就占据着非常重要的位置,如何用更少的硬件资源来支撑更多的功能、来完成更多的任务是软件工程师的职责,也是用来衡量一个软件工程师技艺高低的标准。
性能在软件工程诞生时就占据着非常重要的位置,如何用更少的硬件资源来支撑更多的功能、来完成更多的任务是软件工程师的职责,也是用来衡量一个软件工程师技艺高低的标准。这跟炒菜是一个道理,同样的食材,在饭店的师傅手里跟普通人手里的做法是有很大的差别的;食堂师傅是工程化的做法,讲究是短时间炒制更多的菜,而且味道不能掉;而普通人则比较随意,没有什么章法,而且可能只是爱好或者放松的渠道,所以不用考虑炮制的性能如何,能不能达到预计的目标。简单来说,厨师是靠手艺去赚钱,而其他人只是玩玩。那么程序员的手艺体现在哪呢?可以肯定的说,性能调优是最重要的一环,也是最基本的一环,甚至我觉得只有掌握这个技能后才能够进阶做构架或者做设计,否则如果反其道而行之则会举步维艰。
这时你可能会说,我是觉得性能是很重要,但是怎么去学呢?IT 是个发展迅速的行业,每年涌现的新技术,新框架数不胜数,多如繁星,往往还没学懂 Java,Go 都快被淘汰了……一茬接一茬,无从下手。如果你常常有这个感觉,那么这篇文章可能对你有帮助。
性能优化的套路
先说结论,性能优化或者说如何能够开发出效率更高的程序来(当然硬件资源一样且使用率一样的条件下),只有两条路:
掌握局部性原理
掌握基本的算法设计与分析
在现有计算机构架下(冯诺依曼构架)这是调优的充分必要条件,换句话说就是,如果你写了一个性能比别人好的程序,那么只有两种情况,要么是局部性原理用得更好,要么是就是采用了更好的算法;同时,如果你想要写出比别人好的程序,那么你也只有两条路可以走,运用更底层的局部性优化设计,或者设计更好的算法来解决这个问题。
局部性原理
如果你在软件工程领域“阅历”足够丰富,你就会有恍然大悟的感觉,因为这种例子实在太多:Linux 中的硬盘缓冲、页缓存,Redis 中元素较少的时候用 ziplist 代替 hashmap 可能还会带来性能的提升,内存的减少;Kafka 依赖的磁盘缓存,分区存储机制,Mysql 的 B+树索引;更底层的 CPU 的 Cacheline 技术,分页技术等等其实都是用了局部性原理来作为理论基础的。那么什么是局部性原理呢?
简单来说分为三个要点:
程序总是按照“批”来处理数据,这样效率最高;比如:CPU 读取内存是按照批来处理如:一个 cacheline=64/128byte,内存与硬盘按照 4KB 来读取。因为计算机结构的特点,各级设备(CPU 到内存到硬盘与外设)之间的访问速度是有数量级的差别;所以就注定不能一个个字节来读取;这个现象决定计算机优化的方向,被称为冯诺依曼瓶颈;
最近访问过的资源(内存位置),可能近期内还会被访问,这个叫做时间局部性,对应的解决方案是缓存;
当前位置内存被访问过,那么大概率旁边的内存也会被后续访问,这个叫做空间局部性,对应的解决方案是预加载;
所以推导的顺序是:正是因为现代计算机有冯诺依曼瓶颈所以需要用缓存与预加载来提高程序的性能,否则会因为 IO 过重,资源得不到利用而效率偏低。
还是拿做饭这件事举例子。当我们去买菜的时候,会尽可能的减少去菜市场的时间,也肯定不可能发现什么菜没有了再临时去菜市场购买,这样“IO”就太重了,要使用局部性原理来解决。就炒菜的例子来说,我们要炒青椒炒肉这个菜;首先是买菜,我们发现在买青椒的时候可以把葱姜蒜也都一并买了,因为蔬菜区往往是相邻的;而买肉的时候,尽量也把酱油醋都一并买了,它们也是很可能也是挨着的;这种在空间上相邻的提前加载这就是“预加载”了;而回到家,我们开始炒菜的时候,厨房的布置往往是炒菜的锅子跟油盐酱醋是分开放置的,但是炒菜的时候要经常加盐、放酱油,所以为了减少来回取油盐的时间,会在炒菜的时候将配菜与油盐放置在炒锅的附近,加快炒菜的速度,等炒完后又重新放回去,这个就叫做“缓存”的思维——把经常使用的资源放在工作较近的地方,等使用完再释放。
在 Linux 内核中,我们可以看到很多的 buffer 与 cache,这些数据结构都是局部性原理的具体使用实例。比如,我们知道内存跟磁盘之间 IO 速度相差 5 个数量级。那么往往会出现,一个进程刚写入磁盘的数据,又要被其他进程读取,那么一来一回 CPU 都在等待磁盘读取数据,十分浪费资源,那么如果将磁盘的数据放入磁盘写 buffer,读的时候优先从 buffer 中读取,而 buffer 都是在内存中,这样就弥补了这个性能的 gap,这就是“时间局部性”原理的使用,也就是我们常说的“缓存”,或者“空间换时间”;
Mysql 数据库有个特点就是数据是存在磁盘上,读取记录时需要将数据从磁盘加载到内存然后进入 CPU 计算得出结果,所以会横跨三个 IO 边界——磁盘、内存与 CPU,它们之间都有好几个数量级的延迟,所以 IO 性能的平衡就十分的重要。其中比较典型的就是 B+树这种针对外部存储型的数据结构的引入,它十分贴合磁盘 IO 的“口味”。磁盘 IO 有个特点,就是读取写入都很慢,但是可以一次读取大量的数据。根据这个特点,B+树采用多叉树的结构进行设计,这样做相比二叉树来说可以减少树的高度,这样带来的好处是每一层数据都可以是在物理空间相邻的,从而可以通过最少的 IO 次数加载更多的数据到内存;而这些数据往往是业务相关的,所以一次 IO 读取的数据可以供后续很多计算工作使用而不用重复 IO。比如,一个表有 20 列,那么每一行数据就有 20 个字段,这些字段往往在磁盘中是连续存放的,当我们通过 id 查询其中某个字段 a 的值时,Mysql 其实会将整个记录都取出来,加载到内存,而程序访问完 a 字段,再访问记录中其他字段时就不需要磁盘 IO 了,直接读取内存中记录的缓存就行,这就是“空间局部性”的使用实例,也就是我们常说的“预加载”,系统预热。
算法分析与设计
这是计算机科学的范畴,也是最引人注目的领域,就好比武侠小说里面的武功,华丽的招式比不过强大的内功,练招式容易练内功难,一旦内功深厚,招式什么的都是分分钟被秒杀,就好比《一拳超人》中的琦玉老师,专治各种花里胡哨。而我想说的是,算法设计技术就是软件工程领域的内功心法。当然我远远没有达到可以对这个领域品头论足的程度,就想抛砖引玉,介绍点皮毛,希望对有天赋的同学有所启发。
数据结构
数据结构之所以重要是因为不同的数据结构表现出来的数学性质是构成特定算法的基础。就好比各种化学元素可以搭建各种性质不同的化合物,而不同性质的化合物正式化学工程所依赖的基础。计算机科学的数据结构可以大致分为两种,一种是数组,另一种是链表。两种性质截然相反的“物质”。
数组——线性性能最好,支持随机访问,按照索引取数组中的元素时间复杂度是 O(1),而插入与删除元素时间复杂度是 O(n);
链表——扩展性能最好,支持动态的增减元素,插入、删除元素的时间复杂度是 O(1),而检索元素的时间复杂度变成了 O(n);
(注:O 标记是数学语言,用于标记一个函数的增长快慢,是一个渐进界函数,具体细节可以参考屈婉玲教授的详细解释)
这个世界有时候是惊人对称的,对称是美妙的。这样一对性质完全互斥的结构就是构成所有高等数据结构的基础。就像中国传统文化中的太极一样,你中有我我中有你,互相补充,世间万物的运行法则皆可解释。
举几个例子,有些高等数据结构具有耀眼的数学特性,比如“堆”,或者叫做优先级队列、二叉堆,就是以数组作为基础的。它在插入、删除、查询元素时的性能可以稳定在 O(logn)量级;在互联网行业中,只有 logn 级别的算法可以适用,因为当数据规模急剧增加的时候,对数函数能够很好的平稳压力,所以,"logn"的算法对互联网行业贡献巨大,具有整流器的作用。比如,在微服务流量控制,大数据流处理、topN、高性能定时器都有很多应用。而堆只有在数组实现的算法中才能保持这个特性,如果用链表就会退化为 O(nlogn),失去魔力。
另一个例子是二叉树,这就是链表的一个使用场景。二叉树是一种树状结构,其中平衡二叉树在插入与删除的过程中只要移动 logn 次就能找到自己的新位置,而且代码简单易于维护(不容易写错也是工程中一个重要的考虑点,如果写得代码过于复杂就要反思下是否使用错了数据结构)。比如:红黑树就是一个综合性能很好的平衡二叉查找树;它是一个动态的数据结构,可以在动态添加与查找过程中稳定在 O(logn)量级;在 Linux 内核中大量使用;而且在 Java 中的 ConcurrentHashMap 在冲突大的情况下,冲突元素大于 8 也会升级成红黑树存储冲突元素,来平衡工程与算法效率之间的矛盾。
当然,数据结构是可以融合的,比如 Java 中的 LinkedHashMap 就是融合了数组、哈希表与链表的优秀实践。普通的哈希表因为通过哈希函数将元素链接到数组的索引号上面,实现高速的查找性能,但是丢失了元素的插入顺序,而有时候我们需要这个顺序性来实现特殊的需求,比如缓存淘汰策略;而如果使用链表,形成一个插入的队列,先插入的在队列头,后插入在队列尾部;但是这样虽然保存了插入的顺序但是丢失了查找性能;为了平衡,我们可以在哈希表的基础上,每个元素再增加一个指针用来连接前后插入的元素,形成队列,这样没插入一个元素不仅在哈希表上挂载新的索引点,还要将新元素挂接到队列的尾部,而每一步都是 O(1)的开销,是可以接受的,这就是 LinkedHashMap 的实现原理。
算法设计技术
算法设计技术是使用数据结构解决实际问题的技术,就好比化合物特性研究与化学工程的关系,一个是偏重科学特性的研究,一个是解决实际问题。为了能够让科学能够指导生产,能够造福世间,必须将科学落地,那么这个过程中最重要的一步就是对实际问题的建模,建模是对问题的模拟,越准确越能够解决好问题。那么,在算法设计层面有哪些建模方式呢?大体可以分为四个:
蛮力算法
贪心算法
分治算法
动态规划
其中,蛮力算法是对问题建模的初期阶段,是对问题的程序再现,追求的是定性,性能不一定重要,但是问题描述没问题;分治与贪心是在蛮力基础上的一次降阶,往往可以将问题优化到 O(nlogn)的规模以内;而动态规划可以进一步将问题的阶降到 O(n)级别。降阶是设计算法的初衷,前提是问题本身计算的各个阶段是有冗余的,有重复计算的地方,而找到这个重复的点并不容易,就拿动态规划来说吧,虽然有极致的性能但是发现递推方程并不容易,当然这一切都要经过严格的数学证明才行,这就更加难能可贵。我们这里没有考虑空间的优化,往往降阶的过程中最好保证空间的复杂度不会激增,这样才会有效。这方面我们不展开了,有很多资料可以参考。
作者: 肖劲
文章转载自: ThoughtWorks 洞见(ID:TW-Insights)
原文链接:性能优化那些事
评论