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

133 阅读9分钟

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

重点概览

  • 自动内存管理
  • Go内存管理及优化
  • 编译器和静态分析
  • Go编译器优化

名词汇总

自动内存管理 Auto memory management: 自动内存管理 Grabage collectionn: 垃圾回收 Mutator: 业务线程 Collector: GC线程 Concurrent GC: 并发GC Parallel GC: 并行GC Tracing garbage collection: 追踪垃圾回收 Copying GC: 复制对象GC Mark-sweep GC: 标记-清理GC Mark-compact GC: 标记-压缩GC Reference counting: 引用计数 Generational GC: 分代GC Young generation: 年轻代 Old generation: 老年代 Go内存管理及优化 TCMalloc mmap()系统调用 scan object和noscan object mspan, mcache, mentral Bump-pointer object allocation: 指针碰撞风格的对象分配 编译器和静态分析 词法分析 语法分析 语义分析 Intermediate representation(IR): 中间表示 代码优化 代码生成 Control flow: 控制流 Data flow: 数据流 Intra-procedural analysis: 过程内分析 Inter-procedural analysis: 过程间分析 Go编译器优化 Function inlining: 函数内联 Escape analysis: 逃逸分析

自动内存管理

动态内存

程序在运行时根据需求动态分配的内存: malloc()

自动内存管理(垃圾回收): 由程序语言的运行时系统管理动态内存。避免手动内存管理, 专注于实现业务逻辑。 保证内存使用的正确性和安全性( 避免double-free problem: 释放了两次 和use-after-free problem: 在释放后使用 )

三个任务

  • 为新对象分配空间
  • 找到存活对象
  • 回收死亡对象的内存空间

相关概念 Mutator: 业务线程, 分配新对象, 修改对象指向关 Collector: GC线程, 找到存活对象, 回收死亡对象的内存空间 Serial GC: 只有一个collector 特点: 会有暂停, 只有一个collector Parallel GC: 支持多个collectors同时回收的GC算法 特点: 会有暂停, 暂停时有多个collector Concurrent GC: mutatos(s)和collector(s)可以同时执行 特点: 没有暂停 关键: 必须感知对象执行关系的改变 (暂停就是STP: Stop The World)

评价GC算法:

安全性(Safety): 不能回收存活对象 (基本要求)

吞吐率(Throughput): 1 - GC时间/程序执行总时间

暂停时间(Pause time): STW

内存开销(Space overhead)

追踪垃圾回收 (Tracing garbage collection)

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

  1. 标记根对象 静态变量, 全局变量, 常量, 线程栈等
  2. 标记: 找到可达对象 求指针指向关系的传递闭包: 从根对象出发, 找到所有可达对象
  3. 清理: 所有不可达对象 法1: 将存活对象复制刀另外的内存空间 (Copying GC) 法2: 将死亡对象的内存标记为"可分配" (Mark-sweep GC) 法3: 移动并整理存活对象("原地"整理) (Mark-compact GC)

分代GC (Generational GC)

分代假说(Generational hypothesis): most objects die young Intuition: 很对对象在分配出来后很快就不再使用了 每个对象有年龄: 经历过GC的次数 目的: 对年轻和老年的对象, 制定不同的GC策略, 降低整体内存管理的开销 不同年龄的对象处于heap的不同区域 年轻代(Young generation) 常规的对象分配 由于存活对象很少, 可以采用copying collection GC吞吐率很高 (即GC花的时间少) 老年代(Old generation) 对象趋向于一直活着, 反复复制开销很大 可以采用mark-sweep collection 碎片多了可以来个mark-compact

引用计数(Reference counting)

对象都有一个与之关联的引用数目 对象存活的条件: 当且仅当引用数大于0 优点: 内存管理的操作被平铺到程序执行过程中 内存管理不需要了解runtime的实现细节 (如C++ 智能指针smart pointer) 缺点: 维护引用计数的开销较大 (因为要通过原子操作保证对引用计数操作的原子性和可见性) 无法回收环形数据结构 (解决方法: weak reference) 内存开销: 每个对象都引入的额外内存空间存储引用数目 回收内存时依然可能引发暂停

Go内存管理及优化

Go内存管理

分块

目标: 为对象在heap上分配内存 提前将内存分块 调用系统调用mmap()向OS申请一大块内存, 例如4MB 先将内存划分成大块, 例如8KB, 称作mspan 再将大块内存继续划分成特定大小的小块, 用于对象分配 noscan mspan: 分配不包含指针的对象 (GC不需要扫描) scan mspan: 分配包含指针的对象 (GC需要扫描) 对象分配: 根据对象的大小, 选择最合适的块返回

缓存

借鉴了TCMalloc (TC: thread caching) 每个p包含一个mcache用于快速分配, 用于为绑定于p上的g分配对象 mcache管理一组mspan 当mcache中的mspan分配完毕, 向mcentral申请带有为分配块的mspan 当mspan中没有分配的对象, mspan会被缓存在mcentral中, 而不是立即释放并归还给OS

Go内存管理优化

对象分配是非常高频的操作: 每秒分配GB级别的内存 小对象占比高 Go内存分配比较耗时 分配路径长: g->m->p->mcache->mspan->memory block->return pointer

优化方案 Balanced GC

每个g绑定一大块内存(1KB), 称作goroutine allocation buffer(GAB) GAB用于noscan类型的小对象分配: <128B 使用三个指针维护GAB: base, end, top Bump pointer(指针碰撞)风格对象分配: 无须和其它分配请求互斥 分配动作简单高效

GAB对象Go内存管理来说是一个大对象 本质: 将多个小对象的分配合并成一次大对象的分配 问题: GAB的对象分配方式会导致内存被延迟释放 解决方案: 移动GAB中存活对象 当GAB总大小超过一定阈值时, 将GAB中存活的对象复制到另外分配的GAB中 原先的GAB可以释放, 避免内存泄露 本质: 用copying GC的算法管理小对象

编译器和静态分析

编译器

结构

源代码 ↓词法分析器 词素(lexeme) ↓语法分析器 抽象语法树(AST) ↓语义分析器 (decorated) AST ↓中间表示 IR ↓代码优化 IR ↓代码生成 目标代码 分析部分 (前端front end) 词法分析 语法分析 语义分析 中间代码生成, 生成IR 综合部分 (后端back end) 代码优化, 机器无关优化, 生成优化后的IR 代码生成, 生成目标代码 复制代码

静态分析

静态分析: 不执行程序代码, 推导程序的行为, 分析程序的性质 控制流(Control flow): 程序执行流程 数据流(Data flow): 数据在控制流上的传递 过程内分析(Intra-procedural analysis): 仅在函数内部进行分析 过程间分析(Inter-procedural analysis): 考虑函数调用时参数传递和返回值的数据流和控制流

Go编译器优化

优点: 用户无感知, 重新编译即可获得收益 通用性强 现状: 采用的优化少 编译时间较短, 没有进行复杂的代码分析和优化 编译优化的思路: 场景: 面向后端长期执行任务 Tradeoff: 用编译时间换取更高效的机器码 Beast mode: 函数内联 逃逸分析 默认栈大小调整 边界检查消除 循环展开

函数内联 (Inlining)

函数内联: 将被调用函数的函数体(callee)的副本替换到调用位置(caller)上, 同时重写代码以反映参数的绑定 优点: 消除函数调用开销, 例如传递参数, 保存寄存器等 将过程间分析转化为过程内分析, 帮助其它优化, 例如逃逸分析 缺点: 函数体变大, instruction cache (icache)不友好 编译生成的Go镜像变大 ⭐工程经验: 函数内联大多数情况下是正向优化 内联策略: 根据调用和被调用函数的规模决定是否做内联 ...

  
  函数内联能多大程度影响性能 - 使用micro-benchmark验证一下
  func BenchmarkInline(b *testing.B) {
      x := rand.Intn(10)
      y := rand.Intn(10)
      for i := 0; i < b.N; i++ {
          addInline(x, y)
      }
  }
  
  func addInline(a, b int) int {
      return a + b
  }
  
  func BenchmarkInlineDisabled(b *testing.B) {
      x := rand.Intn(10)
      y := rand.Intn(10)
      for i := 0; i < b.N; i++ {
          addNoInline(x, y)
      }
  }
  
  //go:noinline
  func addNoInline(a, b int) int {
      return a + b
  }
  
  //go:noineline可以禁止编译器对函数进行内联优化
  结果
  BenchmarkInline-16              1000000000               0.2431 ns/op
  BenchmarkInlineDisabled-16      1000000000               1.235 ns/op
  内联提升了5倍性能
 复制代码

Beast Mode Go函数内联受到的限制较多 语言特性, 例如interface, defer等, 限制了函数内联 内联策略非常保守 Beast Mode: 调整函数内联的策略, 使更多函数被内联 降低函数调用的开销 增加了其他优化的机会: 逃逸分析 开销 Go镜像增加~10% 编译时间增加

逃逸分析

逃逸分析: 分析代码中指针的动态作用域, 指针在任何处可以被访问 大致思路: 从对象分配处出发, 沿着控制流, 观察对象的数据流 若发现指针p在当前作用域s 作为参数传递给其他函数 传递给全局变量 传递给其他的goroutine 传递给已逃逸的指针指向的对象 则指针p指向的对象逃逸出s, 反之则没有逃逸出s

Beast mode: 函数内联拓展了函数边界, 更多对象不逃逸 优化: 未逃逸的对象可以在栈上分配 对象在栈上分配和回收很快: 移动sp 减少在heap上的分配, 降低GC负担

总结

性能优化 自动内存管理 Go内存管理 编译器与静态分析 编译器优化 实践 Balanced GC 优化对象分配 Beast mode 提升代码性能 分析问题的方法与解决问题的思路, 不仅适用于Go语言, 其他语言的优化也同样适用