Go 的内存管理| 青训营笔记

90 阅读41分钟

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

性能优化

业务层优化

  • 针对特定场景,具体问题,具体分析
  • 比较容易获得较大的性能收益

语言运行时优化

  • 解决更通用的性能问题
  • 考虑更多场景
  • Tradeoffs

数据驱动:

  • 自动化性能分析工具——pprof
  • 依靠数据而非猜测,实事求是
  • 首先优化最大瓶颈

SDK是什么?

辅助开发某一类软件的相关文档、范例和工具的集合都可以叫做SDK(Software Development Kit)。

自动内存管理

所谓自动内存管理,其实就是指垃圾回收,在 Go 中,程序在运行时根据需求动态分配的内存(即动态内存)会被纳入自动内存管理的范畴。

通过自动内存管理,我们可以避免手动释放内存,将注意力专注在业务逻辑,同时还可以避免发生内存安全问题(诸如 内存重复释放问题 double-free problem 或是 内存释放后使用问题 use-after-free problem)。

一个垃圾回收周期大致有三个任务:为新对象分配空间,找到存活对象,回收死亡对象的内存空间。

垃圾回收中的相关概念:

  • Mutator:业务线程,分配新对象,修改对象指向关系
  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间
  • Serial GC:只有一个collector
  • Parallel GC:支持多个collectors同时回收的GC算法
  • Concurrent GC:mutator(s) 和 collector(s) 可以同时执行

GC的评价指标:

  • 安全性(Safety):指垃圾回收器不应回收存活的对象;基本要求
  • 吞吐率(Throughput):指垃圾回收器花在 GC 上的时间占程序执行总时间的比率;花在GC上的时间
  • 暂停时间(Pause time):指垃圾回收导致业务线程挂起(暂停)的时间(GC 导致的暂停被称为 stop the world, STW)业务是否感知
  • 内存开销(Space overhead):指垃圾回收器元数据占用的内存开销;GC元数据开销

追踪垃圾回收

其实就是可到达性算法,GC root指针指向关系不可达的对象被视为垃圾可以被回收,也是Java目前在使用的算法。

  1. 首先,标记根对象,这些根对象可能包括静态变量,全局变量,常量,线程栈等;
  2. 然后,从根对象触发,找到所有引用根对象的可达对象
  3. 最后,清理所有不可达对象,这分为三个步骤:将存活对象复制到另外的内存空间(Copying GC),将死亡对象的内存标记为"可分配"(Mark-sweep GC),移动并整理存活对象(Mark-compact GC)。

根据对象的生命周期,使用不同的标记和清理策略。

分代GC

Java语言与Go一样都支持分代回收,将正在使用的对象分为新生代和老年代,采用不同的机制判断是否需要回收。

大多数对象在很短的生命周期内就会死亡,分配出来后很快就不再使用了。通过为年轻和年老(经历过 GC 的次数越多则越老,反之越年轻)的对象指定不同的 GC 策略,降低整体内存管理的开销。

  • 新生代
    • 常规的内存分配
    • 由于存活对象很少,可以采用copying GC
    • GC吞吐率很高
  • 老年代
    • 对象趋向于一直活着,反复复制开销较大
    • 可以采用mark-sweep GC

引用计数法

为每一个对象维护一个与之关联的引用数目,对象每被引用一次计数加一,释放一次计数减一,当且仅当引用数大于 0 时,该对象才会被标记为存活,否则,对象会被回收。

优点

  • 内存管理的操作被平摊到程序执行过程中
  • 内存管理不需要了解runtime的实现细节:C++智能指针(smart pointer)

缺点

  • 维护引用计数的开销较大:通过原子操作保证对引用计数的原子性可见性
  • 无法回收环形数据结构——weak reference
  • 内存开销:每个对象都引入的额外内存空间存储引用数目
  • 回收内存时依然可能引发暂停

Go内存管理及分化

分块

可以通过系统调用(mmap())提前向操作系统申请一大块内存,然后再不断将内存分配成特定大小的小块,用于对象分配;将内存分配为包含指针的大块(scan mspan)和不包含指针的大块(noscan mspan)来有针对性地进行 GC。

缓存

通过维护 mcache 管理一组 mspan 加快内存分配效率,避免重复向操作系统申请内存。

Go内存优化管理

  • 对象分配是非常高频的操作:每秒分配GB级别的内存
  • 空间较小的对象占比比较高
  • Go内存分配比较耗时
    • 分配路径长:g -> m -> p -> mcache -> mspan -> memory block -> return pointer
    • pprof:对象分配的函数是最频繁调用的函数之一

Balanced GC:

  • 每个g都绑定一大块内存(1KB),称作goroutine allocation buffer (GAB)
  • GAB用于noscan类型的小对象分配:< 128B
  • 使用三个指针维护GAB:base基地址, end结束地址, top当前地址
  • Bump pointer(指针碰撞)风格对象分配:
  1. 无需和其他分配请求互斥
  2. 分配动作简单高效(移动top指针)

编译器和静态分析

重点在于后端部分,包括:

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

静态分析

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

常见的两种分析

  • 控制流:程序执行的流程
  • 数据流:数据在控制流上的传递

从而对应了两种方式:

  • 过程内分析:仅在函数内部进行分析
  • 过程间分析:考虑过程调用时参数传递和返回值的数据流和控制流

对于Java而言,编译是把源文件编译为JVM可以识别的字节码,而不是机器码,这里是与Go不一样的。

编辑器优化

函数内联

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

优点

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

缺点

  • 函数体变大,instruction cache不友好(大概就是会占用很多的意思)
  • 编译生成的Go镜像变大(时间换空间的感觉)

逃逸分析

分析代码中指针的动态作用域: 指针在何处可以被访问。

大致思路 

  • 从对象分配处出发,沿着控制流,观察对象的数据流 
  • 若发现指针 p 在当前作用域 s: 
  1. 作为参数传递给其他函数 
  2. 传递给全局变量 
  3. 传递给其他的 goroutine 
  4. 传递给已逃逸的指针指向的对象
  • 则指针 p 指向的对象逃逸出 s,反之则没有逃逸出 
  • Beast mode: 函数内联拓展了函数边界,更多对象不逃逸