go内存管理及编译性能优化 | 青训营笔记

111 阅读10分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 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可以感知对象指向关系的改变

image.png

image.png

image.png

image.png

image.png

对于GC算法,评价的指标有4个

  • 安全性 Safety , 不能回收存活对象,这是最基本的要求

  • 吞吐量 Throughput , 程序运行 GC 所需要的时间,(1−GC时间)/程序总运行时间

  • 暂停时间 PauseTime , 能否被业务所感知

  • 内存开销 SpaceOverhead ,GC自身的元数据开销

go中gc用的几种方法

追踪垃圾回收(Tracing garbage collection)

回收指针指向关系不可达的对象

image.png 主要的过程:

标记根对象

  • 静态变量、全局变量、常量、线程栈等 标记:找到可达对象
  • 求指针指向关系的传递闭包,即从根对象触发,找到所有的可达对象 清理:所有的不可达对象
  • 将存活对象复制到另外的内存空间(copying GC)
  • 将死亡对象的内存标记为可分配(mark sweep GC)
  • 移动并整理存活对象(Mark-compact GC)

使用哪种策略主要是根据对象的生命周期

其中第一种策略(copying GC)

image.png

image.png

image.png

分代GC

image.png

年轻代

  • 常规的对象分配
  • 由于存活对象很少,可以采用copying Gc
  • GC吞吐率很高

image.png

老生代

  • 对象趋向于一直活着,反复复制开销较大
  • 可以采用mark-sweep collection

image.png

引用计数(Reference counting)

  • 每个对象都有一个与之关联的引用数目
  • 对象存活的条件:当且仅当引用数大于0

优点:

  • 内存管理的操作被平摊到程序执行过程中

  • 内存管理不需要了解runtime的实现细节

缺点:

  • 维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性

  • 无法回收环形数据结构 内存开销:每个对象都引入的额外内存空间存储引用数目

  • 回收内存时依然可能引发暂停

image.png

还有三色标记法

可以看 golang 垃圾回收gc 详解 - 知乎 (zhihu.com)

(15条消息) 深入浅出GO GC垃圾回收_inthirties的博客-CSDN博客

go内存分配

内存分配的目标:为对象在heap上分配内存

手段:提前分块

image.png

  • 调用系统调用 mmap()向 OS申请一大块内存,例如 4MB

  • 先将内存划分成大块,例如 8KB ,称作 mspan

  • 再将大块继续划分成特定大小的小块,用于对象分配

  • noscan mspan: 分配不包含指针的对象 —— GC 不需要扫描

  • scan mspan: 分配包含指针的对象 —— GC 需要扫描

实际分配的过程就是选择一个合适的块分配

image.png

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、编译器和性能优化

编译器的结构

image.png

分析部分(前端)

  • 词法分析,生成词素
  • 语法分析,生成语法树
  • 语义分析,收集类型信息,进行语义检查
  • 中间代码生成,生成intermediate representation(IR)

综合部分(后端)

  • 代码优化,机器无关优化,生成优化后的IR
  • 代码生成,生成目标代码

静态分析:不执行程序代码,推导程序的行为,分析程序的性质

控制流(Control flow):程序执行的流程

image.png

数据流(Data flow):数据在控制流上的传递

image.png

优化

现状:采用的优化少,编译时间短,没有进行复杂代码的的分析和优化

思路:用编译时间换取更高效的机器码

函数内联

内联:将被调用的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定

优点:

  • 消除函数调用开销,例如参数传递、保存寄存器等
  • 将过程间分析转化为过程内分析,帮助其他优化,如逃逸分析 性能

image.png

缺点

  • 函数体变大,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的值,所以发生了逃逸,会把内存分配到堆中。