go语言内存管理与编译器优化| 青训营笔记

84 阅读5分钟

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

自动内存管理

  • 动态内存: 程序在运行时根据需求动态分配的内存: malloc()
  • 自动内存管理(垃圾回收): 由程序语言的运行时系统管理动态内存
    • 避免手动内存管理
    • 保证内存使用的正确性和安全性: double-free problem, use-after-free problem
  • 三个任务:
    • 为新对象分配空间
    • 找到存活对象
    • 回收死亡对象的内存空间

相关概念

  • Mutator: 业务线程, 分配新对象, 修改对象指向关系

  • Collector: GC(garbage collector)线程, 垃圾回收线程

    • Serial GC 只有一个collector
    • Parallel GC 多个collectors同时回收
    • Concurrent GC: mutator 和 collector 可以同时执行

image-20230130120206652.png

  • 评价GC算法
    • 安全性(safety): 不能回收存活的对象
    • 吞吐率(throughput): 花在GC上的时间
    • 暂停时间(pause time): 业务是否感知
    • 内存开销(space overhead): GC元数据开销

追踪垃圾回收

对象被回收的条件: 指针指向关系不可达的对象

  • 标记根对象: 静态变量, 全局变量, 常量, 线程栈等
  • 标记: 从根对象出发, 找到所有可达对象
  • 清理: 所有不可达对象
    • 将存活对象复制到另外的内存空间(copying gc)
    • 将死亡对象的内存标记为可分配(mark-sweep gc)
    • 移动并整理存活对象(mark-compact gc)

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

分代GC(generational GC)

分代假说(generational hypothesis): most objects die young 很多对象在分配出来后很快就不再使用了

针对年轻和老年的对象, 制定不同的GC策略, 降低整体内存管理的开销

  • 年轻代(young generation)
    • 常规的对象分配
    • 存活对象很少, 可以采用copying gc
  • 老年代(old generation)
    • 对象趋向于一直活着, 反复复制开销大
    • 使用mark-sweep gc

引用计数

每个对象都有一个与之关联的引用数目

对象存活的条件: 当且仅当引用数大于0

优点:

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

缺点:

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

go内存管理

分块

为对象在heap上分配内存

  • 调用系统调用mmap()向os申请一大块内存
  • 将内存划分为大块如8KB, 称为mspan
    • noscan mspan: 分配不包含指针的对象, gc不需要扫描
    • scan mspan: 分配包含指针的对象, gc需要扫描
  • 大块继续划分为特定大小的块

根据对象的大小,选择最合适的块返回

缓存

每个p包含一个mcache用于快速分配, 用于为绑定于p上的g分配对象(gmp模型)

  • mcache管理一组mspan
  • 当mcache中的mspan分配完毕, 向mcentral申请带有未分配块的mspan
  • 当mspan中没有分配的对象, mspan会被缓存在mcentral中, 而不是立刻释放归还os

image-20230130153527750.png

go对象分配的性能问题

对象分配是非常高频的操作, 每秒分配GB级别的内存

  • 小对象占比高
  • 分配路径长: g->m->p->mcache->mspan->memory block->return pointer

编译器和静态分析

编译器

image-20230130153628659.png

分析部分:

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

综合部分:

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

静态分析

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

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

通过分析控制流和数据流, 可以知道关于程序更多的性质, 进行优化

  • 过程内分析(intra-procedural analysis)

  • 过程间分析(inter-procedural analysis)

    考虑过程调用时参数传递和返回值的数据流和控制流, 需要通过分析控制流和数据流进行

go编译器优化

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

函数内联, 逃逸分析, 默认栈大小调整, 边界检查消除, 循环展开...

函数内联(inlining)

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

优点:

  • 消除函数调用开销
  • 将过程间分析转化为过程内分析, 帮助其他优化

缺点:

  • 函数体变大, instruction cache不友好
  • 编译生成的go镜像变大

逃逸分析

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

  • 从对象分配处出发, 沿着控制流, 观察对象的数据流
  • 如果发现指针p在当前作用域
    • 作为参数传递给其他函数
    • 传递给全局变量
    • 传递给其他的goroutine
    • 传递给已逃逸的指针指向的对象
  • 则指针p指向的对象逃逸出s

优化: 未逃逸的对象可以在栈上分配