#5 性能优化和自动内存管理 | 青训营笔记

86 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
本次编程学习的基本环境配置如下
OS: macOS 13.1
IDE: Goland 2022.3
Go Version: 1.18

重点内容

  1. 关于高性能 Go 语言发行版优化的内存管理优化
  2. 自动内存管理与 Go 内存管理知识,提供可行性的优化建议
  3. 编译器相关知识
  4. 探讨目前Go内存管理过程中问题,提出解决方案,
  5. 通过对编译器基本算法讲解,引出编译器优化路径。

优化

  1. 性能优化:提升软件系统处理能力, 减少不必要的消耗,充分发掘计算机算力
  2. 性能优化的好处:
    1. 提升用户体验:带来用户体验的提升
    2. 高效利用资源:降本增效,很小的优化乘以海量机器会是显著的性能提升和成本节约
  3. 性能优化的层面 (业务代码->SDK和基础库->Runtime->OS)
    1. 业务层优化: 针对特定场景,容易获得较大性能收益
    2. 语言运行时优化:解决更通用的性能问题,考虑更多场景,需要做好tradeoff
  4. 使用数据驱动的方法
    1. 自动化性能分析工具-pprof
    2. 依靠数据而非猜测
    3. 首先优化最大瓶颈
  5. 性能优化和软件质量
    1. 在保证接口稳定的前提下改进具体实现
    2. 测试驱动开发: 覆盖尽可能多场景,方便回归
    3. 写文档: 什么要这么做, 要达到怎样的效果, 方便用户选择是否开启优化
    4. 隔离: 通过选项控制是否开启优化, 没开启的时候, 和没优化前是一样的
    5. 可观测: 给出必要的日志输出, 告诉用户 已经 开启了该优化

自动内存管理

概念

  1. 动态内存: 程序在运行时更具需求动态分配的内存
  2. 自动内存管理(垃圾回收): 由程序语言的运行时系统管理动态内存
    1. 避免手动内存管理
    2. 保证内存使用的正确性和安全性, 避免 Double-Free 和 User-After-Free problem
  3. 三个任务:
    1. 为新对象分配空间
    2. 找到存活对象
    3. 回收死亡对象的内存空间
  4. Concurrently: bi
  5. Mutator threads: 业务线程, 分配新对象, 修改对象指向关系
  6. Collector: GC线程, 找到存活对象, 回收死亡对象的内存空间
  7. Serial GC: 只有一个collector, GC时暂停程序
  8. Parallel GC: 支持多个collectors同时回收的GC算法, GC时暂停程序
  9. Concurrent GC: mutator(s)和collector(s)同时执行, Collectors必须感知对象指向关系的改变

image.png

评价GC算法

  1. 安全性/正确性: 不能回收存活的对象
  2. 吞吐率: 1 - GC时间 / 程序执行总时间 (程序花在GC上的时间)
  3. 暂停时间: stop the world(STW) 业务是否感知
  4. 内存开销: (space overhead) GC元数据开销, 越小越好

Tracing GC 追踪垃圾回收

  1. 对象被回收的条件: 指针指向关系不可达的对象
  2. 标记根对象
    1. 静态变量, 全局变量, 常量, 线程栈等 都是必须存活的对象
  3. 标记: 找到可达对象
    1. 找指针指向关系的传递闭包: 从根对象出发, 找到所有可达对象
  4. 清理: 所有不可达对象
    1. 将存活对象复制到另外的内存空间(Copying GC), 移动
    2. 将死亡对象的内存标记为"可分配"(Mark-sweep GC), free-list
    3. 移动并整理存活对象(Mark-compact GC), 原地整理对象(放到开头)
  5. 根据对象的生命周期, 使用不同的标记和清理的策略
分代GC(Generational GC)
  1. 分代假说: 大多数对象很快释放
  2. 每个对象都有年龄, 即经历GC的次数
  3. 目的: 对年轻和老年的对象, 制定不同的GC策略, 降低整体内存管理的开销
  4. 不同年龄的对象处于heap的不同区域
  5. 对于年轻代, 由于存货对象很少, 可以采用 copying collection, GC吞吐率很高
  6. 对于老年代, 对象趋向于一直或者, 反复复制开销大, 可以用 mark-sweep collection

Reference Counting 引用计数

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

Go内存管理和优化

分块

  1. 目标: 为对象在heap上分配内存
  2. 提前对内存分块
    1. 调用系统调用mmap()?不是malloc()向OS申请一大块内存,例如4MB
    2. 先将内存划分成大块,例如8KB,称作mspan
    3. 再将大块继续划分成特定大小的小块,用于对象分配
    4. noscan mspan:分配不包含指针的对象一GC不需要扫描
    5. scan mspan:分配包含指针的对象一GC需要扫描

image.png

缓存

  1. TCMalloc: thread caching
  2. 每个p包含一个mcache用于快速分配, 用于为绑定于p上的g分配对象
  3. mcache管理一组mspan
  4. 当mcache中的mspan分配完毕, 向mcentral申请未分配块的mspan
  5. 当mspan没有分配的对象, mspan会被缓存到mcentral中, 而不是立刻释放并归还给OS

image.png

Go内存管理优化

  1. 对象分配是非常高频的操作
  2. 小对象占比较高
  3. Go内存分配比较耗时: g -> m -> p -> mcache -> mspan -> memory block -> return ptr (g是Goroutine?)
  4. Balanced GC
    1. 每个 g 都绑定一大块内存(1KB), 称为 goroutine allocation buffer
    2. GAB用于noscan内存的小对象分配 < 128B
    3. 使用三个指针维护GAB: base, end, top
    4. Bump Pointer(指针碰撞)风格对象分配
      1. 无需和其他分配请求互斥
      2. 分配对象简单高效(类似于栈内存分配)
    5. GAB对于Go内存管理来说是一个大对象
    6. 本质: 将多个小对象的分配合并成一次大对象的分配
    7. 问题: GAB的对象分配方式会导致内存被延迟释放
      1. 当GAB总大小超过一定阈值时, 将GAB中存货的对象复制到另外分配的GAB中
      2. 原先的GAB可以释放, 避免内存释放
      3. 本质: 用copying GC的算法管理小对象

编译器和静态分析

编译器的结构

  1. 编译器是非常重要的系统软件
    1. 识别符合语法和非法的程序
    2. 生成正确且高效的代码
  2. 分析部分(前端)
    1. 词法分析: 生成词素
    2. 语法分析: 生成语法树
    3. 语义分析: 收集类型信息, 进行语义检查
    4. 中间代码生成: 生成中间表示(IR)
  3. 综合部分(后端)
    1. 代码优化, 机器无关优化, 生成优化后的IR
    2. 代码生成, 生成目标代码

image.png

静态分析

  1. 静态分析: 不执程序代码, 退到程序的行为, 分析程序的性质
  2. 控制流: 程序执行的流程 ==> 控制流图(Control-flow Graph)是以基本块为节点的图
  3. 数据流: 数据在控制流上的传递

image.png

过程内分析和过程间分析

  1. 过程内分析: 仅在函数内部进行分析
  2. 过程间分析: 考虑函数调用时参数传递和返回值的数据流和控制流

过程间分析是个问题, 需要同时分析控制流和数据流--->联合求解, 比较复杂

Go编译器优化

  1. 为什么做编译器优化
    1. 用户无感知,重新编译即可获得性能收益
    2. 通用性优化
  2. 现状
    1. 采用的优化少
    2. 编译时间较短,没有进行较复杂的代码分析
  3. 优化编译优化的思路
    1. 场景:面向后端长期执行任务
    2. Tradeoff:用编译时间换取更高效的机器码
  4. Beast mode
    1. 函数内联
    2. 逃逸分析
    3. 默认栈大小调整
    4. 边界检查消除
    5. 循环展开
函数内联(lnlining)
  1. 内涵: 将被调用函数的函数体(callee)的副本替换到调用位置(caller)上, 同时重写代码以反映参数的绑定
  2. 优点
    1. 消除函数调用开销, 例如传递参数, 保存寄存器等
    2. 将过程间分析转化成过程内分析, 帮助其他优化, 比如逃逸分析
  3. 缺点
    1. 函数体变大, icahce不友好
    2. 编译生成的Go镜像变大, 编译时间增加
  4. 函数内联在大多数情况下是正向优化
  5. 内联策略, 如调用和被调函数的规模
逃逸分析

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

思路

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

函数内联扩展了函数边界, 很多对象不逃逸了

  • 未逃逸的对象可以在栈上分配和回收
  • 减少在heap上的分配, 降低GC负担

总结

本节讲述了Go内存分配和编译器结构和优化相关知识, 分析问题的方法与解决问题的思路, 不仅适用于Go语言, 其他语言的优化也同样适用.

引用

  1. 字节内部课-性能优化以及自动内存管理:juejin.cn/course/byte…
  2. 字节内部课-Go 内存管理 & 编译器优化思路: juejin.cn/course/byte…