您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件开发专栏 > 开发技术 > 正文

聊聊软件性能优化全景

发表于:2020-12-31 作者:我不想种地 来源:码砖杂役

本文转载自微信公众号「码砖杂役」,作者我不想种地。转载本文请联系码砖杂役公众号。 

性能优化是指在不影响正确性的前提下,使程序运行得更快,它是一个非常广泛的话题。

软件产品多种多样,影响程序执行效率的因素很多,因此,性能优化,特别是对不熟悉的项目做优化,不是一件容易的事。

性能优化可分为宏观和微观两个层面。宏观层面包括架构重构,而微观层面,则包括算法的优化,编译优化,工具分析,高性能编码等,这些方法是有可能独立于具体业务逻辑,因而有更加广泛的适应性,且更易于实施。

具体到性能优化的方法论,首先,应建立度量,你度量什么,你得到什么。所以,性能优化测试先行,须基于数据而不能凭空猜测,这是做优化的一个基本原则。搭建真实的压测环境,或者逼近真实环境,有时候是困难的,也可能非常耗费时间,但它依然是值得的。

有许多工具能帮助我们定位程序瓶颈,有些工具能做很友好的图形化展示,定位问题是解决问题的前置条件,但定位问题可能不是最难的,分析和优化才是最耗时的关键环节,修改之后,要再回归测试,验证是否如预期般有效。

什么是高性能程序?架构致广远、实现尽精微。

架构优化的关键是识别瓶颈,这类优化有很多套路,比如通过负载均衡做分布式改造,比如用多线程协程做并行化改造,比如用消息队列做异步化和解耦,比如用事件通知替代轮询,比如为数据访问增加缓存,比如用批处理+预取提升吞吐,比如IO与逻辑分离、读写分离等等。

架构调整和优化虽然收效很大,却因受限于各种现实因素,因而并不总是可行。

能不做的尽量不做、必须做的高效做是性能优化的一个根本法则,提升处理能力和降低计算量可视为性能优化的两个方向。

怎么让程序跑的更快?这要求我们充分利用硬件的各种特性,想方设法减少等待并且提高并发,提升CACHE命中率,使用更高效的结构和算法;而降低计算量,则可能意味着要跳出纯技术范畴,从产品和业务视角去审视:哪些功能是必须的,哪些功能是可选可配置的。

有时候,我们不得不从细节的维度去改进程序。通常,我们应该使用简单的数据结构和算法,但如有必要,就应积极使用更高效的结构和算法,不止逻辑结构,物理结构(实现)同样影响执行效率;分支预测、反馈优化、启发性以及基于机器学习编译优化的效果日益凸显;熟练掌握编程语言深刻理解标准库实现能帮助我们规避低性能陷阱;深入细节做代码微调甚至指令级优化有时候也能取得意想不到的效果。

有时候,我们需要做一些交换,比如用空间置换时间,比如牺牲一些通用性可读性换取高性能,我们只应当在非常必要的情况下才这么做,它是权衡的艺术。

## 1、架构优化

### 负载均衡

负载均衡其实就是解决一个分活的问题,对应到分布式系统,一般在逻辑服的前面都会安放一个负载均衡器,比如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也有编译选项,开启它会在编译阶段自动插入预取代码,手动增加预取代码需要小心处理,时机很重要,最后一定要基于测试数据,另外,即使表现很好,但代码修改也有可能导致效果衰减,而且预取语句执行本身也有开销,只有预取的收益大于预取的开销才是值得的。

累啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦啦,写不动啦啦啦!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!哪天有空再写后面的章节吧

## 2、算法优化

### 哈希(HASH)

#### 哈希和字符串比较

#### HashMap

#### 哈希和平衡搜索树的比较

### 基于有序数组的二分查找

### 数据结构的实现优化

### 延迟计算 & 写时拷贝

### 预计算

### 增量更新

## 3、代码优化

### 内存优化

小对象分配器

内存分配和对象构建分离

### cache优化

i-cache优化、d-cache优化、cache对齐、结构体重排

### 判断前置

### 整体操作替代小操作

### 复用

### 减法

#### 减少冗余

#### 减少拷贝、零拷贝

#### 减少参数个数(寄存器参数、取决于ABI约定)

#### 减少函数调用次数/层次

#### 减少存储引用次数

#### 减少无效初始化和重复赋值

### 循环优化

### 防御性编程适可而止

### release干净

### 慎用递归

## 4、编译优化

### inline

### restrict

### LTO

### PGO

### 优化选项

## 5、其他优化

### 绑核

### SIMD

### 锁与并发

#### 锁的粒度

#### 无锁编程

#### Per-cpu data structure & thread local

#### 内存屏障

小结

性能优化是一项细致的工作,工程师们曾致力于寻找一劳永逸解决性能问题的捷径,但遗憾的是,没有银弹,但这并不意味着性能优化无章可循。软件工程师们在性能优化方面积累了大量的经验,包括架构、缓存、预取、工具、编译器与编程语言,代码重构等实践经验方方面面,这些方法和探讨都具有借鉴意义。

性能优化也是一个系统性工程,出现性能瓶颈再优化是一种先污染后治理的思路。更好的方式是将性能贯穿于软件的整个生命周期之中,在设计之初即把性能作为一项需求甚至关键目标加以考虑,开发中持续监控性能的变化并严格遵从高性能编码规范,后期维护将性能纳入维护体系。

严格的说,性能优化和性能设计有所不同,性能优化通常是在现有系统和代码基础上做改进,它并非推倒重来,考验的是开发者反向修复的能力,而性能设计考验的是设计者的正向设计能力,但性能优化的方法可以指导性能设计,两者互补。