这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记
高性能Go语言发行版优化与落地实践
性能优化是什么?
提高软件系统处理能力,减少不必要的消耗,充分发掘计算机能力
性能优化的好处:
- 用户体验:带来用户体验的提升——让抖音更加丝滑,让双十一不再卡顿
- 资源高效利用:降低成本,提高效率
性能优化的层面
- 业务代码
- SDK
- 基础库
- 语言运行时
- OS
业务层优化
- 准对特定场景,具体问题,具体分析,比如说用自动化性能分析工具pprof
- 容易获得较大的收益
语言运行时层面优化:
对于go来讲是对Go的SDK进行优化
- 解决更通用的性能问题
- 考虑更多场景
- Tradeoffs
性能优化与软件质量
- 软件质量至关重要
- 在保证接口稳定的前提下改进具体实现(指的是:我们现在的GoSDK,已经有了一个存在的接口,有用户已经在使用了之前了接口的行为,我们加入了一个新的接口的时候,我们要保证接口实现了什么功能,保证接口的稳定)
- 测试用例:覆盖尽可能多的场景,方便回归测试
- 文档:做了什么,没做什么,能达到怎样的效果(给用户)
- 隔离:通过选项控制是否开启优化
- 可观测:必要的日志输出
自动内存管理
-
动态内存:
程序在运行时根据需求分配动态分配的内存:malloc()
-
自动内存管理(垃圾回收):由程序语言的运行时系统管理动态内存
避免手动管理内存,专注于实现业务逻辑
保证内存使用的正确性和安全性
-
三个任务:
为新对象分配内存空间
找到存活对象
回收死亡对象的内存空间
自动内存管理的几个概念:
-
Concurrently是什么意思
同时的
-
Mutator threads指的是什么
Mutator:业务线程,分配新对象,修改对象的指向关系
Collector:GC线程,找到存活对象,回收死亡对象的内存空间
垃圾回收器,和Java类似
Serial GC:单线程,只有一个collector
Parallel GC:支持多线程的GC算法
Concurrent GC:Mutator 和 Collector 可以同时执行(个人理解是:不用Stop the World)
其中,Concurrent GC的Collectors必须感知对象指向关系的改变
评价GC算法
- 安全性:不能回收存活的对象,这是基本要求
- 吞吐率:时间程序执行总时间,指的是花在GC上的时间
- 暂停时间:stop the World,业务是否感知
- 内存开销:GC元数据开销
判断对象是否为垃圾的两种算法
和Java类似
- 引用计数法
- 追踪垃圾回收(可达性分析法)
追踪垃圾回收法
跟Java的可达性分析法一模一样
-
对象回收的条件:指针指向关系不可达的对象
-
标记根对象 GCRoot
静态变量、全局变量、常量、线程栈等作为GCRoot
流程:
标记:找到可达对象
清理:所有不可达对象
- 将存活对象复制到另外的内存空间(Copying GC) 复制算法,保证空间连续性,一般用于young区
- 将死亡对象的内存空间标记为可分配(Mark-sweep GC)标记清除算法,会产生碎片
- 移动并整理存活对象(Mark-compact GC)标记整理法
分代GC
根据对象的声明周期,使用不同的标记和清理策略
熟悉JVM的会发现,几乎一模一样
- 分代假说,大部分的对象都是朝生夕死的
- Intuition:很对对象在分配出来后很快就不再使用了
- 每个对象都有年龄,经过GC的次数
- 目的:对年轻和年老的对象,制定不同发GC策略,降低整体内存的管理开销
- 不同年龄的对象处于heap的不同区域,young区 和 old 区
young区:
- 常规的对象分配,一开始在young区分配空间
- 由于存活的对象很少,一般使用复制算法,以为存活的少复制的对象就少,GC吞吐率很高
Old区:
- 对象趋于一直存活,反复复制开销较大
- 一般使用标记整理算法或者标记清除算法
引用计数法
- 每个对象都有一个与之关联的引用数目
- 对象存活的条件:当且仅当引用数大于0
优点:
- 内存管理的操作被平摊到程序执行过程中
- 内存管理不需要了解runtime的实现细节:C++的智能指针
缺点:
- 维护引用技术的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
- 存在循环引用的情况,无法回收环形数据结构
- 内存开销:每个对象都引入额外的空间村存储引用数目
- 虽然内存管理的操作被平摊到程序执行过程中,但回收内存时仍可能引发暂停stop the world
循环引用的情况:
PS: 其实Go使用的垃圾回收算法是:三色标记法
三色标记法是标记清除法的进阶
可以看这篇博客
图解Golang垃圾回收机制! - 知乎 (zhihu.com)
Go内存管理及优化
分块
-
目标:为对象在heap上分配内存
-
提前将内存分块
具体过程:
- 调用系统调用mmap()向OS申请一块大内存mcache,例如4MB
- 先将内存划成大块,例如8KB,称作
mspan,这样大块内存中就包含了很多mspan - 再将mspan继续划分成特定大小的小块,用于对象分配,这样mspan中就有了很多小块
mspan分为两种:
noscan mspan:分配不包含指针的对象——GC不需要扫描,可以理解为出度为0,没有包含指针引用到其他对象sacn mspan:分配包含指针的对象——GC需要扫描
-
对象分配
根据对象大小,在mspan中选择最合适的小块返回
这边需要先理解mspan的概念,方便后续对缓存的理解
缓存
-
TCMalloc:thread caching
-
每个p包含一个mache用于快速分配,用于绑定于p上的g分配对象
-
mcache管理一组小块不一样的mspan
当需要为对象分配内存时,会根据对象的大小找到一个嘴合适的mspan,如果有空余分块,就直接返回,就完成了一次对象的分配,但会有下面这种情况
-
当mcache中的某个mspan分配完毕,向mcentral中申请带有未分配块的maspan。mcentral表示下一级的缓存
-
当mspan中没有分配的对象,mspan会被缓存在mcentral中,而不是立刻释放归还给OS,以便之后对象分配
mspan中没有对象分配的情况
Go内存管理优化
-
对象分配是非常高频的操作:每秒分配GB级别的内存
-
通过pprof发现,小对象占比比较高
-
Go内存分配比较耗时
分配路径长:g -> m -> p ->mcache -> mspan -> memory block -> return pointer
pprof:对象分配的函数时最频繁调用的函数之一
字节对Go内存管理的优化
Balance GC
每一个 g 都当定一个大块内存(1KB),称作 goroutine allocation buffer (GAB)
GAB用于
noscan类型的小对象分配:< 128 B,不满足这样的对象还是走正常的分配路径使用三个指针维护GAB:base,end,top
Bump pointer(指针碰撞)风格分配对象
分配对象时对每个协程来说的,所以无需和其他分配请求互斥
分配动作简单高效,只需要将base指针往后移动
个人理解:
GAB就是一个大的对象,走之前的Go内存分配路径,但对于我们程序中使用到的对象,是分配在已经分配好的GAB中,这时候我们创建noscan类型的对象就在GAB中分配,不需要GC扫描,而整个GAB相当于scan类型的对象,当GAB中没有对象被引用的时候,才会被回收
-
GAB对于Go内存管理来说是一个大对象
-
本质:将多个小对象的分配合成一次大对象的分配
-
缺点:GAB的对象分配 方式会导致内存被延迟释放,只要GAB中还有一个对象被引用,那么这整块GAB就无法被回收,会造成浪费
解决方法:
类似JVM中yound区,将GAB中存活的对象复制到Survivor GAB中,本质还是复制算法。Survivor GAB不属于任何一个协程
编译器和静态分析
编译器结构
重要的系统软件
识别符合语法和非法的程序
生成正确且高效的代码
分析部分(前端 front end)
词法分析,生成词素(lexme)
语法分析,生成语法树
语义分析,收集类型信息,进行语义检查
中间代码生成,生成IR
综合部分(后端 back end)
代码优化,机器无关优化,生成优化后的IR
代码生成,生成目标大妈
静态分析
静态分析:不执行代码,推导程序的行为,分析程序的性质
控制流:程序执行流程
数据流:数据在控制流上的传递
通过分析控制流和数据流,我们可以知道更多关于程序的性质
再根据这些性质优化代码
TBD
睡大觉,小命要紧,而且编译原理不大会,太菜了。
总结:Go内存管理还是和Java非常类似的