这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记」
为什么需要性能优化? 当然是甲方的要求,但要求的是什么呢?
提升用户体验,降低成本,提高效率
如何进行性能优化?
提升软件系统的处理能力,降低不必要的消耗,充分发挥计算机的算力,可以从哪些层面进行优化?
主要是从两个方面进行优化:业务层和语言运行层。
-
业务层主要从代码的逻辑来进行优化,通过一下分析工具,来锁定最大瓶颈处也可能是代码质量问题处,因此可以获得较大的提升
-
语言运行层则是解决更基础的问题,例如编译时的优化,其作用显然不局限于某个具体的业务,而是整个使用该编译器的公司等。
无论怎么优化,其基础均需要在实际数据下的优化,其次解决问题一般先去解决最大瓶颈,最后优化不能改变代码的正确性(即不能将1 + 1 = 2 优化成 1 + 1 = 3 即使运算效率快100倍)
一、背景
1.自动内存管理
go语言由于是一个后生代语言,因此吸收了其他语言的优势之处,初次接触这个语言,最让我意外的还是他既有c/c++中的指针,又有java的自动回收机制。这对于无论是c++ / java 转行来说都是一个易于上手的友好语法。 这里就不得不提提go的内存管理机制。
go中也像java一样有着主流的三种垃圾回收机制(也许有着借鉴前者的关系)
-
复制对象GC :见名知义,将回收区域通过可达性标记找到所有的存活的对象,将其复制到另外的空间(java中同样也是将回收区存活的对象复制到一块叫做Surivor的区域存放存活的对象)
-
标记清理GC:标记存活的对象,统一回收不存活的对象
-
标记压缩GC:通过标记存活的对象,在回收时将其复制放在内存的前方,并将边界高数GC,让GC回收边界后的部分
那么我在学习时可能有以下疑问,究竟三者哪一个好呢?具体通过什么实现的呢? 没有最好的,只有更好的,对于三者回收机制,不同的时机有更适合的一种方法,下面讲解一种分代收集理论
分代GC【提到回收的内存区域,又要提到golang中的两个区域(java老版本也存在这两个区域)】:
1.新年代(Young Generation):这里通常是刚创建的对象,在长期积累的经验中,发现新年代往往不容易存活,大部分需要被回收。因此对于这块区域我们使用复制对象GC效率更高
2.老年代(Old Generation):长驻于内存的对象(经历过多次GC,一次GC算做一次年龄),由于长驻内存,因此几乎不会发生GC(趋势于一直活着),反复复制开销较大,采用mark- sweep collection(标记清理)
既然各有各的适用条件,那么如何评价GC算法呢?
- 安全性(Safety):不能回收存活的对象(避免出错)
基本要求 - 吞吐率(Throughput):
花在GC上的时间 - 暂停时间(Pause time):stop the world(STW)
业务是否感知 - 内存开销(Space overhead):
GC原数据开销对于这些指标的评判决定了一个GC算法在一定环境下的好坏
二、优化
1.自动内存管理机制和Go内存管理机制
Go内存分配——分块
当对象在heap上分配内存时:
- 提前将内存分块
- 调用系统调用mmap()向OS申请一大块内存,例如4MB
- 先将内存划分成大块,例如8KB,称作mspan
- 在将大块继续划分成特定大小的小块,用于对象分配
- noscan mspan:分配不包含指针的对象 —— GC不需要扫描
- scan mspan:分配 包含指针的对象 —— GC扫描
- 对象分配:根据对象的大小,选择最合适的块返回
Go内存分配——缓存
-
TCMalloc: thread caching
-
每个p包含一个mcache 用于快速分配,用于为绑定于p上的g分配对象
-
mcache 管理一组 mspan
-
当mcache 中的mspan 分配完毕,向mcentral申请带有未分配块的 mspan
-
当mspan 中没有分配的对象,mspan 会被缓存在mcentral 中,而不是立刻释放井归还给
结合上述机制我们如何优化呢?:Balanced GC
- 每个g都绑定一大块内存(1KB),称作goroutine allocation buffer(GAB)
- GAB用于noscan类型的小对象分配(< 128 B)
- 适用三个指针维护GAB:base ,end,top(类似C语言读文件中的bengin end current三个指针)
- Bump pointer(指针碰撞)风格对象分配
那如何判断g上的内存是否分配满呢?
如果top加当前所需的大小比整块内存小则可以分配
if top + size <= end {
addr := top
top += size
return addr
}
2.编译器优化
要知道如何编译器如何优化我们需要了解编译器的结构
- 重要的系统软件
- 识别符合语法和非法的程序
- 生成正确且高效的代码
- 分析部分(前段 front end)
- 词法分析,生成词素(lexeme)
- 语法分析,生成语法书
- 予以分析,收集类型信息,进行语义检查
- 中间代码生成,生成 intermediate representation(IR)
- 综合部分(后端 bakc end)
- 代码优化,机器无关优化,生成优化后的IR
- 代码生成,生成目标代码
静态分析(不运行代码,推导程序的行为,分析程序的性质
int a = 30
int b = 9 - (a / 5)
int c
c = b * 4
if c > 10 {
c = c -10
}
return c * (60 / a)
通过分析控制流(Control flow:程序执行的流程)和数据流(Data flow:数据在控制流上的传递),我们可以知道更多关于程序的性质
为什么做编辑器优化?
- 用户无感知,冲编译即可获得性能收益
- 通用性优化
编译优化方向:Tradeoff(用编译时间换取更高效的机器码|适合面向后端长期执行的任务)
- 函数内联:将被调用函数的函数体的副本替换到调用位置上,同时重写代码以反映参数的绑定
- 优点:
- 消除函数调用开销(由于需要压栈,需要传递参数,保存寄存器等)
- 将过程间分析转换成过程内分析,帮组其他优化。例如逃逸分析
- 缺点:
- 函数体变大,instuction cache(icache)不友好
- 编译生成的Go镜像变大 但是内联在大多数情况下是正向优化
- 优点:
- 逃逸分析:分析代码中指针的动态作用域:指针在何处可以被访问
- 大致思路
- 从对象分配处出发,沿着控制流,观察对象的数据流
- 若发现指针p在当前作用域 s,则已经逃逸出s:
- 作为参数传递给其他函数
- 传递给全局变量
- 传递给其他的goroutine
- 传递给已逃逸的指针指向对象
- 优化:未逃逸的对象可以在栈上分配
- 对象在栈上分配和回收很块:移动sp
- 减少heap的分配,降低GC负担
- 大致思路
- 默认栈大小调整
- 边界检查消除
- 循坏展开
- ...