这是我参与【第五届青训营】伴学笔记创作活动的第4天
一、本堂课重点内容
- 优化
- 内存管理优化
- 编译器优化
- 背景
- 自动内存管理和Go内存管理机制
- 编译器优化的基本问题和思路
- 实践:字节跳动遇到的性能问题以及优化方案
二、详细知识点介绍
1、性能优化
提升软件系统处理能力,减少不必要的消耗,充分发掘计算机算力
为什么需要性能优化?
-
用户体验: 带来用户体验的提升————让刷抖音更丝滑,让双十一购物不再卡顿
-
资源高效利用: 降低成本,提高效率————很小的优化乘以海量机器会是显的性能提升和成本节约
性能优化的层面
性能优化与软件质量
- 软件质量至关重要
- 在保证接口稳定的前提下改进具体实现
- 测试用例: 覆盖尽可能多的场景,方便回归
- 文档: 做了什么,没做什么,能达到怎样的效果
- 隔离: 通过选项控制是否开启优化
- 可观测:必要的日志输出
2、自动内存管理(垃圾回收)
动态内存:程序在运行时根据需求动态分配的内存:malloc()
自动内存管理:由程序语言的运行时系统回收动态内存
三个任务:
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
相关概念
- Mutator: 业务线程,分配新对象,惨改对象指向关系
- Collector: GC 线程,找到存活对象,回收死亡对象的内存空间
- Serial GC: 只有一个 collector
- Parallel GC: 支持多个 collectors 同时回收的 GC 算法
- Concurrent GC: mutator(s) 和 collector(s) 可以同时执行
- Collectors必须感知对象指向关系的改变
GC算法
追踪垃圾回收
对象被回收条件:指针指向关系不可达的对象
标记根对象:静态变量、全局变量、常量、线程栈等,标记:找到可达对象,清理:所有不可达对象
分代GC(Generational GC)
分代GC是基于这样一个假设:大部分新分配的对象存活周期较短,在分配后的第一轮GC中就会被回收掉。
如果这个假设成立,那么GC期间只去扫描和清扫新分配的对象就可以清扫掉大部分需要回收的对象,这样就可以节省GC的时间。
一些概念:
引用计数
每个对象都有一个与之关联的引用数目,对象存活的条件: 当且仅当引用数大于 0
- 优点
- 内存管理的操作被平摊到程序执行过程中
- 内存管理不需要了解 runtime 的实现细节: C++ 智能指针(smart pointer)
- 缺点
- 维护引用计数的开销较大: 通过原子操作保证对引用计数操作的原子性和可见性
- 无法回收环形数据结构- weak reference
- 内存开销: 每个对象都入的额外内存空间存储引用数目
- 回收内存时依然可能引发暂停
3、Go内存管理及优化
内存分配 ---- 分块
- 目标:为对象在 heap 上分配内存
- 提前将内存分块
- 调用系统调用 mmap() 向 OS 申请一大块内存,例如4 MB
- 先将内存划分成大块,例如 8 KB,称作 mspan
- 再将大块继续划分成特定大小的小块,用于对象分配
- noscan mspan: 分配不包含指针的对象一-GC 不需要扫描
- scan mspan: 分配包含指针的对象GC 需要扫描
- 对象分配:根据对象的大小,选择最合适的块返回
内存分配 ---- 缓存
- TCMalloc: thread caching
- 每个p 包含一个 mcache 用于快速分配,用于为绑定于p 上的 g 分配对象
- mcache 管理一组 mspan
- 当 mcache 中的 mspan 分配完毕,向 mcentral 申请带有未分配块的 mspan
- 当 mspan 中没有分配的对象,mspan 会被缓存在mcentra1 中,而不是立刻释放并归还给 OS
内存管理优化
- 对象分配是非常高频的操作: 每秒分配 GB 级别的内存
- 小对象占比较高
- Go 内存分配比较耗时
- 分配路径长: g ->m -> p -> mcache -> mspan -> memory block -> return pointer
- pprof: 对象分配的函数是最频繁调用的函数之一
优化方案:Balanced GC
- 指针碰撞风格的对象分配
- 实现了copying GC
- 性能收益
4、编译器和静态分析
编译器结构
静态分析
- 静态分析:不执行程序代码,推导程序的行为,分析程序的性质
- 控制流(Control flow): 程序执行的流程
- 数据流(Data flow): 数据在控制流上的传递
- 通过分析控制流和数据流,我们可以知道更多关于程序的性质(properties)
- 根据这些性质优化代码
过程内分析和过程间分析
- 过程内分析(Intra-procedural analysis)
- 仅在函数内部进行分析
- 过程间分析(Inter-procedural analysis)
- 考虑过程调用时参数传递和返回值的数据流和控制流
- 为什么过程间分析是个问题?
- 需要通过数据流分析得知 i 的具体类型,才能知道 ifoo () 调用的是哪个 foo ()
- 根据 i 的具体类型,产生了新的控制流,A.foo(),分析继续
- 过程间分析需要同时分析控制流和数据流 -- 联合求解,比较复杂
5、Go编译器优化
函数内联
- 内联: 将被调用函数的函数体(callee) 的副本替换到调用位置(caller) 上,同时重写代码以反映参数的绑定
- 优点
- 消除函数调用开销,例如传递参数、保存寄存器等
- 将过程间分析转化为过程内分析,帮助其他优化,例如逃逸分析
- 缺点
- 函数体变大,instruction cache (icache) 不友好
- 编译生成的 Go 镜像变大
- 函数内联在大多数情况下是正向优化
- 内联策略
- 调用和被调函数的规模
- ……
Beast Mode
- Go函数内联受到的限制较多
- 语言特性,例如 interface,defer 等,限制了函数内联内联
- 策略非常保守
- Beast mode: 调整函数内联的策略,使更多函数被内联
- 降低函数调用的开销
- 增加了其他优化的机会: 逃逸分析
- 开销
- Go 镜像增加 ~10%
- 编译时间增加
逃逸分析
三、课后个人总结
通过今天的学习,我知道逃逸分析是一个非常强大的功能,当然在日常编写程序时也需要注意,我们依旧要注重对栈和堆变量的作用域的控制,知道变量的存储位置对程序的效率有着一定的帮助,因为分配在栈上的变量越多,堆上的变量越少,就越可以减轻内存分配的开销,减小 GC 的压力,提高程序的运行速度。
四、课程链接学习
Go 内存管理 & 编译器优化思路 - 掘金 (juejin.cn)