04Go语言内存管理和编译器优化 | 青训营笔记

189 阅读9分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第5天

课程上 juejin.cn/course/byte…

课程下 juejin.cn/course/byte…

讲师: 张逸飞

相关术语

 自动内存管理
     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语言, 其他语言的优化也同样适用