0006.性能优化及自动内存管理 | 青训营笔记
[TOC]
一、课程介绍
1.课程回顾
- 上节课: 高质量编程与性能调优实战
- 高质量编程 编码规范:写出高质量、可维护的代码 性能优化建议
- 性能优化 分析工具---pprof: 采样原理、如何定位性能问题等等 业务优化 基础库优化 ==Go 语言优化==
2.你能学到什么
-
本节课程: 高性能 Go语言发行版==优化==与==落地实践==
-
优化 ==内存管理==优化 ==编译器==优化
-
背景 自动内存管理和 Go内存管理机制 编译器优化的基本问题和思路
-
实践: 字节跳动遇到的性能问题以及优化方案
二、性能优化简介
1.追求极致性能
- 性能优化是什么? 提升软件系统处理能力,==减少不必要的消耗==,充分发掘计算机算力
- 为什么要做性能优化? 用户体验:带来用户体验的提升--让刷抖音更丝滑,让双十一购物不再卡顿 ==资源高效利用:降低成本,提高效率==--很小的优化乘以海量机器会是显著的性能提升和成本节约
2.性能优化的层面
- 业务代码
- SDK
- 基础库
- 语言运行时
- OS
所以我们可以做两个层面的优化。
- 业务层优化
- 语言运行时优化
业务层优化:
- 针对特定场景,具体问题,具体分析
- 容易获得较大性能收益
语言运行时优化:
- 解决更通用的性能问题
- 考虑更多场景
- Tradeoffs
数据驱动:
- 自动化性能分析工具
- pprof
- 依靠数据而非猜测
- 首先优化最大瓶颈
3.性能优化与软件质量
- 软件质量至关重要
- 在保证接口稳定的前提下改进具体实现
- 测试用例: 覆盖尽可能多的场景,方便回归
- 文档: 做了什么,没做什么,能达到怎样的效果
- 隔离: 通过选项控制是否开启优化
- 可观测: 必要的日志输出
4.总结
- 性能优化的基本问题
- 性能优化的两个层面
- 性能优化的可维护性
三、自动内存管理
1.概念
(1)动态内存
程序在运行时根据需求动态分配的内存: malloc()
(2)自动内存管理 (垃圾回收)
由程序语言的运行时系统管理动态内存
- 避免手动内存管理,专注于实现业务逻辑
- 保证内存便用的正确性和安金性: double-free problem, use-after-free problem
(3)三个任务
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
(4)其他
- Mutator: 业务线程,分配新对象,修改对象指向关系
- Collector: GC线程,找到存活对象,回收死亡对象的内存空间
- Serial GC:只有一个 collector
- Parallel GC:支持多个 collectors 同时回收的 GC算法
- Concurrent GC: mutator(s) 和 collector(s) 可以==同时执行==
2.Tracing garbege collection
(1)追踪垃圾回收
对象被回收的条件: 指针指向关系不可达的对象
-
标记根对象 静态变量、全局变量。常量、线程栈等
-
标记: 找到可达对象 求指针指向关系的传递闭包: 从根对象出发,找到所有可达对象
-
清理: 所有不可达对象 将存活对象复制到另外的内存空间 (Copying GC 将死亡对象的内存标记为"可分配”(Mark-sweep GC 移动井整理存活对象 (Mark-compact GC
==根据对象的生命周期,使用不同的标记和清理策略==
(2)分代 GC (Generational GC)
-
分代假说 (Generational hypothesis): most objects die young
-
Intuition:很多对象在分配出来后很快就不再使用了
-
每个对象都有年龄: 经历过GC的次数
-
目的:对年轻和老年的对象,制定不同的GC策略,==降低整体内存管理的开销==
-
不同年龄的对象处于 heap 的不同区域
-
年轻代 (Young generation) 常规的对象分配 由于==存活对象很少==,可以采用copying collection GC吞吐率很高
-
老年代 (Old generation) ==对象趋向于一直活着,反复复制开销较大== 可以采用 mark-sweep collection
(3)引用计数
-
每个对象都有一个与之关联的引用数目
-
对象存活的条件: 当且仅当引用数大于0
-
优点 内存管理的操作被平摊到程序执行过程中 内存管理不需要了解 runtime 的实现细节: C++智能指针 (smart pointer)
-
缺点 维护引用计数的开销较大: 通过==原子操作==保证对引用计数操作的==原子性==和==可见性== 无法回收环形数据结构--weak reference 内存开销: 每个对象都引入的额外内存空间存储引用数目 回收内存时依然可能引发暂停
四、Go内存管理及优化
1.分块
-
目标:为对象在 heap 上分配内存
-
提前将内存分块 调用系统调用mmap()向 OS申请一大块内存,例如4 MB 先将内存划分成大块,例如8KB,称作mspan 再将大块继续划分成==特定大小==的小块,用于对象分配 noscan mspan: 分配不包含指针的对象--GC不需要扫描 scan mspan:分配包含指针的对象--GC需要扫描
-
对象分配: 根据对象的大小,选择最合适的块返回
2.Go内存分配缓存
-
TCMalloc: thread caching
-
每个p 包含一个 mcache用于快速分配,用于为绑定于p上的g分配对象
-
mcache管理一组mspan
-
当mcache中的 mspan 分配完毕,向 mcentral申请带有未分配块的mspan
-
当mspan 中没有分配的对象,mspan 会被缓存在mcentral中,而不是立刻释放并归还给OS
3.Go内存管理优化
-
对象分配是非常高频的操作: 每秒分配GB级别的内存
-
小对象占比较高
-
Go内存分配比较耗时 分配路径长: g -> m -> p -> mcache -> mspan -> memory block -> return pointer pprof: 对象分配的函数是最频繁调用的函数之一
4.我们的优化方案: Balanced GC
-
每个g都邻定一大块内存 (1 KB) ,称作 ==goroutine allocation buffer (GAB)==
-
GAB 用于 noscan 类型的小对象分配: < 128 B
-
使用三个指针维护 GAB: base, end, top
-
Bump pointer (指针碰撞) 风格对象分配 无须和其他分配请求互斥 分配动作简单高效
GAB对于 Go内存管理来说是一个大对象
本质: 将多个小对象的分配合并成一次大对象的分配
问题: GAB 的对象分配方式会导致内存被延迟释放
五、编译器和静态分析
1.编译器的结构
- 重要的系统软件: 识别符合语法和非法的程序 生成正确且高效的代码
- 分析部分 (前端 front end): 词法分析,生成词素 (lexeme) 语法分析,生成语法树 语义分析,收集类型信息,进行语义检查 中间间代码生成,生成 intermediate representation (IR)
- 综合部分 (后端 backend): 代码优化,机器无关优化,生成优化后的 IR 代码生成,生成目标代码
2.静态分析
-
静态分析: ==不执行程序代码==,推导程序的行为 分析程序的性质
-
控制流 (Control flow): 程序执行的流程
-
数据流 (Data flow): 数据在控制流上的传递
-
通过分析控制流和数据流,我们可以知道更多关于程序的性质 (properties)
3.过程内分析和过程间分析
- 过程内分析 (Intra-procedural analysis) 仅在函数内部进行分析
- 过程间分析 (Inter-procedural analysis) 考虑函数调用时参数传递和返回值的数据流和控制流
六、Go编译器优化
1.背景介绍
-
为什么做编译器优化: 用户无感知,重新编译即可获得性能收益 通用性优化
-
现状: 采用的优化少 编译时间较短,没有进行较复杂的代码分析和优化
-
编译优化的思路: 场景: 面向后端长期执行任务 Tradeoff:==用编译时间换取更高效的机器码==
-
Beastmode: ==函数内联== ==逃逸分析== 默认栈大小调落 边界检查消除 循环展开
2.函数内联 (Inlining)
-
内联: 将被调用函数的函数体 (callee) 的副本替换到调用位置 (callen 上,同时雷写代码以反映参数的绑定
-
优点: 消除函数调用开销,例如传递参数、保存寄存器等 ==将过程间分析转化为过程内分析==,帮助其他优化,例如==逃逸分析==
-
缺点: 函数体变大,instruction cache (icache) 不友好 编译生成的Go镜像变大
-
==函数内联能多大程度影响性能?==使用micro-benchmark验证一下
使用micro-benchmark 快速验证和对比性能优化结果
3.逃逸分析
-
逃逸分析: 分析代码中指针的动态作用域: 指针在何处可以被访问
-
大致思路 从对象分配处出发,沿着控制流,观察对象的数据流 若发现指针p 在当前作用域s: 作为参数传递给其他函数 传递给全局变量 传递给其他的 goroutine 传递给已逃逸的指针指向的对象
-
则指针p指向的对象逃逸出s,反之则没有逃逸出s