这是我参与「第五届青训营 」伴学笔记创作活动的第5天
讲师: 张逸飞
相关术语
自动内存管理
Auto memory management: 自动内存管理
Grabage collectionn: 垃圾回收
Mutator: 业务线程
Collector: GC线程
Concurrent GC: 并发GC
Parallel GC: 并行GC
Tracing garbage collection: 追踪垃圾回收
Copying GC: 复制对象GC
Mark-sweep GC: 标记-清理GC
Mark-compact GC: 标记-压缩GC
Reference counting: 引用计数
Generational GC: 分代GC
Young generation: 年轻代
Old generation: 老年代
Go内存管理及优化
TCMalloc
mmap()系统调用
scan object和noscan object
mspan, mcache, mentral
Bump-pointer object allocation: 指针碰撞风格的对象分配
编译器和静态分析
词法分析
语法分析
语义分析
Intermediate representation(IR): 中间表示
代码优化
代码生成
Control flow: 控制流
Data flow: 数据流
Intra-procedural analysis: 过程内分析
Inter-procedural analysis: 过程间分析
Go编译器优化
Function inlining: 函数内联
Escape analysis: 逃逸分析
课程回顾
上节课: 高质量编程与性能调优实战
性能优化:
分析工具 - pprof: 采样原理, 如何定位性能问题等等
业务优化
基础库优化
Go语言优化
本节课是对Go语言优化的展开
软件结构
业务代码
SDK
基础库
语言运行时
OS
1 自动内存管理
动态内存
程序在运行时根据需求动态分配的内存: malloc()
自动内存管理(垃圾回收): 由程序语言的运行时系统管理动态内存
避免手动内存管理, 专注于实现业务逻辑
保证内存使用的正确性和安全性(
避免double-free problem: 释放了两次
和use-after-free problem: 在释放后使用
)
三个任务
为新对象分配空间
找到存活对象
回收死亡对象的内存空间
相关概念
Mutator: 业务线程, 分配新对象, 修改对象指向关系
Collector: GC线程, 找到存活对象, 回收死亡对象的内存空间
Serial GC: 只有一个collector
特点: 会有暂停, 只有一个collector
Parallel GC: 支持多个collectors同时回收的GC算法
特点: 会有暂停, 暂停时有多个collector
Concurrent GC: mutatos(s)和collector(s)可以同时执行
特点: 没有暂停
关键: 必须感知对象执行关系的改变
(暂停就是STP: Stop The World)
评价GC算法:
安全性(Safety): 不能回收存活对象 (基本要求)
吞吐率(Throughput): 1 - GC时间/程序执行总时间
暂停时间(Pause time): STW
内存开销(Space overhead)
追踪垃圾回收 (Tracing garbage collection)
对象被回收的条件: 指针指向关系不可达的对象
步骤:
1 标记根对象
静态变量, 全局变量, 常量, 线程栈等
2 标记: 找到可达对象
求指针指向关系的传递闭包: 从根对象出发, 找到所有可达对象
3 清理: 所有不可达对象
法1: 将存活对象复制刀另外的内存空间 (Copying GC)
法2: 将死亡对象的内存标记为"可分配" (Mark-sweep GC)
法3: 移动并整理存活对象("原地"整理) (Mark-compact GC)
分代GC (Generational GC)
分代假说(Generational hypothesis): most objects die young
Intuition: 很对对象在分配出来后很快就不再使用了
每个对象有年龄: 经历过GC的次数
目的: 对年轻和老年的对象, 制定不同的GC策略, 降低整体内存管理的开销
不同年龄的对象处于heap的不同区域
年轻代(Young generation)
常规的对象分配
由于存活对象很少, 可以采用copying collection
GC吞吐率很高 (即GC花的时间少)
老年代(Old generation)
对象趋向于一直活着, 反复复制开销很大
可以采用mark-sweep collection
碎片多了可以来个mark-compact
引用计数(Reference counting)
对象都有一个与之关联的引用数目
对象存活的条件: 当且仅当引用数大于0
优点:
内存管理的操作被平铺到程序执行过程中
内存管理不需要了解runtime的实现细节 (如C++ 智能指针smart pointer)
缺点:
维护引用计数的开销较大 (因为要通过原子操作保证对引用计数操作的原子性和可见性)
无法回收环形数据结构 (解决方法: weak reference)
内存开销: 每个对象都引入的额外内存空间存储引用数目
回收内存时依然可能引发暂停
小结
自动内存管理的背景和意义
概念和评价方法
追踪垃圾回收
引用计数
分代GC
2 Go内存管理及优化
Go内存管理
三色标记法, 有空可以去了解一下
分块
目标: 为对象在heap上分配内存
提前将内存分块
调用系统调用mmap()向OS申请一大块内存, 例如4MB
先将内存划分成大块, 例如8KB, 称作mspan
再将大块内存继续划分成特定大小的小块, 用于对象分配
noscan mspan: 分配不包含指针的对象 (GC不需要扫描)
scan mspan: 分配包含指针的对象 (GC需要扫描)
对象分配: 根据对象的大小, 选择最合适的块返回
缓存
借鉴了TCMalloc (TC: thread caching)
每个p包含一个mcache用于快速分配, 用于为绑定于p上的g分配对象
mcache管理一组mspan
当mcache中的mspan分配完毕, 向mcentral申请带有为分配块的mspan
当mspan中没有分配的对象, mspan会被缓存在mcentral中, 而不是立即释放并归还给OS
Go内存管理优化
对象分配是非常高频的操作: 每秒分配GB级别的内存
小对象占比高
Go内存分配比较耗时
分配路径长: g->m->p->mcache->mspan->memory block->return pointer
优化方案 Balanced GC
每个g绑定一大块内存(1KB), 称作goroutine allocation buffer(GAB)
GAB用于noscan类型的小对象分配: <128B
使用三个指针维护GAB: base, end, top
Bump pointer(指针碰撞)风格对象分配:
无须和其它分配请求互斥
分配动作简单高效
GAB对象Go内存管理来说是一个大对象
本质: 将多个小对象的分配合并成一次大对象的分配
问题: GAB的对象分配方式会导致内存被延迟释放
解决方案: 移动GAB中存活对象
当GAB总大小超过一定阈值时, 将GAB中存活的对象复制到另外分配的GAB中
原先的GAB可以释放, 避免内存泄露
本质: 用copying GC的算法管理小对象
3 编译器和静态分析
编译器
结构
源代码
↓词法分析器
词素(lexeme)
↓语法分析器
抽象语法树(AST)
↓语义分析器
(decorated) AST
↓中间表示
IR
↓代码优化
IR
↓代码生成
目标代码
分析部分 (前端front end)
词法分析
语法分析
语义分析
中间代码生成, 生成IR
综合部分 (后端back end)
代码优化, 机器无关优化, 生成优化后的IR
代码生成, 生成目标代码
静态分析
静态分析: 不执行程序代码, 推导程序的行为, 分析程序的性质
控制流(Control flow): 程序执行流程
数据流(Data flow): 数据在控制流上的传递
过程内分析(Intra-procedural analysis):
仅在函数内部进行分析
过程间分析(Inter-procedural analysis):
考虑函数调用时参数传递和返回值的数据流和控制流
小结
编译器的结构和编译的流程
编译器后端优化
静态分析
数据流分析和控制流分析
过程内分析和过程间分析
4 Go编译器优化
优点:
用户无感知, 重新编译即可获得收益
通用性强
现状:
采用的优化少
编译时间较短, 没有进行复杂的代码分析和优化
编译优化的思路:
场景: 面向后端长期执行任务
Tradeoff: 用编译时间换取更高效的机器码
Beast mode:
函数内联
逃逸分析
默认栈大小调整
边界检查消除
循环展开
函数内联 (Inlining)
函数内联: 将被调用函数的函数体(callee)的副本替换到调用位置(caller)上, 同时重写代码以反映参数的绑定
优点:
消除函数调用开销, 例如传递参数, 保存寄存器等
将过程间分析转化为过程内分析, 帮助其它优化, 例如逃逸分析
缺点:
函数体变大, instruction cache (icache)不友好
编译生成的Go镜像变大
⭐工程经验: 函数内联大多数情况下是正向优化
内联策略:
根据调用和被调用函数的规模决定是否做内联
...
函数内联能多大程度影响性能 - 使用micro-benchmark验证一下
func BenchmarkInline(b *testing.B) {
x := rand.Intn(10)
y := rand.Intn(10)
for i := 0; i < b.N; i++ {
addInline(x, y)
}
}
func addInline(a, b int) int {
return a + b
}
func BenchmarkInlineDisabled(b *testing.B) {
x := rand.Intn(10)
y := rand.Intn(10)
for i := 0; i < b.N; i++ {
addNoInline(x, y)
}
}
//go:noinline
func addNoInline(a, b int) int {
return a + b
}
//go:noineline可以禁止编译器对函数进行内联优化
结果
BenchmarkInline-16 1000000000 0.2431 ns/op
BenchmarkInlineDisabled-16 1000000000 1.235 ns/op
内联提升了5倍性能
Beast Mode
Go函数内联受到的限制较多
语言特性, 例如interface, defer等, 限制了函数内联
内联策略非常保守
Beast Mode: 调整函数内联的策略, 使更多函数被内联
降低函数调用的开销
增加了其他优化的机会: 逃逸分析
开销
Go镜像增加~10%
编译时间增加
逃逸分析
逃逸分析: 分析代码中指针的动态作用域, 指针在任何处可以被访问
大致思路:
从对象分配处出发, 沿着控制流, 观察对象的数据流
若发现指针p在当前作用域s
作为参数传递给其他函数
传递给全局变量
传递给其他的goroutine
传递给已逃逸的指针指向的对象
则指针p指向的对象逃逸出s, 反之则没有逃逸出s
Beast mode: 函数内联拓展了函数边界, 更多对象不逃逸
优化: 未逃逸的对象可以在栈上分配
对象在栈上分配和回收很快: 移动sp
减少在heap上的分配, 降低GC负担
小结
Go编译器优化的问题
Beast mode
函数内联
逃逸分析
通过micro-benchmark 快速验证性能优化
性能收益
总结
性能优化
自动内存管理
Go内存管理
编译器与静态分析
编译器优化
实践
Balanced GC 优化对象分配
Beast mode 提升代码性能
分析问题的方法与解决问题的思路, 不仅适用于Go语言, 其他语言的优化也同样适用