高性能Go语言发行版优化与落地实践 | 青训营笔记

200 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记


性能优化基本问题

  • 性能优化:提升软件系统处理能力,减少消耗,发掘算力
  • 为什么要做性能优化
    • 用户体验
    • 资源高效利用:降低成本提高效率(很小的优化乘海量的机器也会带来很大收益)

性能优化的两个层面(业务、语言)

  • 业务层优化
    • 针对特定场景具体问题具体分析
    • 容易得到大收益
  • 语言运行时优化——对SDK进行优化
    • 解决更通用的性能问题
    • 考虑更多场景
    • Tradeoffs:需要做权衡
  • 数据驱动
    • pprof
    • 依靠数据而非猜测
    • 首先优化最大瓶颈

性能优化与软件质量(可维护性)

image.png

  • 保证接口稳定的情况下改进具体实现
  • 测试用例:覆盖尽可能多的场景,方便回归。(测试样例都跑一遍)
  • 文档:做了、没做、取得什么效果
  • 隔离:用选项控制是否开启优化,保证行为的一致性
  • 可观测:必要的日志输出

1、自动内存管理

背景

  • 动态内存:malloc()
  • 自动内存管理(垃圾回收):
    • 避免手动内存,专注业务逻辑
    • 保证内存使用的正确性和安全性:double free problem、use after free problem
  • 三个核心任务
    • 新对象分配空间
    • 找到存活对象
    • 回收死亡对象的内存空间

相关概念

image.png

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

  • Colloctor:GC线程,找到存活对象,回收死亡对象的内存空间

  • Serial GC:只有一个collector

  • Parallel GC: 支持多个collectors同时回收的GC算法

  • Concurrent GC:Mutator和collector可以同时执行,一边垃圾回收一边用户线程执行

    • collectors必须感知对象指向关系的改变
      image.png
  • GC算法

    • 安全性:不能回收存活对象
    • 吞吐率:花在GC上的时间(吞吐量越高越好)
    • 暂停时间:业务是否感知(时间越短越好)
    • 内存开销:GC元数据开销(越低越好)

方法一:追踪垃圾回收(Tracing GC)

  • 对象回收条件:指针不可达
  • 标记根对象
    • 静态、全局变量、常量、线程栈
  • 标记可达对象:从跟对象出发找到所有可达对象
  • 清理:所有不可达对象
    • copying GC 新复制旧需要新空间 image.png
    • Mark-sweep GC使用free list管理空闲内存 image.png
    • Mark-compact-GC 原地整理 image.png

根据生命周期、选择不同标记和清理策略

GC策略:分代GC

  • 分代假说:most objects die young 很多对象分配出来就不再使用

  • 对象年龄:经历过GC的次数

  • 目的:不同年龄不同Gc策略降低整体内存管理开销

  • 不同年龄的对象处于heap的不同区域

  • 年轻代

    • 常规对象分配
    • 由于存活对象少,可以采用copy GC
    • 吞吐量高
  • 老年代

    • 对象趋于活着,反复复制开销大
    • 采用其他两种方式

方法二:引用计数

image.png

  • 对象存活条件:当且仅当引用数大于0
  • 优点:
    • 内存管理的操作平摊到程序执行过程中
    • 内存管理不选要了解runtime的实现细节:C++智能指针
  • 缺点:
    • 维护起来开销大:通过原子操作保证原子性和可见性
    • 无法回收环形数据结构 image.png
    • 内存开销:引入额外内存空间存储引用数目
    • 回收时依然可能引发暂停 image.png

2、Go内存管理以及优化

Go内存分配

分块

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

image.png

缓存

image.png 每类大小的mspans都会对应相同大小的mcentral

go内存管理优化

  • 对象分配是高频操作:每秒分配GB级别内存
  • 小对象占比高
  • go内存分配比较耗时
    • 分配路径长 image.png
    • pprof:对象分配的函数是最频繁调用的函数之一

优化方案:Balanced GC

image.png 感觉核心是对noscan类型的对象缩短分配路径。

技术细节

  • GAB对Go内存管理来说是一个大对象

  • 本质上:将对多个小对象的分配合并成一次大对象的分配

  • 问题:GAB的对象分配会导致内存被延迟释放image.png

  • 方案:移动GAB中存活的对象

    • GAB的大小超过阈值时,将存活的对象复制到另外分配的GAB中
    • 原先GAV释放,避免内存泄漏
    • 本质:用copying GC管理算法的小对象,Survivior GAB不属于任一个Goroutine,由go进行内存块的管理 image.png

性能收益:利用率下降4.6%

3、编译器和静态分析

编译器的结构

image.png - IR是机器无关的

编译器后端优化:静态分析

  • 不去执行代码,推导程序的行为、分析程序的性质
  • 控制流:程序执行的流程 image.png
  • 数据流:数据在控制流上的传递
  • 通过分析数据流和控制流,可以知道更多关于程序的性质
  • 根据性质优化代码

过程内分析和过程间分析

  • 过程内分析:函数内部分析
  • 过程间分析:函数调用时的参数传递和返回值的数据流和控制流 image.png

4、go编译器的优化

背景

  • why?
    • 用户无感知,重新编译就有收益
    • 通用性优化
  • 现状
    • 采用的优化少
    • 编译时间段,没有复杂代码分析和优化
  • 编译优化的思路
    • 场景: 面向后端长期执行任务
    • Tradeoff:用编译时间换取更高效的机器码
  • Beast mode:
    • 函数内联
    • 逃逸分析
    • 。。。

函数内联

  • 内联:将被调用函数的函数体副本替换导调用位置上,重新代码以反映参数的绑定
  • 优点:
    • 消除函数调用的开销,如传参、保持寄存器
    • 将过程间分析转换为过程内分析,帮助其他优化,如逃逸分析
  • 性能提升内联后提升4.58倍 image.png
  • 使用micro-benchmark快速验证和对比性能结果
  • 缺点:
    • 函数体变大,icache不友好
    • 编译生成的go镜像变大

Beast Mode

  • go函数内联受到的限制较多
    • 语言特性
    • 内联策略保守,很多函数不会内联
  • Beast mode: 调整函数内联的策略,使更多函数被内联
    • 降低系统调用开销
    • 增加其他的优化机会
  • 开销
    • go镜像增加10%
    • 编译时间增加

逃逸分析:

  • 分析代码中指针的动态作用域,指针在何处可以被访问 image.png
  • 核心是:p能不能在s外访问到
  • Beast mode:内联扩展函数边界,更多对象不逃逸
  • 优化:未逃逸对象可以在栈上分配
    • 对象在栈上分配和回收很快:移动sp
    • 减少在heap上的分配,降低GC负担