后端 Go 内存分配与编译器优化 day 6 | 青训营笔记

95 阅读4分钟

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

Go 内存分配

分块

  1. 先使用mmap向系统分配一大块连续内存空间,
  2. 将分配到的内存空间切分成固定大小的块(如8K),称作 mspan
  3. 将不同mspan用于固定长度的分配(如8字节, 16字节等)
  4. noscan mspan: 分配不包含指针的对象, GC不需要扫描
  5. sacn mspan: 分配包含指针的对象, GC需要扫描
  6. 分配对象时, 向上取整找到符合的mspan进行分配

缓存

Pasted image 20230119120825.png go的内存分配借鉴了TCMalloc的thread caching思想

  • 每个p(处理器)对应了自己的分配区mcache, 用于保存当前用到的mspans(非固定大小)
  • 应用整体有若干个mcentral缓存, 每个mcentral存储固定大小的mspans, 对应虚拟内存上的堆区域(Heap Arena), 通常是以内存页为分配单元.
  • 分配内存时, 首先请求mcache, 无法满足则向mcentral请求mspans, 如果还不能满足则mcentral扩容, 向系统请求新的内存页.
  • 分配内存不释放, mcache中的mspans在清空后返回到mcentral中, 不直接返回给操作系统.

优化- Balanced GC

观察

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

为g(goroutine)加入分配缓冲区, 缩短分配路径

  • g绑定一块内存(1KB), 称作goroutine allocation buffer(GAB)
  • GAB 用于noscan 类型的小对象分配: < 128 B
  • Bump pointer(指针碰撞, 类似brk), 使用三个指针维护 本地操作, 无需互斥同步, 分配简单快速
if top + size <= end {
	addr := top
	top += size
	return addr
}
  • GAB内部的内存碎片使用 copying GC (这是否意味着每个g要对应多个GAB?)

编译器与静态分析

编译器结构

Pasted image 20230119124512.png

目标:

  • 识别符合语法和非法的程序
  • 生成正确且高效的代码 编译器前端(分析部分)
  • 词法分析, 生成词素 lexeme
  • 语法分析, 生成语法书 AST
  • 语义分析, 收集类型信息, 进行语义检查
  • 中间代码生成, 生成 intermediate representation (IR) 编译器后端
  • 代码优化, 机器无关优化, 生成优化后的 IR
  • 代码生成, 生成目标代码

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

  • 控制流(Control flow), 程序执行的流程
  • 数据流(Data flow), 数据在控制流上的传递
  • 过程内分析(intra-procedural analysis), 仅在函数内部进行分析
  • 过程间分析(inter-procedural analysis), 考虑函数调用时参数传递和返回值的数据流和控制流, (需要结合上下文, 同时分析控制流和数据流, 较为复杂)

go 编译器优化

编译器优化的优点

  • 用户无感知, 重新编译即可获得性能受益
  • 通用性优化 现状
  • 采用的优化少
  • 目标为合理的编译时间, 没有使用较复杂的代码分析和优化 思路: 面向后端长时间执行的任务, 以更高的编译时间换取更高效的机器码 常见优化策略:
  • 函数内联
  • 逃逸分析
  • 默认栈大小调整
  • 边界检查消除
  • 循环展开

函数内联

  • 将被调用函数的函数体(callee)的副本替换到调用位置上(caller), 并以本地变量引用替换参数
  • 优点:
    • 消除了函数调用开销, 例如传递参数, 保存寄存器等
    • 将过程间分析转换为过程内分析, 有利于其他优化, 如逃逸分析
  • 缺点:
    • 函数体变大, instruction cache 不友好
    • 编译生成的 Go 镜像变大
  • 函数内联在大多情况下为正向优化
  • 调优方向: 内联的策略
    • 调用和被调用函数的规模

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

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

优化后, 函数内联拓展了函数边界, 更多对象不逃逸, 未逃逸的对象可以在栈上分配

  • 对象在栈上分配和回收很快: 移动sp
  • 减少在堆上的分配, 降低了GC负担