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

266 阅读8分钟

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

高性能Go语言发行版优化与落地实践

性能优化是什么?

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

性能优化的好处:

  • 用户体验:带来用户体验的提升——让抖音更加丝滑,让双十一不再卡顿
  • 资源高效利用:降低成本,提高效率

性能优化的层面

  • 业务代码
  • SDK
  • 基础库
  • 语言运行时
  • OS

业务层优化

  • 准对特定场景,具体问题,具体分析,比如说用自动化性能分析工具pprof
  • 容易获得较大的收益

语言运行时层面优化:

对于go来讲是对Go的SDK进行优化

  • 解决更通用的性能问题
  • 考虑更多场景
  • Tradeoffs

性能优化与软件质量

  • 软件质量至关重要
  • 在保证接口稳定的前提下改进具体实现(指的是:我们现在的GoSDK,已经有了一个存在的接口,有用户已经在使用了之前了接口的行为,我们加入了一个新的接口的时候,我们要保证接口实现了什么功能,保证接口的稳定)
  • 测试用例:覆盖尽可能多的场景,方便回归测试
  • 文档:做了什么,没做什么,能达到怎样的效果(给用户)
  • 隔离:通过选项控制是否开启优化
  • 可观测:必要的日志输出

image.png

自动内存管理

  • 动态内存:

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

  • 自动内存管理(垃圾回收):由程序语言的运行时系统管理动态内存

    避免手动管理内存,专注于实现业务逻辑

    保证内存使用的正确性和安全性

  • 三个任务:

    为新对象分配内存空间

    找到存活对象

    回收死亡对象的内存空间

自动内存管理的几个概念:

  • Concurrently是什么意思

    同时的

  • Mutator threads指的是什么

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

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

    垃圾回收器,和Java类似

    Serial GC:单线程,只有一个collector

    Parallel GC:支持多线程的GC算法

    Concurrent GC:Mutator 和 Collector 可以同时执行(个人理解是:不用Stop the World)

    其中,Concurrent GC的Collectors必须感知对象指向关系的改变

image.png

评价GC算法

  • 安全性:不能回收存活的对象,这是基本要求
  • 吞吐率:时间程序执行总时间​,指的是花在GC上的时间
  • 暂停时间:stop the World,业务是否感知
  • 内存开销:GC元数据开销

判断对象是否为垃圾的两种算法

和Java类似

  • 引用计数法
  • 追踪垃圾回收(可达性分析法)

追踪垃圾回收法

跟Java的可达性分析法一模一样

  • 对象回收的条件:指针指向关系不可达的对象

  • 标记根对象 GCRoot

    静态变量、全局变量、常量、线程栈等作为GCRoot

    流程:

    标记:找到可达对象

    清理:所有不可达对象

    • 将存活对象复制到另外的内存空间(Copying GC) 复制算法,保证空间连续性,一般用于young区
    • 将死亡对象的内存空间标记为可分配(Mark-sweep GC)标记清除算法,会产生碎片
    • 移动并整理存活对象(Mark-compact GC)标记整理法

分代GC

根据对象的声明周期,使用不同的标记和清理策略

熟悉JVM的会发现,几乎一模一样

  • 分代假说,大部分的对象都是朝生夕死的
  • Intuition:很对对象在分配出来后很快就不再使用了
  • 每个对象都有年龄,经过GC的次数
  • 目的:对年轻和年老的对象,制定不同发GC策略,降低整体内存的管理开销
  • 不同年龄的对象处于heap的不同区域,young区 和 old 区

young区:

  • 常规的对象分配,一开始在young区分配空间
  • 由于存活的对象很少,一般使用复制算法,以为存活的少复制的对象就少,GC吞吐率很高

Old区:

  • 对象趋于一直存活,反复复制开销较大
  • 一般使用标记整理算法或者标记清除算法

引用计数法

  • 每个对象都有一个与之关联的引用数目
  • 对象存活的条件:当且仅当引用数大于0

优点:

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

缺点:

  • 维护引用技术的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
  • 存在循环引用的情况,无法回收环形数据结构
  • 内存开销:每个对象都引入额外的空间村存储引用数目
  • 虽然内存管理的操作被平摊到程序执行过程中,但回收内存时仍可能引发暂停stop the world

循环引用的情况:

image.png

PS: 其实Go使用的垃圾回收算法是:三色标记法

三色标记法是标记清除法的进阶

可以看这篇博客

图解Golang垃圾回收机制! - 知乎 (zhihu.com)

Go内存管理及优化

分块

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

  • 提前将内存分块

    具体过程:

    1. 调用系统调用mmap()向OS申请一块大内存mcache,例如4MB
    2. 先将内存划成大块,例如8KB,称作mspan,这样大块内存中就包含了很多mspan
    3. 再将mspan继续划分成特定大小的小块,用于对象分配,这样mspan中就有了很多小块

    mspan分为两种:

    1. noscan mspan:分配不包含指针的对象——GC不需要扫描,可以理解为出度为0,没有包含指针引用到其他对象
    2. sacn mspan:分配包含指针的对象——GC需要扫描
  • 对象分配

    根据对象大小,在mspan中选择最合适的小块返回

image.png

这边需要先理解mspan的概念,方便后续对缓存的理解

缓存

  • TCMalloc:thread caching

  • 每个p包含一个mache用于快速分配,用于绑定于p上的g分配对象

  • mcache管理一组小块不一样的mspan

    当需要为对象分配内存时,会根据对象的大小找到一个嘴合适的mspan,如果有空余分块,就直接返回,就完成了一次对象的分配,但会有下面这种情况

  • 当mcache中的某个mspan分配完毕,向mcentral中申请带有未分配块的maspan。mcentral表示下一级的缓存

  • 当mspan中没有分配的对象,mspan会被缓存在mcentral中,而不是立刻释放归还给OS,以便之后对象分配

image.png mspan中没有对象分配的情况

image.png

Go内存管理优化

  • 对象分配是非常高频的操作:每秒分配GB级别的内存

  • 通过pprof发现,小对象占比比较高

  • Go内存分配比较耗时

    分配路径长:g -> m -> p ->mcache -> mspan -> memory block -> return pointer

    pprof:对象分配的函数时最频繁调用的函数之一

字节对Go内存管理的优化

Balance GC

  • 每一个 g 都当定一个大块内存(1KB),称作 goroutine allocation buffer (GAB)

  • GAB用于noscan类型的小对象分配:< 128 B,不满足这样的对象还是走正常的分配路径

  • 使用三个指针维护GAB:base,end,top

  • Bump pointer(指针碰撞)风格分配对象

    分配对象时对每个协程来说的,所以无需和其他分配请求互斥

    分配动作简单高效,只需要将base指针往后移动

image.png

个人理解:

GAB就是一个大的对象,走之前的Go内存分配路径,但对于我们程序中使用到的对象,是分配在已经分配好的GAB中,这时候我们创建noscan类型的对象就在GAB中分配,不需要GC扫描,而整个GAB相当于scan类型的对象,当GAB中没有对象被引用的时候,才会被回收

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

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

  • 缺点:GAB的对象分配 方式会导致内存被延迟释放,只要GAB中还有一个对象被引用,那么这整块GAB就无法被回收,会造成浪费

    解决方法:

    类似JVM中yound区,将GAB中存活的对象复制到Survivor GAB中,本质还是复制算法。Survivor GAB不属于任何一个协程

image.png

编译器和静态分析

编译器结构

  • 重要的系统软件

    识别符合语法和非法的程序

    生成正确且高效的代码

  • 分析部分(前端 front end)

    词法分析,生成词素(lexme)

    语法分析,生成语法树

    语义分析,收集类型信息,进行语义检查

    中间代码生成,生成IR

  • 综合部分(后端 back end)

    代码优化,机器无关优化,生成优化后的IR

    代码生成,生成目标大妈

静态分析

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

控制流:程序执行流程

数据流:数据在控制流上的传递

通过分析控制流和数据流,我们可以知道更多关于程序的性质

再根据这些性质优化代码

TBD

睡大觉,小命要紧,而且编译原理不大会,太菜了。

总结:Go内存管理还是和Java非常类似的