Go内存管理与编译器优化思路 | 青训营笔记

101 阅读6分钟

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

本次内容为课程学习笔记。

Go内存分配

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

  • 提前将内存分块

调用系统调用mmap()向OS申请一大块内存,例如4MB,先将内存划分为大块,比如8KB,称为mspan,再将mspan的大块内存继续划分成特定大小的小块,用作对象分配,例如第一个mspan每个块划分大小8B,16B等,对mspan做分类,有noscan mspan, 分配不包含指针的对像,所以GC不需要扫描这些对象,scan mspan,会分配包含指针的对象,此时GC需要扫描。

而对象分配即为根据对象的大小选择最合适的块来返回。

  • 缓存

Go的内存分配器借鉴了TCMalloc的实现机制,TC指的是 thread caching,通过多级缓存来实现快速内存分配。

每个p包含一个mcache用于快速分配,用于为绑定于p上的g分配对象,分配内存从G出发,然后找到m和p,p上有mcache 存有很多mspans,根据对象大小找到最合适的mspan,如果mcache是满的,从下一级缓存,也即向mcentral申请带有未分配块的mspan,空余的mspan会被缓存在mcentral中,而不是立刻释放并归还给OS。

Go内存管理优化

对象分配对于线上服务是非常高频的操作,每秒分配GB级别的内存。因此需要内存分配的优化。而线上数据,小对象占比较高,绝大多数对象都小于80B,所以针对小对象做特定优化,而且Go内存分配比较耗时,分配路径长,g->m->p-> mcache -> mspan -> memoryblock -> return pointer。在对象分配上消耗CPU较多,基于pprof可以看出,对象分配的函数是最频繁调用的函数之一。

优化方案Balanced GC

GAB: 每个g都绑定一大块内存(1 KB),称为goroutine allocation buffer(GAB), GAB用于noscan类型的<128B的小对象分配。

GAB的指针碰撞: 使用三个指针维护GAB: base(基地址), end(结束地址), top(当前地址),基于该思路,操作top指针移动部分长度,然后返回内存地址,无需和其他分配请求互斥,分配动作简单高效。

if top + size <= end {
	addr := top
	top += size
	return addr
}

原理: GAB对于Go内存管理来说是一个大对象,本质是将多个小对象的分配合并成一次大对象的分配,所以对于大对象走较长的分配路径,而小对象基于指针碰撞分配路径

问题: 如果一个小对象存活,GAB整体都被标记为存活,这种的对象分配方式会导致内存被延迟释放。

解决方案: 当GAB总大小超过一定阈值的时候,移动GAB中存活的对象,将GAB中存活的对象复制到另外分配的GAB中,原先的GAB可以释放,避免内存泄漏,本质是用copying GC的算法来管理小对象。

编译器和静态分析

编译器结构

编译器是重要的系统软件,识别符合语法和非法的程序,进而生成正确且高效的代码。包括前端和后端。

分析部分(前端 ):执行源代码读入;词法分析来生成词素;语法分析来生成语法树; 语义分析将结果放在语法树上,形成decorated AST;收集类型信息,进行语义检查进而生成中间代码,不论是什么编译器都是生成同样一组intermediate representation(IR)

综合部分(后端 ):代码优化是机器无关的优化,生成优化后的IR, 基于不同平台的代码生成,生成目标代码。

静态分析

方法是不执行程序代码,只推导程序的行为。

  • 控制流:程序执行的流程

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

通过分析控制流和数据流,从而了解程序的性质信息,并根据这些信息来优化代码。

关于过程内和过程间分析

  • 过程内分析:仅在函数内部进行分析

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

过程间分析需要同时分析数据流和控制流,联合求解比较复杂。而通过函数内联可以简化过程间分析。

Go编译器优化

特点:用户无感知,只要重新编译即可获得性能收益,而且通用性优化对所有方向都有手段

场景:面向后端长期执行任务,所以考虑用编译时间换取更高效的机器码

开发产品Bease mode可以实现如下编译配置:函数内联;逃逸分析;默认栈大小调整;边界检查消除;循环展开

函数内联

内联函数将被调用函数的函数体副本替换到调用位置上,同时重写代码来反映参数的绑定。

  • 优点是消除函数调用开销,例如传递参数,保存寄存器等;将过程间分析转换为过程内分析,帮助其他优化,例如逃逸分析等。

  • 缺点:函数体变大了,对instruction cache(icache)不友好,而且编译生成的Go镜像会变大。

函数内联在大多数情况下是正向优化。但不能无休止做内联,需要依据内联策略让编译器调整该函数是否该被内联。

改进:Go函数内联受到的限制较多,因为语言特性,例如interface和defer等限制了函数内联,内联策略比较保守。BeastMode调整了函数内联的策略,使得更多函数被内联,降低了函数调用的开销,增加了其他优化的机会,开销上,Go的镜像增加10%,编译时间增加。

逃逸分析

逃逸分析定义为分析代码中指针的动态作用域。也即指针在何处可以被访问。

从对象分配处出发, 沿着控制流,观察对象的数据流。如果指针p在当前作用域s:

  • 作为参数传递给其他函数
  • 传递给全局变量
  • 传递给其他的goroutine
  • 传递给已逃逸的指针指向的对象

则指针p指向的对象逃逸出s,也就是给了其他域访问p指向对象的机会,反之则没有逃逸出s。

Beast mode的改进是函数内联拓展了函数的边界,导致更多对象不逃逸。对象在栈上分配和回收很快,只需要移动sp;减少在heap上的分配对象,降低GC负担。