青训营伴学笔记——Go 语言内存管理 | 青训营笔记

101 阅读7分钟

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

性能优化

是什么?

性能优化可以提升软件系统出库能力,减少不必要的消耗,充分发掘计算机的算力

为什么

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

性能优化层面

业务层

  • 针对特定的场景,具体问题,具体分析
  • 容易获得较大的性能收益

语言运行时的优化

  • 解决更通用的性能问题
  • 考虑更多场景
  • 权衡(tradeoffs)消耗

数据驱动

  • 自动化性能分析工具——pprof

  • 依靠数据而不是猜测

    怀疑性能有问题的时候,应该通过测试、日志、profillig来分析出哪里有问题,有的放矢,而不是凭感觉、撞运气。一个系统有了性能问题,瓶颈有可能是CPU,有可能是内存,有可能是IO(磁盘IO,网络IO),大方向的定位可以使用pprof的top以及stat系列来定位,针对单个进程,可以通过使用pidstat来分析。

  • 首先优化最大的瓶颈

性能优化与软件质量

软件质量至关重要

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

自动内存管理

相关的概念

动态内存

  • 程序在运行时根据需求去动态分配的把内存就是动态内存

自动内存管理 由程序语言运行时系统管理动态内存

  • 可以避免手动内存管理,专注于业务逻辑
  • 保证内存使用的正确性和安全性

Garbage Collection(GC)

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

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

  • Serial GC: 只有一个 collector

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

  • Concurrent GC: mutator(s) 和 collector(s) 可以同时执行

    • collector 必须感知对象指向关系的改变
  • 评价 GC 算法

    • 安全性:不能回收存活着的对象 基本要求
    • 吞吐率:1 - GC 时间 / 程序执行总时间 花在GC上的时间占比
    • 暂停时间:业务是否感知 是否对业务造成用户体验的影响
    • 内存开销:GC 元数据开销
  • 追踪垃圾回收

    • 对象被回收的条件:指针指向关系不可达的对象(无法使用/已经抛弃的对象)

    • 标记根对象(静态变量、全局变量、常量、线程栈等(不会被回收的))

    • 标记可达对象(根据指针关系的传递闭包,从跟兑现刚出发找到所有可达对象)

    • 清理:所有不可达对象

      • 将存活对象复制到另外的内存空间(Copying GC)
      • 将死亡对象的内存标记为“可分配”(Mark-sweep GC)。
      • 移动并整理存活对象(Mark-compact GC)
    • 根据对象的生命周期,使用不同的标记和清理策略 举例:

  • 分代 GC(generational GC)

    • 分代假说:大部分新分配的对象存活周期较短,在分配后的第一轮GC中就会被回收掉。
    • 目的:针对不同的对象,指定不同的 GC 策略,降低整体内存开销
  • 引用计数

    • 每一个对象都有一个引用数目

    • 存货条件:当且仅当引用数大于 0

    • 优点:

      • 内存操作被平摊到程序执行中
      • 不需要了解 runtime 的实现细节:c++的智能指针
    • 缺点:

      • 维护引用计数的开销比较大:通过原子操作保证对引用计数操作的可见性个原子性
      • 无法回收环形的数据结构
      • 内存开销增大(用于计数)
      • 回收内存依旧是可能引发暂停的

GO 内存管理及优化

分块

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

  • 提前将内存分块

    • 调用系统调用 mmap(mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。mmap在用户空间映射调用系统中作用很大。)
    • 先分为大块,称为 mspan
    • 再将大块继续划分为特定大小的小块,就可以用于对象分配啦
    • noscan mspan:分配不包含指针的对象——GC不需要扫描
    • scan mspan:分配包含指针的对象—GC需要扫描
  • 对象分配:根据对象大小选择合适的块返回

  • 对象分配是很高频的操作:每秒 GB 级别

  • 小对象的占比高

  • Go 内存分配比较耗时

    • 分配路径很长
    • pprof:对象分配是调用最频繁的函数之一
  • 字节的优化方案LBalanced GC

    • 每个g都绑定一大块内存(1KB),称作 goroutine allocation buffer (GAB,协程分配缓冲器)
    • GAB用于 noscan 类型的小对象分配: < 128B
    • 使用三个指针维护GAB:base, ned,top
    • Bump pointer(指针碰撞)风格对象分配
      • 不用和其他和其他分配请求互斥
      • 分配动作简单高效
  • GAB 对于 Go 内存管理是一个比较大的对象(将多个小对象合并成一次大对象的分配)

Go编译器优化

函数内联

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

  • 优点:

    • 消除函数调用开销
    • 将过程间分析转化为过程内分析,可以帮助其他的优化
  • 缺点:

    • 函数体变大对icache不够友好
    • 编译生成的 Go 镜像变大
  • 大多情况下内联函数还是正向优化 所以即便是大都是正向优化我们也不能无条件的使用函数内联

Beast Mode

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

逃逸分析

本该分配到函数栈空间的变量,被分配到了堆空间,称为内存逃逸;过多的内存逃逸会导致 GC 压力变大,堆空间内存碎片化。分析指针动态作用域的方法称之为逃逸分析

通过逃逸分析可以有效减少程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。也就是说通过逃逸分析可以判断对象的引用和使用范围从而决定是否要将这个对象分配到堆上面。

  • 大致思路:
    • 从对象分配处出发,沿着控制流,观察对象的数据流
    • 若发现指针 p 在当前作用域 s:
      • 作为参数传递给其他函数
      • 传递给全局变量
      • 传递给其他的 goroutine
      • 传递给已逃逸的指针指向的对象
    • 则指针 p 指向的对象逃逸出 s,反之则没有逃逸出
  • Beast mode: 函数内联拓展了函数边界,更多对象不逃逸
  • 优化: 未逃逸的对象可以在栈上分配
    • 对象在栈上分配和回收很快: 移动 sp
    • 减少在 heap 上的分配,降低 GC 负担