性能优化“三板斧”

672 阅读13分钟

背景

最近实现了一些统计报表工具,由于运算量较大,遇到了较多的性能优化方面的问题,如多任务的处理,批量读取与写入,内存优化预处理等,在此略作梳理总结

一般性原则

忌凭空猜测

当觉得某块代码执行太慢需要优化的时候,最好通过测试、日志、prof等方式具体定位,不是一个简单的我觉得这块有问题就改,可以先有一个初步的分析,再通过测试手段去验证结论,最后在执行优化后还有和之前的结果做一个比对,对于性能提升占比有多少

一般来说,导致性能瓶颈问题的方向:磁盘io,内存,cpu等

忌过早优化

The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.(真正的问题是,程序员花了太多时间担心错误的地方和错误时间的效率;过早优化是编程中所有邪恶(或至少大部分)的根源。)

不要施加过度的性能妄想,技术开发经常会陷入一种极致的牛角尖,当然不是说不好,只是某种程度会影响到实际的开发效率,养成良好的开发习惯比开发过程中拍脑袋的优化往往更实际。依据第一感觉(习惯)的编码,初步验证之后再分析优化点,根据业务实际环境和优先级执行优化

忌过度优化

性能的优化几乎是没有止境的,比如响应速度,可以优化代码执行效率,避免不必要的内存分配,减少gc压力,多任务处理,强制调度等,还可以加多级缓存,减少物理距离,就近部署等,越往后,边界效应就越明显,如何把握这个度,是需要分析业务需求目的,是否值得这么大的代价。比如只有几十个人使用的内部系统,就不用按照十万在线的目标去优化。过度优化还会对代码的可读性,可维护性有影响,需要多方面考虑。

在满足业务需求的前提下,最大效率完成开发最好不过。

一:并发

并发与并行

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。

你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。

你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。

并行的关键是你有同时处理多个任务的能力。

知乎用户U0XW9H

并发的方式

单核的任务切换

在只有一个CPU的情况下,为了让多个任务能够“同时”运行,通常是这个任务做一会儿,然后切到那个任务做一会儿,只要切换的速度足够快,就能让多个应用任务看起来是同时运行的,造成一种并发的假象

然而切换过程是有代价的,通常需要进行上下文的切换,操作系统需要保存相应的CPU状态和指令指针,并计算出要切换执行那个任务,并重新加载切换任务对应的处理器状态

就好比你吃饭吃到一半,电话来了,你停了下来(放下碗和筷子,拿起手机)接了电话,接完后(放下手机,端起之前的碗筷)继续吃饭

多核的并行执行

得益于现在cpu多核的特性,可以真正实现并行处理多个任务,在内存和磁盘没有成为瓶颈的情况下,效率可以成倍增加,在理想情况下,一个核执行一个任务,就不再需要大量的切换消耗

并发的两种方式:双核机器的真正并行 Vs. 单核机器的任务切换

并发的途径

多进程并发

将应用程序分为多个独立的进程,它们在同一时刻运行,就像同时进行网页浏览和文字处理一样,但多个进程之间的通信需要操作系统提供指定的通道(信号、套接字、文件、管道等等),这样的方式又有优劣,好处是进程之间相互隔离,安全性能够得到保障,不会被其他进程篡改内存造成破坏,缺点是通信复杂,速度较慢,单个进程运行所消耗的资源也多(需要时间启动进程,操作系统需要内部资源来管理进程,等等)

多线程并发

在单个进程中运行多个线程。线程很像轻量级的进程:每个线程相互独立运行,相应的,进程中多线程的通信也比多进程要简单快捷很多,进程中的所有线程都共享地址空间,并且所有线程访问到大部分数据———全局变量仍然是全局的,指针、对象的引用或数据可以在线程之间传递。

那么,代价是什么呢?

代价就是需要开发耗费更多的精力在多线程开发中注意不同线程的共享数据一致性和并发修改等问题(往往可以通过加锁等方式处理)

多协程并发

协程类似线程的函数调用,但执行过程中,在内部可中断,然后转而执行别的函数,在适当的时候再返回来接着执行,执行过程中是单个线程负责调度,所以不存在线程之间切换的性能消耗,但协程之间的切换还是存在,但相比cpu的线程切换来说,就小很多

并发的局限性

核心限制

考虑到实际cpu的核心数,cpu密集任务并发任务最好控制和cpu核心一致,避免因为单核任务调度反而造成性能下降

内存资源限制

通常,每个协程至少占用2k的内存(根据实际的执行情况会占用更多),需要充分考虑到当前机器的内存大小和执行逻辑的所需要内存,合理预估最大协程数量,保证运行时不会因为内存不足而中断

任务性质

如果任务有较多的io、网络等待操作等,可以适当提高协程数,因为此时协程会让出cpu

还要考虑并发处理过程中的内存分配,大量的内存分配会导致gc压力,进一步降低并发性能

暂时无法在飞书文档外展示此内容

如下所示,在协程超过cpu核心1000倍时,性能严重下降

func TestTask(t *testing.T) {
   // 协程超过核心反而引起效率降低
   coreNum := runtime.NumCPU() // 8
   taskCost(t, coreNum)
   // 超过核心数10倍
   taskCost(t, coreNum*100)
   // 超过核心数100000倍
   taskCost(t, coreNum*1000)
}

func taskCost(t *testing.T, coreNum int) (*ssync.TaskWorker, time.Time, time.Duration) {
    // 这里是封装了一个协程任务组的处理,就不贴源码了
   taskWork := ssync.NewTaskWorker(coreNum)
   startTime := time.Now()
   for i := 0; i < coreNum; i++ {
      ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
      taskWork.AddTask(func() {
         // 死循环占用cpu1ms
         defer cancel()
         for {
            select {
            case <-ctx.Done():
               return
            default:
            }
         }
      })
   }
         // 打印协程数
   t.Logf("taskWork.NumGoroutine:%d", runtime.NumGoroutine())
   taskWork.Wait()
   cost := time.Now().Sub(startTime)
   t.Logf("cost:%v", cost)
   return taskWork, startTime, cost
}
=== RUN   TestTask
    stat_test.go:85: taskWork.NumGoroutine:14
    stat_test.go:88: cost:20.777958ms
    stat_test.go:85: taskWork.NumGoroutine:807
    stat_test.go:88: cost:146.883ms
    stat_test.go:85: taskWork.NumGoroutine:6962
    stat_test.go:88: cost:23.283311291s
--- PASS: TestTask (23.45s)

二:缓存

没有什么性能问题是缓存解决不了的,如果有,那就再加一级缓存

缓存真是再熟悉不过了,云端的可以缓存在本地,本地的可以缓存到内存,内存的可以缓存在cpu内部的高速缓存......

缓存就是缩短调用路径从而增加执行速度

缓存策略

Cache-Aside

程序首先检查缓存。如果在缓存中找到,表示已经命中缓存。数据被读取并返回。

如果在缓存中没有找到,则未命中。需要查询数据库来读取数据,将数据返回给客户端,然后还要将数据存在缓存中,这样对相同数据的后续读取可以命中缓存。

优缺点

这样有一个优点:程序知道缓存和数据的存在,可以根据业务需要存不同于数据库的模型的数据,例如存储某个请求相应的回包信息,而不需要直接存对应的原始数据再来构造回包。

与之相对的,是缓存与数据一致性的维护问题,一般会设置缓存的过期时间,或者一定的过期策略来触发缓存的更新

Read-Though Cache

这个和上述Cache-Aside策略十分相似,不同的是程序只知道cache的存在,无需关心Database的数据,由cache维护缓存数据的一致性,而cache中的数据模型也要和DB中的数据模型保持一致

Write-Through Cache

首先将数据写入缓存,然后写入数据库。缓存与数据库保持一致。

通常配合Read-Though Cache策略使用,利用cache作为中间层,完成读写操作,保证数据一致性。

但存在一定的写延迟,需要额外关注缓存丢失、持久化和恢复的问题

Write-Around

数据直接写入数据库,只有读取的数据才能进入缓存。Write-around可以与read-through结合使用,并在数据只写一次、读取次数较少或从不读的情况下提供良好的性能。例如,实时日志或聊天室消息。同样,这个模式也可以与cache-aside组合使用。

Write-Back

应用程序将数据写入缓存,缓存会立即确认,并在延迟一段时间后将数据写入数据库。有时这种策略也被称为write-behind。

Write-back缓存提高了写性能,对于写工作量大的工作负载非常有用,降低了DB的压力。

但和Write-Through一样,如果缓存失效,数据可能会永久丢失。

缓存管理

缓存虽好,可不要贪多。

计算机的资源是有限的,如何利用有限的资源最大化缓存的效益,需要合适缓存管理模式,负责缓存生命周期的控制。

FIFO(first in first out)

先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。

LFU(less frequently used)

最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。

LRU(least recently used)

最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。

缓存场景

本地缓存

指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等;同时,它的缺点也是因为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存。

对于可能重复创建、销毁,且创建销毁代价很大的对象,比如进程、线程,也可以缓存,对应的缓存形式如单例、资源池(连接池、线程池)。

分布式缓存

指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存,最大化资源利用。

三:合并

合并主要是针对重复性耗时的操作进行批量处理的一种策略,主要是代码习惯和业务分析上的方式

处理分析

注意各种资源操作:数据读取、文件读写、网络请求等

是否存在对应重复操作的过程,如果有,需要针对目前业务情况考虑是否需要合并对应的操作,转成批量处理,以减少对应准备操作,就类似并发操作中,cpu在多线程之间切换所需要的上下文恢复等操作

合并场景

在io、网络操作多场景,通过合并一系列操作往往能达到意想不到的性能提升

例如涉及到网络请求时,网络传输的时间可能远大于请求的处理时间,因此合并网络请求就可以节省这一部分的传输时间,写文件的时候也可以批量写,以减少IO开销

在需要写大量文件的场景下,比如100个关卡,每个关卡100套牌的数据就需要1w个文件,这个时候也可以借用合并的思想,为了避免主线程长时间等待io操作,可以通过异步任务的方式完成批量完成1w文件的写入,只需要主线程最后等待执行完成就可以,避免了cpu无效等待

func TestMerge(t *testing.T) {
   coreNum := runtime.NumCPU() // 8
   // 主协程同步等待
   startTime := time.Now()
   for i := 0; i < coreNum; i++ {
      func() {
         // do something
         time.Sleep(10 * time.Millisecond)
      }()
      mockIo()
   }
   cost := time.Now().Sub(startTime)
   t.Logf("cost:%v", cost)

   taskWork := ssync.NewTaskWorker(coreNum)
   startTime = time.Now()
   for i := 0; i < coreNum; i++ {
      func() {
         // do something
         time.Sleep(10 * time.Millisecond)
      }()
      // 异步合并执行io
      taskWork.AddTask(func() {
         mockIo()
      })
   }
   taskWork.Wait()
   cost = time.Now().Sub(startTime)
   t.Logf("cost:%v", cost)
   return
}

func mockIo() {
   // 10ms sleep不占用cpu,模拟io
   time.Sleep(10 * time.Millisecond)
   return
}
    stat_test.go:138: cost:174.931458ms
    stat_test.go:154: cost:98.463625ms

总结

在一般性原则的基础上,对具体情况具体分析,针对80%的占用作优化,避免过度和过早优化

并发、缓存、合并操作是性能优化比较通用的方法,平时用的也比较多,还有一些其他的方法,比如更高效的实现,从业务逻辑层面优化;最后计算原则,将计算操作合并在最后一步,能减少无效计算操作......

极致的性能不是目的,满足业务需要才能体现价值

参考文章

性能优化指南:性能优化的一般性原则与方法 - xybaby - 博客园

C++ Concurrency In Action

Go 语言高性能编程 | 极客兔兔

Go 并发编程篇(二):协程实现原理及使用入门 - Geekr

协程

Caching Strategies and How to Choose the Right One

缓存那些事