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

88 阅读6分钟

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

优化是优化什么

  • 优化

    • 内存管理优化
    • 编译器优化
  • 背景

    • 自动内存管理和Go

追求极致性能

  • 性能优化是什么?

提升软件系统处理能力,减少不必要的消耗,充分发掘计算机算力

  • 为什么要做性能优化

    • 用户体验
    • 资源高效利用:降低成本,提高效率

性能优化层面

有很多,本文主要聚焦业务层优化和语言运行时优化

  • 业务层优化

    • 针对特定场景,具体问题,具体分析
    • 容易获得较大性能收益
  • 语言运行时优化

    • 解决更通用的性能问题:内存分配编译器,代码质量
    • 考虑更多场景
    • Tradeoffs(权衡取舍)
  • 数据驱动

    • pprof
    • 依靠数据而非猜测
    • 首先优化最大瓶颈

性能优化与软件质量

  • 测试用例:覆盖尽可能多的场景,方便回归
  • 隔离:通过选项控制是否开启优化
  • 可观测:必要的日志输出
  • 软件质量至关重要
  • 在保证接口稳定的前提下改进具体实现
  • 文档:做了什么,没做什么,效果如何

总结

  • 性能优化的基本问题

    • 内存管理
    • 编译器优化
  • 两个层面

    • 业务层优化
    • 语言运行时优化
  • 可维护性

一、自动内存管理

  • 动态内存

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

    • 自动内存管理

      • 避免手动内存管理,专注业务实现
      • 保证内存使用的正确性和安全性
    • 三个任务

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

image.png

collectors必须感知对象指向关系的改变

因为这时用户线程也在执行,很可能又指向了新的对象,这时这个新对象也必须被标记为存活。

image.png

追踪垃圾回收

  • 对象被回收的条件:指针指向关系不可达的对象
  • 标记根对象
    • 静态变量,全局变量,常量,线程栈等(感觉和java GC root选择挺像)
  • 标记:找到可达对象:求指针指向关系的传递闭包,从根对象出发
  • 清理:清理所有不可达对象
    • 将存活对象复制到另外内存空间 (Copying GC) 复制到另外内存空间
    • 死亡对象标记为可分配(Mark-sweep GC) free list管理
    • 移动并整理存活对象(Mark-compact GC) 原地整理
  • 根据对象生命周期,使用不同标记和清理策略

分代GC

  • 年轻代:存活对象很少,吞吐率高,采用copying collection
  • 老年代:反复复制开销较大,mark-sweep collection

引用计数

  • 优点:

    • 内存管理操作被平摊到程序执行过程中,顺带着就完成了
    • 内存管理不需要了解runtime的实现细节
  • 缺点

    • 开销大:通过原子操作保证原子性和可见性
    • 无法回收环形数据结构 weak reference(swift语言)
    • 内存开销:每个对象引入额外空间存储引用数目
    • 回收内存依然可能引发暂停(比如root为null,被回收的对象很多)
  • 总结:自动内存管理背景意义,概念和评价方法,追踪垃圾回收,引用计数,分代GC

二、Go内存管理及优化

Go内存分配----分块

  • 目标:为对象在heap上分配内存
  • 提前将内存分块

image.png

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

Go内存分配----缓存 g表示goroutine,m表示machine,是os线程模型,代表真正执行计算的资源,p代表processor,相对于g是cpu,g只有绑定到p上才可以被调度执行.

  • TCMalloc
  • 每个p包含一个mcache用于快速分配,用于为绑定在p上的g分配对象
  • mcache管理一组mspan
  • mcache中mspan分配完毕,向mcentral中申请带有未分配块的mspan,再插到mcache里,如果还不够,再去heap上申请内存用来分配
  • mspan没有分配的对象,mspan会被缓存再mcentral中,而不是立刻释放并归还给OS

Go内存管理优化

  • 对象分配非常高频:每秒分配GB级别

  • 小对象占比高

  • Go内存分配比较耗时

    • 分配路径长
    • pprof:最频繁调用的函数
  • 字节优化方案:Balanced GC

    • 每个g绑定一大块内存:GAB,它是独有的

    • 用于noscan类型小对象(noscan类型表示这个对象没有指针)

    • 三个指针维护

    • Bump pointer风格对象分配

      • 无需和其他分配请求互斥
      • 分配动作简单高效

GAB对于go内存管理来说是一个大对象

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

问题:导致内存被延迟释放

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

  • gab总大小超过一定阈值时,将gab中存活的对象复制到另外分配的gab中
  • 原先的gab可以释放,避免内存泄漏
  • 本质:用copying GC的算法管理小对象

总结:分块、缓存、对象分配性能问题(小对象为主,分配路径过长)、Balanced GC

对象长度存放在哪里

用额外的gap,开头是1是对象头,go没有专门的对象头

三、编译器和静态分析

编译器的结构

  • 系统软件
  • 分析部分(前端)
  • 综合部份(后端,本文主要关注点)

静态分析

  • 不执行程序代码,推导程序的行为,分析程序的性质。
  • 控制流:程序执行的流程
  • 数据流:数据在控制流上的传递

发现性质,根据这些性质优化代码

过程内分析和过程间分析

  • 内:仅在函数内部

  • 间:过程调用时参数传递和返回值的数据流和控制流

    过程间分析需要联合求解,比较复杂

四、Go编译器优化

  • why:

    • 用户无感知
    • 通用性优化
  • 现状

    • 采用的优化少
    • 编译时间较短,没有进行较复杂的代码分析优化
  • 优化思路

    • 场景:面向后端长期执行任务
    • Tradeoff:用编译时间换取更高效的机器码

函数内联

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

  • 优点

    • 消除函数调用开销
    • 间变内,帮助其他优化,例如逃逸分析
  • 函数内联多大程度影响性能

image.png

  • 缺点

    • 函数体变大
    • 编译生成的Go镜像变大
  • 内联在大多数情况下是正向优化

  • 内联策略

    • 调用被调用函数规模
    • ...
Beast Mode
  • Go函数内联受到的限制较多

    • 语言特性
    • 内联策略保守
  • Beast mode:调整内联策略,使更多函数被内联

    • 降低函数调用开销
    • 增加了其他优化机会:逃逸分析
  • 开销

    • Go镜像增加:~10%
    • 编译时间增加:更多的机会去做了优化
逃逸分析
  • 分析代码中指针的动态作用域:指针在何处可以被访问

  • 大致思路

image.png

  • beast mode:函数内联拓展了函数边界,更多对象不逃逸

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

    • 减少heap上分配,降低GC负担
    • 栈上分配回收很快:移动sp