这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天,主要学习go的内存管理和性能优化部分的知识。
1、内存管理
自动内存管理
程序在运行时常常会根据需求动态分配内存,比如 maloc/newm。而这些动态分配的内存如果不能够被很好的释放,那么就可能会导致严重的内存泄漏的问题。
而自动内存管理(垃圾回收,又称 GC)这一机制,就是一种通过程序语言的运行时系统来自动管理动态内存的机制,避免了程序员手动管理内存,使程序员只要关注业务逻辑,保证了程序的 安全性和正确性 。否则会发生一些问题如:double-free problem,use-after-free problem
GC 的主要任务有三个:
- 为新对象分配内存空间
- 找到存活对象
- 回收死亡对象的内存空间
go中的线程可以分为两类:业务线程和GC线程
-
Mutator:业务线程,分配新对象,修改对象指向关系
-
Collector:GC线程,找到存活对象,回收死亡对象的内存空间
-
Serial GC:只有一个collector
-
Parallel GC:支持多个collectors同时回收的GC算法
-
Concurrent GC: mutator(s)和collector(s)可以同时执行,因此需要collectors可以感知对象指向关系的改变
对于GC算法,评价的指标有4个
-
安全性 Safety , 不能回收存活对象,这是最基本的要求
-
吞吐量 Throughput , 程序运行 GC 所需要的时间,(1−GC时间)/程序总运行时间
-
暂停时间 PauseTime , 能否被业务所感知
-
内存开销 SpaceOverhead ,GC自身的元数据开销
go中gc用的几种方法
追踪垃圾回收(Tracing garbage collection)
回收指针指向关系不可达的对象
主要的过程:
标记根对象
- 静态变量、全局变量、常量、线程栈等 标记:找到可达对象
- 求指针指向关系的传递闭包,即从根对象触发,找到所有的可达对象 清理:所有的不可达对象
- 将存活对象复制到另外的内存空间(copying GC)
- 将死亡对象的内存标记为可分配(mark sweep GC)
- 移动并整理存活对象(Mark-compact GC)
使用哪种策略主要是根据对象的生命周期
其中第一种策略(copying GC)
分代GC
年轻代
- 常规的对象分配
- 由于存活对象很少,可以采用copying Gc
- GC吞吐率很高
老生代
- 对象趋向于一直活着,反复复制开销较大
- 可以采用mark-sweep collection
引用计数(Reference counting)
- 每个对象都有一个与之关联的引用数目
- 对象存活的条件:当且仅当引用数大于0
优点:
-
内存管理的操作被平摊到程序执行过程中
-
内存管理不需要了解runtime的实现细节
缺点:
-
维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
-
无法回收环形数据结构 内存开销:每个对象都引入的额外内存空间存储引用数目
-
回收内存时依然可能引发暂停
还有三色标记法
可以看 golang 垃圾回收gc 详解 - 知乎 (zhihu.com)
(15条消息) 深入浅出GO GC垃圾回收_inthirties的博客-CSDN博客
go内存分配
内存分配的目标:为对象在heap上分配内存
手段:提前分块
-
调用系统调用 mmap()向 OS申请一大块内存,例如 4MB
-
先将内存划分成大块,例如 8KB ,称作 mspan
-
再将大块继续划分成特定大小的小块,用于对象分配
-
noscan mspan: 分配不包含指针的对象 —— GC 不需要扫描
-
scan mspan: 分配包含指针的对象 —— GC 需要扫描
实际分配的过程就是选择一个合适的块分配
mspan:Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表.
mcache:每个goroutine都会绑定一个mcache,本地缓存可用的mspan资源,这样就可以直接给Goroutine分配,因为不存在多个Goroutine竞争的情况,所以不会消耗锁资源。mcache在初始化的时候是没有任何mspan资源的,在使用过程中会动态地从mcentral申请,之后会缓存下来。当对象小于等于32KB大小时,使用mcache的相应规格的mspan进行分配。
mcentral:为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当goroutine的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取. mcentral被所有的goroutine共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源。
empty表示这条链表里的mspan都被分配了object,或者是已经被cache取走了的mspan,这个mspan就被那个工作线程独占了。而nonempty则表示有空闲对象的mspan列表。每个central结构体都在mheap中维护.
简单说下mcache从mcentral获取和归还mspan的流程:
- 获取 加锁;从nonempty链表找到一个可用的mspan;并将其从nonempty链表删除;将取出的mspan加入到empty链表;将mspan返回给工作线程;解锁。
- 归还 加锁;将mspan从empty链表删除;将mspan加入到nonempty链表;解锁。
mheap:代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存。
当mcentral没有空闲的mspan时,会向mheap申请。而mheap没有资源时,会向操作系统申请新内存。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。
同时我们也看到,mheap中含有所有规格的mcentral,所以,当一个mcache从mcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan。
分配对象
Go的内存分配器在分配对象时,根据对象的大小,分成三类:
-
小对象(小于等于16B):使用mcache的tiny分配器分配;
-
一般对象(大于16B,小于等于32KB):首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;如果mcache没有相应规格大小的mspan,则向mcentral申请;如果mcentral没有相应规格大小的mspan,则向mheap申请;如果mheap中也没有合适大小的mspan,则向操作系统申请
-
大对象(大于32KB):直接从mheap上分配
2、编译器和性能优化
编译器的结构
分析部分(前端)
- 词法分析,生成词素
- 语法分析,生成语法树
- 语义分析,收集类型信息,进行语义检查
- 中间代码生成,生成intermediate representation(IR)
综合部分(后端)
- 代码优化,机器无关优化,生成优化后的IR
- 代码生成,生成目标代码
静态分析:不执行程序代码,推导程序的行为,分析程序的性质
控制流(Control flow):程序执行的流程
数据流(Data flow):数据在控制流上的传递
优化
现状:采用的优化少,编译时间短,没有进行复杂代码的的分析和优化
思路:用编译时间换取更高效的机器码
函数内联
内联:将被调用的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定
优点:
- 消除函数调用开销,例如参数传递、保存寄存器等
- 将过程间分析转化为过程内分析,帮助其他优化,如逃逸分析 性能
缺点
- 函数体变大,instruction cache(icache) 不友好
- 编译生成的go镜像变大
逃逸分析
通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸.
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。也就是说,通过逃逸分析,将对象对象合理地分配到它应该待的地方。
编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。
变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上。
分析逃逸分析的基本思路:
-
从对象分配处出发,沿着控制流,观察数据流。若发现指针 p 在当前作用域:
- 作为参数传递给其他函数
- 传递给全局变量
- 传递给其他的 goroutine
- 传递给已逃逸的指针指向的对象
-
则指针 p 逃逸,反之则没有逃逸.
优化:未逃逸出当前函数的指针指向的对象可以在栈上分配
- 对象在栈上分配和回收很快:移动sp 即可完成内存的分配和回收;
- 减少在堆上分配对象,降低 GC 负担。
ps:栈和堆的区别
先聊聊Go的「堆栈」,再聊聊Go的「逃逸分析」。 - 知乎 (zhihu.com)
逃逸分析举例
参数是interface类型
package main
import "fmt"
func main() {
a := 666
fmt.Println(a)
}
因为Println(a ...interface{})的参数是interface{}类型,编译期无法确定其具体的参数类型,所以内存分配到堆中。
变量在函数外部有引用
package main
func test() *int {
a := 10
return &a
}
func main() {
_ = test()
}
变量a在函数外部存在引用。
我们来分析一下执行过程:当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。如果这时外部通过引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存。
为了避免上述非法内存的情况,在这种情况下变量的内存分配必须分配到堆上。
变量内存占用较大
package main
func test() {
a := make([]int, 10000, 10000)
for i := 0; i < 10000; i++ {
a[i] = i
}
}
func main() {
test()
}
我们定义了一个容量为10000的int类型切片,发生了逃逸,内存分配到了堆上(heap)
变量大小不确定时
package main
func test() {
l := 1
a := make([]int, l, l)
for i := 0; i < l; i++ {
a[i] = i
}
}
func main() {
test()
}
发生了逃逸,分配到了heap堆中。
原因是这样的:
我们虽然在代码段中给变量 l 赋值了1,但是编译期间只能识别到初始化int类型切片时,传入的长度和容量是变量l,编译期并不能确定变量l的值,所以发生了逃逸,会把内存分配到堆中。