Go 的内存管理
本文主要参考大佬分析内存的系列文章,参杂自己的思考,谨以记录和传播知识
第一章 OS是怎么管理内存的?
Go为什么要有自己的内存管理?
Go语言的内存管理是建立在OS的内存管理之上的.
设计的目的是最大化的发挥OS内存管理层面的优势,避开导致低效情况.
OS内存管理的主要机制
现在计算机内存管理的方式都是一步步演变来的,最开始是非常简单的,后来为了满足各种需求而增加了各种各样的机制,越来越复杂
-
最原始的方式
我们可以把内存看成一个数组,每个数组元素的大小是
1B
(也就是 8 位bit)。CPU 通过内存地址来获取内存中的数据,内存地址可以看做成数组的游标(index)。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y80zarft-1632489279656)(https://i.loli.net/2021/09/23/HJk2Q4O6injcbCx.png)]
CPU 在执行指令的时候,就是通过内存地址,将物理内存上的数据载入到寄存器,然后执行机器指令。但随着发展,出现了多任务的需求,也就是希望多个任务能同时在系统上运行。这就出现了一些问题:
- **内存访问冲突:**程序很容易出现 bug,就是 2 或更多的程序使用了同一块内存空间,导致数据读写错乱,程序崩溃。更有一些黑客利用这个缺陷来制作病毒。
- **内存不够用:**因为每个程序都需要自己单独使用的一块内存,内存的大小就成了任务数量的瓶颈。
- **程序开发成本高:**你的程序要使用多少内存,内存地址是多少,这些都不能搞错,对于人来说,开发正确的程序很费脑子。
举个例子,假设有一个程序,当代码运行到某处时,需要使用
100M
内存,其他时候1M
内存就够;为了避免和其他程序冲突,程序初始化时,就必须申请独立100M
内存以保证正常运行,这就是一种很大的浪费,因为这100M
它大多数时候用不上,其他程序还不能用。 -
虚拟内存
虚拟内存的出现,很好的解决了上述的一系列问题.用户程序只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址.
所有程序统一使用一套连续虚拟地址,比如0x0000~0xffff. 从程序的角度来看,它觉得自己独享了一整块内存.不用考虑访问冲突的问题.系统会将虚拟地址翻译成物理地址,加载数据.
对于内存不够用的问题,虚拟内存本质上是将磁盘当成最终存储,而主存作为一个程序可以从虚拟内存申请很大的空间使用,比如1G; 但OS不会真的在物理内存上开辟1G的空间,它只是开辟了很小一块,比如1M给程序使用. 这样,程序在访问内存时,OS查看访问的地址是否能够转换成物理内存地址. 能则正常访问,不能则再开辟.这使得内存得到了更高效的利用.
如下图所示,每个进程所使用的虚拟地址空间都是一样的,但他们的虚拟地址会被映射到主存上的不同区域,甚至映射到磁盘上(当内存不够用时).
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CoZVHMgR-1632489279657)(https://i.loli.net/2021/09/23/VP2Uf9kIvxwzLTm.png)]
其实本质上很简单,就是OS将程序常用的数据放到内存里加速访问,不常用的数据放在磁盘上. 这一切对用户程序来说完全是透明的,用户程序可以假装所有数据都在内存里,然后通过内存地址去访问数据.在这背后,OS系统会自动将数据在主存和磁盘之间进行交换.
-
虚拟地址翻译
虚拟内存的实现方式,大多数都是通过
page
来实现的. OS虚拟内存空间分成一页一页的管理,每页的大小为 4K (当然这是可以配置的,不同OS不一样). 磁盘和主存之间的置换也是以page
为单位来操作的. 4K 算是通过实践折中出来的通用值,太小会出现频繁的置换,太大了又浪费内存.虚拟地址 —> 物理地址 的映射关系由
page table
记录,它其实就是一个数组,数组中每个元素叫做page table entry
(简称PTE), PTE由一个有效位和n位地址字段构成,有效位标识这个虚拟地址是否分配了物理内存.page 被操作系统放在物理内存的指定位置,CPU上有个
Memory Management Unit(MMU)
单元,CPU 把虚拟地址给 MMU, MMU 去物理内存中查询页表,得到实际的物理地址.当然,MMU 不会每次都去查的,它自己也有一份缓存叫Translation Lookaside Buffer(TLB)
,是为了加速地址翻译.[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2xtzZiHs-1632489279658)(https://i.loli.net/2021/09/23/RJTwvuhBXMiPGn3.png)]
哲理:
你慢慢会发现整个计算机体系里面,缓存是无处不在的.
整个计算机体系就是建立在一级级的缓存之上的,无论软硬件.
让我们来看一下CPU内存访问的完整过程:
- CPU 使用虚拟地址访问数据, 比如执行了 MOV 指令加载数据到寄存器, 把地址传递给 MMU.
- MMU 生成 PTE 地址, 并从主存(或自己的Cache)中得到它.
- 如果 MMU 根据 PTE 地址得到真实的物理地址, 正常读取数据. 流程到此结束.
- 如果 PTE 信息标识没有关联的物理地址, MMU 则出发一个缺页异常.
- OS 捕获到这个异常, 开始执行异常处理程序. 在物理内存上创建一页内存,并更新页表.
- 缺页处理程序在物理内存中确定一个牺牲page, 如果这个牺牲page上有数据, 则把数据保存到磁盘上.
- 缺页处理程序更新 PTE.
- 缺页处理程序结束, 再回去执行上一条指令(导致缺页异常的那个指令, 也就是 MOV 指令). 这次肯定命中了.
-
内存命中率
你可能已经发现, 上述的访问步骤中, 从第 4 步开始都是一些很繁琐的操作, 频繁的执行对性能影响很大. 毕竟访问磁盘是非常慢的, 它会引发程序性能的急剧下降. 如果内存访问到第3步成功结束了, 我们就说 page 命中了; 反之就是未命中, 或者说缺页, 表示它开始执行第4步了.
假设在 n 此内存访问中, 出现命中的次数是m, 那么 m / n * 100% 就是命中率, 这是衡量内存管理程序好快的一个很重要的指标.
如果物理内存不足了, 数据会在主存和磁盘之间频繁交换, 命中率很低, 性能出现急剧下降, 我们称这种现象叫内存颠簸. 这时你会出现系统的 swap 空间利用率开始增高, CPU 利用率中 iowait 占比开始增高.
大多数情况下,只要物理内存够用, 页命中率不会非常低, 不会出现内存颠簸的情况. 因为大多数程序都有一个特点, 就是局部性.
哲理:
局部性就是说被引用过一次的存储器位置, 很可能在后续再被引用多次; 而且在该位置附近的其他位置, 也很可能会在后续一段时间内被引用.
前面说过计算机到处使用一级级的缓存来提升性能, 归根到底就是利用局部性的特征, 如果没有这个特性, 一级级的缓存不会有那么大的作用. 所以一个局部性很好的程序运行速度会更快.
-
CPU Cache
随着技术发展, CPU 的运算速度越来越快, 但内存访问的速度却一直没什么突破. 最终导致了 CPU 访问主存就成了整个机器的性能瓶颈. CPU Cache 的出现就是为了解决这个问题, 在 CPU 和 主存之间再加了 Cache, 用来缓存一块内存中的数据, 而且还不止一个, 现在计算机一般都有3级Cache, 其中 L1 Cache 的访问速度和寄存器差不多.
现在访问数据的大致顺序是 CPU —> L1 Cache —> L2 Cache —> 主存 —> 磁盘. 从左到右访问速度越来越慢, 空间越来越大, 单位空间/byte 的价格越来越低.
现代存储器的整体层次结构大致如下图:
在这种架构下, 缓存的命中率就更加重要了, 因为系统会假定所有程序都是有局部性特征的. 如果某一级出现了未命中, 他就会将该级存储的数据更新最近使用的数据.
主存与存储器之间以 page (通常是4K) 为单位进行交换
cache 与 主存之间是以 cache line (通常是 64 Byte) 为单位交换的
eg: 一个验证命中率的问题, 循环一个数组为每个元素赋值
func Loop(nums []int, step int) {
l := len(nums)
for i := 0; i < step; i++ {
for j := i; j < l; j += step {
nums[j] = 4
}
}
}
参数step 为 1
时, 和普通一层循环一样. 假设 step 为 2, 则效果就是跳跃式遍历数组, step 越大, 访问跨度也就越大, 程序的局部性越不好.
下面是 nums 长度为 10000
, step = 1
和 step = 16
时的压测结果:
goos: darwin
goarch: amd64
BenchmarkLoopStep1-4 300000 5241 ns/op
BenchmarkLoopStep16-4 100000 22670 ns/op
可以看出, 两种遍历方式会出现 3 倍的性能差距. 这种问题最容易出现在多维数组的处理上, 比如遍历一个二维数组很容易出现局部性很差的代码.
-
程序的内存布局
最后看一下程序的内存布局. 现在我们知道了每个程序都有自己一套独立的地址空间可以使用, 比如 0x0000 ~ 0xffff, 但我们在用高级语言(C/Go) 写程序的时候, 很少直接使用这些地址. 我们都是通过变量名来访问数据的, 编译器会自动将我们的变量名转换成真正的虚拟地址.
那最终编译出来的二进制文件, 是如何被OS加载到内存中并执行的呢?
其实, OS 已经将一整块内存划分好了区域, 每个区域用来做不同的事情. 如图:
- text 段: 存储程序的二进制指令
- data 段: 存储已被初始化的全局变量, 比如常量(const)
- bss 段: 存储未被初始化的全局变量, 和 .data 段一样都属于静态分配, 在这里面的变量数据在编译阶段就确定了大小, 不释放
- stack 段: 栈空间, 主要用于函数调用时存储临时变量的. 这部分的内存是自动分配, 自动释放的
- heap 段: 堆空间, 主要用于动态分配, C 语言中
malloc
和free
操作的内存就在这里; Go 主要靠GC 自动管理这部分
其实, 现代的OS进程的内存区域没这么简单, 要比这复杂多了, 比如 内核区域, 共享库区域. 因为我们不是要开发一套操作系统, 细节可以忽略. 这里只需要记住堆空间和栈空间即可.
-
小结
- 理想状态下没有内存一说, 就是因为磁盘速度跟不上, 磁盘的缓存需求应运而生(内存,主存).
- CPU的发展太快, 主存也跟不上, 需要缓存, CPU Cache 应运而生.
- OS是通过虚拟内存的方法来管理内存(增删改查), 本质就是把正用和要用的东西放在主存上.
- 缓存基本都是基于局部性原理
局部性好的程序, 可以提高缓存命中率, 这对OS的内存管理是很友好的, 可以提高程序的性能. CPU Cache 层面的低命中率会导致程序运行缓慢; 内存层面的低命中率会导致内存颠簸(穿透), 出现这种现象时你的服务基本上已经瘫痪了.
第二章 Go 的内存管理
它是参考 tcmalloc
实现的(细节上根据自身的需要做了一些优化), 其实就是利用好了OS 管理内存的特点, 扬长避短, 站在巨人的肩膀上!
Go的内存是自动管理的, 那它在背后帮我们做了什么呢?
第一节 管理模型
-
池
程序动态申请内存空间, 是要使用系统调用的, 比如 Linux 系统上是调用
mmap
方法实现的. 但对于大型系统服务来说, 直接调用mmap
申请内存,会有一定的代价. 比如:- 系统调用会导致进程进入内核态, 内核分配完内存后(对虚拟地址和物理地址进行映射等操作), 再返回用户态.
- 频繁申请很小内存空间, 容易出现大量内存碎片, 增大OS整理碎片的压力.
- 为了保证内存访问具有良好的局部性, 开发者需要投入精力去做优化, 这是一个很重要的负担.
如何解决上面的问题呢? 有经验的人, 可能很快就想到了解决方案, 那就是我们常说的
对象池
(也可以说是缓存)假设系统需要频繁动态申请内存来存放一个数据结构, 比如
[10]int
. 那么我们完全可以在程序启动之初, 一次性申请几百甚至上千个[10]int
. 这样就完美的解决了上面遇到的问题:- 不需要频繁申请内存了, 而是从对象池里拿, 程序不会频繁进入内核态
- 因为一次性申请一个连续的大空间, 对象池会被重复利用, 不会出现碎片
- 程序频繁访问的就是对象池背后的同一块内存空间, 局部性良好
这样会造成一定的内存浪费, 我们可以定时检测对象池的大小, 保证可用对象的数量在一个合理的范围, 少了就提前申请, 多了就自动释放.
如果某种资源的申请和回收是昂贵的, 我们都可以通过建立资源池的方式来解决, 比如连接池, 内存池等等, 都是一个思路.
-
Go的内存管理本质
就是一个内存池, 只不过内部做了很多的优化. 比如自动伸缩内存池大小, 合理的切割内存块等等.
-
概念
-
page
: 内存页, 一块 8K 大小的内存空间. Go 与 OS之间的内存申请和释放都是以page
为单位的 -
span
: 内存块, 一个或多个连续的page
组成一个span
. 如果把page
比喻成工人,span
可以看成是小队, 工人被分成若干个队伍, 不同队伍干不同的(sizeclass)活 -
sizeclass
: 空间规格, 每个span
都带有一个sizeclass
, 标记着该span
中的page
应该如何使用. 标志着span
是一个什么样的队伍. -
object
: 对象, 用来存储一个变量数据内存空间, 一个span
在初始化时,会被切割成一堆等大的object
. 假设object
的大小是 16B,span
大小是 8K, 那么就会把span
中的page
就会被初始化8K / 16B = 512
个object
. 所谓内存分配, 就是分配一个object
出去. -
内存碎片
系统(OS/各种runtime)在内存管理过程中, 会不可避免的出现一块块无法被使用的内存空间, 这就是内存管理的产物.
-
内部碎片
一般都是因为字节对齐(为什么要字节对齐呢?),如上面介绍 Tiny 对象分配的部分; 为了字节对齐, 会导致一部分空间直接被放弃掉, 不做分配使用.
-
外部碎片
一般时因为内存的不断分配和释放, 导致一些释放的小内存块分散在内存各处, 无法被用以分配. 不过Go的内存管理机制不会引起大量外部碎片.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cKfkTTBI-1632489279663)(https://i.loli.net/2021/09/23/HODtCgLBh2VZnJY.png)]
看图说话:
不同颜色代表不同的
span
不同
span
的sizeclass
不同, 表示里面的page
将会按照不同的规格切割成一个个等大的object
用作分配测试某个版本初始堆内存的分配情况:
package main import "runtime" var stat runtime.MemStats func main() { runtime.ReadMemStats(&stat) println(stat.HeapSys) }
Go 在程序启动时会分配一块虚拟内存地址,结构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tuUfaBqg-1632489279663)(https://i.loli.net/2021/09/24/hr95up8EePvqJO1.png)]
-
span:用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个page, 已经使用了多大等等。
bitmap:存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
arena_start:将要分配给应用程序使用的空间
-
内存池 mheap
Go 的程序在启动之初, 会一次性从OS那里申请一大块内存作为内存池. 这块内存空间会放在一个叫
mheap
的struct
中管理, mheap 负责将这一整块内存切割成不同的区域, 并将其中一部分的内存切割成合适的大小, 分配给用户使用. -
mcentral
用途相同(
sizecliass
相同, 用来存储哪种大小的对象)的span
会以链表的形式组织在一起. 比如当分配一块大小为 n 的内存时, 系统计算 n 应该使用哪种sizeclass
, 然后根据sizeclass
的值去找到一个可用的span
来用作分配. 其中sizeclass
一共有67种(Go 1.5), 如下图所示:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mo4o1Epl-1632489279664)(https://i.loli.net/2021/09/23/12fn5wmdcF3aQo8.png)]
找到合适的
span
后, 会从中取一个object
返回给上层使用. 这些span
被放在一个叫做 mcentral 的结构中管理.mheap 将从 OS 那里申请过来的内存初始化成一个大
span
(sizeclass=0). 然后根据需要从这个大span
中切出小span
, 放在mcentral中来管理.大
span
由mheap.freelarge
和mheap.busylarge
等管理.如果 mcentral 中的
span
不够用了, 会从mheap.freelarge
上再切一块, 如果mheap.freelarge
空间不够, 会再次从OS那里申请内存重复上述步骤. 下面看看 mheap 和 mcentral 的数据结构:type mheap struct { // other fields lock mutex free [_MaxMHeapList]mspan // free lists of given length, 1M 以下 freelarge mspan // free lists length >= _MaxMHeapList, >= 1M busy [_MaxMHeapList]mspan // busy lists of large objects of given length busylarge mspan // busy lists of large objects length >= _MaxMHeapList central [_NumSizeClasses]struct { // _NumSizeClasses = 67 mcentral mcentral // other fields } // other fields } // Central list of free objects of a given size. type mcentral struct { lock mutex // 分配时需要加锁 sizeclass int32 // 哪种 sizeclass nonempty mspan // 还有可用的空间的 span 链表 empty mspan // 没有可用的空间的 span 列表 }
这种方式可以避免出现外部碎片, 因为同一个 span 是按照固定大小分配和回收的, 不会出现不可利用的一小块内存把内存分割掉.
-
mcache
如果你阅读的比较仔细, 会发现上面的 mcentral 结构中有一个 lock 字段; 因为并发情况下, 很有可能多个线程同时从 mcentral 那里申请内存的, 必须要用锁来避免冲突.
但锁是低效的, 在高并发的服务中, 它会使内存申请成为整个系统的瓶颈; 所以在mcentral的前面又增加了一层 mcache.
每一个mcache和每一个处理器§是一一对应的, 也就是说每一个P 都有一个 mcache 成员. Goroutine 申请内存时, 首先从自身所在的P的mcache中分配, 如果 mcache 没有可用
span
, 再从 mcentral 中获取, 并填充到 mcache 中.从 mcache 上分配内存空间是不需要加锁的, 因为在同一时间点, 一个P 只有一个线程在其上面运行, 不可能出现竞争. 没有了锁的限制, 大大加速了内存分配.
-
整体内存分配模型
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9h9sIHev-1632489279665)(https://i.loli.net/2021/09/23/cQjTxm7BaspVbOC.png)]
-
其它优化
-
zero size
有一些对象所需的内存大小是 0, 比如
[0]int, struct{}
, 这种类型的数据根本就不需要内存, 所以没必要走上面那么复杂的逻辑系统会直接返回一个固定的内存地址, 源码如下:
func mallocgc(size uintptr, typ *_type, flags uint32) unsafe.Pointer { // 申请的 0 大小空间的内存 if size == 0 { return unsafe.Pointer(&zerobase) } //..... }
测试代码:
package main import ( "fmt" ) func main() { var ( a struct{} b [0]int c [100]struct{} d = make([]struct{}, 1024) ) fmt.Printf("%p\n", &a) fmt.Printf("%p\n", &b) fmt.Printf("%p\n", &c) fmt.Printf("%p\n", &(d[0])) fmt.Printf("%p\n", &(d[1])) fmt.Printf("%p\n", &(d[1000])) }
-
Tiny 对象
上面提到的
sizeclass
的span, 用来给<= 8B
的对象使用, 所以像int32, byte, bool以及小字符串等常用的微小对象, 都会使用sezeclass
= 1 的span
, 但分配给他们 8B的空间, 大部分是用不上的. 并且, 这些类型使用频率非常高, 就会导致出现大量的内部碎片.所以, Go尽量不使用
sizeclass
的span,而是将< 16B
的对象统一视为 tiny 对象(tinysize). 分配时, 从sizeclass
=2的span中获取一个 16B 的object 用以分配. 如果存储的对象小于 16B, 这个空间会被暂时保存起来(mcache.tiny字段), 下次分配时会复用这个空间, 直到这个 object 用完为止. 如下图所示:上图方式的空间利用率是
(1+2+8)/16*100% = 68.75%
原始的管理方式利用率是
(1+2+8)/(8*3)*100% = 45.83%
源码中注释描述, 说是对tiny对象的特殊处理, 平均会节省 20% 左右的内存.
注意: 如果要存储的数据里有指针, 即使
<= 8B
也不会作为tiny对象对待, 而是正常使用sizeclass = 1
的span(为什么呢?). -
大对象
最大的
spanclass
只能存放 32K 的对象. 如果一次性申请超过 32K 的内存, 系统(Go的runtime)会直接绕过 mcache和mcentral, 直接从mheap上获取, mheap中有一个freelarge
字段管理着超大span
.
-
-
释放
没什么特别之处, 就是分配的反过程.
当 mcache 中存在较多空闲 span时, 会归还给 mcentral;
当 mcentral中存在较多空闲 span时, 会归还给 mheap;
当mheap再归还给OS.
-
Go内存管理也是一个金字塔
这种设计比较通用,比如现在常用的web服务设计, 为了提升系统性能, 一般都会设计成
客户端--->服务端cache--->服务端db
,也是金字塔.将有限的计算资源布局成金字塔结构, 再将数据从热到冷分为几个层级, 放置在金字塔结构上, 调度器不断做调整, 将热数据放在金字塔顶端, 冷数据放在金字塔底层.
这种设计利用了计算的局部性特征, 认为冷热数据的交替时缓慢的. 所以最怕的就是, 数据访问出现冷热骤变. 在OS上称这种现象为
内存颠簸
,系统架构上通常被说成缓存穿透
. 其实都是一个意思, 就是过度的使用了金字塔底层的资源. -
源码调用流程图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FnRf2zcf-1632489279668)(https://i.loli.net/2021/09/23/XTWbCe7qU3ahSFp.png)]
-
小结
这种设计之所以快, 主要有一下几个原因:
- 内存分配大多时候都是在用户态完成的, 不需要频繁进入内核态.
- 每个P都有独立的 span cache, 多个CPU 不会并发读写同一块内存, 进而减少CPU L1 cache的 cacheline 出现dirty 情况, 增大 CPU Cache命中率.
- 内存碎片化的问题, Go是自己在用户态管理的, 在OS层面看是没有碎片的, 使得OS对碎片化的管理压力也会降低.
- mcache的存在使得内存分配不需要枷锁.
当然, 这不是没有代价的, Go需要预申请大块内存, 这必然会出现一定的浪费, 不过好在现在内存比较廉价,不用太在意. Go号称时现代版的C, 时代在发展, 科技的进步,使得我们必然走上用空间换时间的道路上.
这套内部机制,使得开发高性能服务器容易很多, 通俗来讲就是坑少了. 一般情况下, 性能都不会太差. 我遇到过的导致内存分出现压力的主要有两种情况:
- 频繁申请大对象, 常见于文本处理, 比如写一个海量日志分析的服务, 很多日志的内容都很长. 这种情况建议自己维护一个对象池, 避免每次都要取 mheap 上分配.
- 滥用指针, 指针的存在不仅容易造成内存浪费, 对GC也会造成额外的压力, 所以尽量不使用指针.
第二节 逃逸分析
-
逃逸的由来,为什么要分析?
Go 语言较C 语言一个很大的优势就是自带 GC 功能, 可 GC 并不是没有代价的。
对比:
-
写 C 语言的时候,在一个函数内声明的变量,在函数退出后会自动释放掉,因为这些变量分配在栈上。如果想要变量的数据能在函数退出后还能访问,就需要调用
malloc
方法在堆上申请内存,如果程序不再需要这块内存了, 再调用free
方法释放掉。 -
Go语言不需要你主动调用
malloc
来分配堆空间,编译器会自动分析,找出需要malloc
的变量, 使用堆内存。编译器的这个分析过程就叫做逃逸分析。所以,当你在一个函数中通过
dict := make(map[string]int)
创建一个map变量, 其背后的数据是放在栈空间还是堆空间上,是不一定的。这要看编译器分析的结果。
然而,逃逸分析并不是百分百准确的,它是有缺陷的。有的时候你会发现有些变量其实在栈空间上分配完全没有问题的,但编译后程序还是把这些数据放在了堆上。如果你了解Go语言编译器逃逸分析的机制,在写代码的时候就可以有意识的绕开这些缺陷,使你的程序更高效。
-
-
补充基础知识
Go 语言虽然在内存管理方面降低了编程门槛,即使你不了解堆栈也能正常开发,但如果你要在性能上较真的话,还是要掌握这些基础知识的。
这里不对堆内存和栈内存的区别做太多阐述。 简单来说就是,栈分配廉价,堆分配昂贵。栈空间会随着一个函数的结束自动释放,堆空间需要 GC 模块不断的跟踪扫描回收。如果对这两个概念有些迷糊,建议阅读下面两篇文章:
这里举一个小例子,来对比下堆栈的差别:
func stack() int { // 变量 i 会在栈上分配 i := 10 return i } func heap() *int { // 变量 j 会在堆上分配 j := 10 return &j }
stack
函数中的变量i
在函数退出会自动释放;而heap
函数返回的是对变量i
的引用,也就是说heap
退出后,变量i
还要能被访问,它会自动被分配到堆空间上。
逻辑的复杂度不言而喻,上面的汇编中可以看到,heap
函数调用了runtime.newobject()
方法,它会调用mallocgc
方法从mcache
上申请内存,申请的内部逻辑参考上一章节。堆内存分配不仅分配逻辑上比栈空间复杂,它最致命的是会带来很大的管理成本,Go语言要消耗很多的计算资源对其进行标记回收(也就是GC成本)。
不要以为使用了堆内存就一定会导致性能低下,使用栈内存一定会带来性能优势。实际项目中,系统的性能瓶颈一般都不会出现在内存分配上,千万不要盲目优化。要找到系统瓶颈,用数据驱动优化
-
逃逸分析
Go 编辑器会自动帮助我们找出需要进行动态分配的变量,它是在编译时追踪一个变量的生命周期,如果能确认一个数据只在函数空间内访问,不会被外部使用,则使用栈空间,否则就要使用堆空间。
命令:
go build -gcflags -m test.go
上面两个函数的编译结果为:
$ go build -gcflags -m test4.go # command-line-arguments .\test4.go:5:6: can inline stack .\test4.go:10:6: can inline heap .\test4.go:16:6: can inline main .\test4.go:17:7: inlining call to stack .\test4.go:18:6: inlining call to heap .\test4.go:12:2: moved to heap: j
-
缺陷
需要使用堆空间则逃逸,这没什么可争议的。但编译器有时会将不需要使用堆空间的变量也逃逸掉,这就容易出现性能问题的大坑。
-
哪些容易导致逃逸呢?
对级间接赋值容易导致逃逸
扩展解释就是对某个引用类对象中的引用类成员进行赋值。
Go 语言中的引用类数据类型有
func
,interface
,slince
,map
,chan
,*Type
。记住公式 Data.Field = Value, 如果 Data、Field都是引用类的数据类型,则会导致 Value 逃逸, 这里的
=
不单单只赋值,也表示参数传递。 -
实际的例子
-
函数变量
如果变量值是一个函数,函数的参数又是引用类型,则传递给它的参数都会逃逸
func test(i int) {} func testEscape(i *int) {} func main() { i, j, m, n := 0, 0, 0, 0 t, te := test, testEscape // 函数变量 // 直接调用 test(m) // 不逃逸 testEscape(&n) // 不逃逸 // 间接调用 t(i) // 不逃逸 te(&j) // 逃逸 }
$ go build -gcflags -m test4.go # command-line-arguments .\test4.go:4:6: can inline test .\test4.go:5:6: can inline testEscape .\test4.go:7:6: can inline main .\test4.go:12:6: inlining call to test .\test4.go:13:12: inlining call to testEscape .\test4.go:15:3: inlining call to test .\test4.go:16:4: inlining call to testEscape .\test4.go:5:17: i does not escape
上例中
te
的类型是func(*int)
,属于引用类型,则调用te(&j)
形成了te
的参数成员*int
赋值的现象,即te.i = &j
会导致逃逸。其他几种调用都没有形成多级间接赋值的情况。同理,如果函数的参数类型是
slince
,map
或interface{}
都会导致参数逃逸。func testSlice(slice []int) {} func testMap(m map[int]int) {} func testInterface(i interface{}) {} func main() { x, y, z := make([]int, 1), make(map[int]int), 100 ts, tm, ti := testSlice, testMap, testInterface ts(x) // ts.slice = x 导致 x 逃逸 tm(y) // tm.m = y 导致 y 逃逸 ti(z) // ti.i = z 导致 z 逃逸 }
$ go build -gcflags -m test4.go # command-line-arguments .\test4.go:3:6: can inline testSlice .\test4.go:4:6: can inline testMap .\test4.go:5:6: can inline testInterface .\test4.go:7:6: can inline main .\test4.go:10:4: inlining call to testSlice .\test4.go:11:4: inlining call to testMap .\test4.go:12:4: inlining call to testInterface .\test4.go:3:16: slice does not escape .\test4.go:4:14: m does not escape .\test4.go:5:20: i does not escape .\test4.go:8:17: make([]int, 1) does not escape .\test4.go:8:33: make(map[int]int) does not escape .\test4.go:12:4: z does not escape
匿名函数的调用也是一样的,它本质上也是一个函数变量。
-
间接赋值
type Data struct { data map[int]int slice []int ch chan int inf interface{} p *int } func main() { d1 := Data{} d1.data = make(map[int]int) // GOOD: does not escape d1.slice = make([]int, 4) // GOOD: does not escape d1.ch = make(chan int, 4) // GOOD: does not escape d1.inf = 3 // GOOD: does not escape d1.p = new(int) // GOOD: does not escape d2 := new(Data) // d2 是指针变量, 下面为该指针变量中的指针成员赋值 d2.data = make(map[int]int) // BAD: escape to heap d2.slice = make([]int, 4) // BAD: escape to heap d2.ch = make(chan int, 4) // BAD: escape to heap d2.inf = 3 // BAD: escape to heap d2.p = new(int) // BAD: escape to heap }
$ go build -gcflags -m test4.go # command-line-arguments .\test4.go:11:6: can inline main .\test4.go:13:16: make(map[int]int) does not escape .\test4.go:14:17: make([]int, 4) does not escape .\test4.go:16:9: 3 does not escape .\test4.go:17:12: new(int) does not escape .\test4.go:19:11: new(Data) does not escape .\test4.go:20:16: make(map[int]int) escapes to heap .\test4.go:21:17: make([]int, 4) escapes to heap .\test4.go:23:9: 3 escapes to heap .\test4.go:24:12: new(int) escapes to heap
-
interface
只要使用了 interface 类型(注意,不是interface{}),那么赋值给它的变量一定会逃逸。因为
interfaceVariable.Method()
先是间接的定位到它的实际值,再调用实际值的同名方法。执行时实际值作为参数传递给方法。相当于interfaceVariable.Method.this = realValue
type Iface interface { Dummy() } type Integer int func (i Integer) Dummy() {} func main() { var ( iface Iface i Integer ) iface = i iface.Dummy() // make i escape to heap // 形成 iface.Dummy.i = i }
$ go build -gcflags -m test4.go # command-line-arguments .\test4.go:7:6: can inline Integer.Dummy .\test4.go:9:6: can inline main .\test4.go:14:8: i escapes to heap <autogenerated>:1: leaking param: .this <autogenerated>:1: inlining call to Integer.Dummy <autogenerated>:1: .this does not escape
-
引用类型的 channel
向 channel 中发送数据, 本质上就是为 channel 内部的成员赋值,就像给一个slince 中的某一项赋值一样。所以
chan *Type
,chan map[Type]Type
,chan []Type
,chan interface{]}
类型都会导致发送到 channel 中的数据逃逸。这本来也是情理之中的,发送给 channel 的数据是要与其他函数分享的,为了保证发送过去的指针依然可用,只能使用堆分配。
func test() { var ( chInteger = make(chan *int) chMap = make(chan map[int]int) chSlice = make(chan []int) chInterface = make(chan interface{}) a, b, c, d = 0, map[int]int{}, []int{}, 32 ) chInteger <- &a // 逃逸 chMap <- b // 逃逸 chSlice <- c // 逃逸 chInterface <- d // 逃逸 }
$ go build -gcflags -m test4.go # command-line-arguments .\test4.go:6:6: can inline test .\test4.go:3:6: can inline main .\test4.go:4:6: inlining call to test .\test4.go:4:6: moved to heap: a .\test4.go:4:6: map[int]int{} escapes to heap .\test4.go:4:6: []int{} escapes to heap .\test4.go:4:6: d escapes to heap .\test4.go:12:3: moved to heap: a .\test4.go:12:31: map[int]int{} escapes to heap .\test4.go:12:40: []int{} escapes to heap .\test4.go:17:14: d escapes to heap
-
可变参数
可变参数如
func(arg ...string)
实际与func(arg []string)
是一样的,会增加一层访问路径。这也是fmt.Sprintf
总是会使参数逃逸的原因。
-
-
小结
熟悉堆栈概念可以让我们更容易看透Go程序的性能问题,并进行优化。
多级间接赋值会导致Go 编译器出现不必要的逃逸,在一些情况下,我们只需要修改一下数据结构就会使性能有大福提升。这也是很多人不推荐在Go中使用指针的原因,因为它会增加一级访问路径,而
map
,slice
,interface{}
等类型是不可避免要用到的,为了减少不必要的逃逸,只能拿指针开刀了。
第三节 垃圾回收
-
GC
编写Go代码不需要像写C/C++那样手动的
malloc
和free
内存,因为malloc操作由Go编译器的逃逸分析机制帮我们加上了,而free
动作则是由GC机制来完成。虽说GC是一个很好的特性,大大降低了编程门槛,但这是以损耗性能为代价的(以满足人们的美好生活为目的)。
这里突发一个插曲,以损耗性能为代价用词非常不合适。我们的现实生活是这样的,以满足人们的美好生活为目标,CPU负载高点 内存不够就再扩点,伴随着工业4.0时代的发展,物理资源的限制不再成为我们的卡点。
Go的GC机制是不断进化提升的,到现在也没有停止。其进化过程中主要有几个重要的里程碑:
- 1.1: 标记+清楚,整个过程需要STW(挂起所有用户goroutine)
- 1.3:标记过程STW,清除过程并行
- 1.5:标记过程使用三色标记法
- 1.8:Hibrid Write Barrier(混合写屏障)
- 。。。:类似JVM的分代机制
-
标记清除
垃圾回收的算法很多,比如最常见的引用计数,节点复制等等。Go采用的是标记清除方式。当GC开始时,从 root 开始一层层扫描,这里的root取当前所有 goroutine 的栈和全局数据区的变量(主要是这两个地方)。扫描过程中把能被触达的 object 标记出来,那么堆空间未被标记的 object 就是垃圾了;最后遍历堆空间所有 object 对垃圾(未标记)的object 进行清除,清除完成则表示 GC 完成。 清除的 object 会被放回到 mcache 中以备后续分配使用。
在Go 内存管理提到过,Go的内存区域中有一个
bitmap
区域,就是用来存储 object 标记的。最开始 Go的整个GC 过程需要STW,因为用户进程如果在GC过程中修改了变量的引用关系,可能会导致清理错误。举个例子,我们假设下面的变量堆空间:
A := new(struct { B *int })
如果GC已经扫描完了变量A,并对 A 和 B 进行了标记,如果没有STW,在执行清除之前,用户线程有可能会执行
A.B = new(int)
,那么这个新对象new(int)
会因为没有标记而被清除。Go GC的STW曾经是大家吐槽的焦点,因为它经常使你的系统卡住,造成几百毫秒的延迟。
-
并行清除
这个优化很简单,如上面所述,STW是为了阻止标记的错误,那么只需要对标记过程进行 STW,确保标记正确,清除过程是不需要STW的。
标记清除算法致命的缺点就在STW上,所以Go后期的很多优化都是针对STW能缩短它的时间,避免出现服务卡顿。
-
三色标记法
为了能让标记过程也并行,Go采用了三色标记+写屏障的机制。它的步骤大致如下:
- GC 开始时,认为所有object都是白色,即垃圾
- 从root区开始遍历,被触达的object置成灰色
- 遍历所有灰色object,将他们内部的引用变量置成灰色,自身置成黑色
- 循环第3步,直到没有灰色object了,只剩下了黑白两种,白色的都是垃圾
- 对于黑色object,如果在标记期间发生了写操作,写屏障会在真正赋值前将新对象标记为灰色
- 标记过程中,
malloc
新分配的 object,会先被标记成黑色再返回
示意图:
还有一种情况:
标记过程中,堆上的 object 被赋值给了一个栈上指针,导致这个 object 没有被标记到。因为对栈上指针进行写入,写屏障是检测不到的(实际上并不是做不到,而是代价非常高,写屏障故意没有去管它)。下图展示了整个流程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-khsxxmjP-1632489279669)(https://i.loli.net/2021/09/24/3HfLdcpZSxgUsbM.png)]
为了解决这个问题,标记的最后阶段,还会回头重新扫描一下所有的栈空间,确保没有遗漏。而这个过程就需要启动STW了,否则并发场景会使上述问题反复重现。
-
GC 流程图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZJX1AwgK-1632489279670)(https://i.loli.net/2021/09/24/saAUq75G9lTPy8i.png)]
- 正常情况下,写操作就是正常的赋值
- GC开始, 开启写屏障等准备工作,需要短暂的 STW
- Stack scan 阶段, 从全局空间和goroutine栈空间上收集变量
- Mark阶段, 执行上述的三色标记法,直到没有灰色对象
- Mark termination阶段, 开启STW,回头重新扫描root区域新变量,对他们进行标记
- Sweep阶段,关闭 STW 和写屏障,对白色对象进行清除
-
Hibrid Write Barrier
三色标记方式,需要再最后重新扫描一遍所有全局变量和goroutie栈空间,如果系统的 goroutine 很多,这个阶段耗时也会比较长,甚至会长达 100ms。毕竟 goroutine 很轻量,大型系统中,上百万的 goroutine 也是常有的事情。
1.8 版本引入了混合写屏障,其会在赋值前对旧数据置灰,再视情况对新值进行置灰,如图所示:
这样就不需要在最后回头重新扫描所有的 goroutine 的栈空间了,这使得整个 GC过程STW几乎可以忽略不计。
写屏障的伪代码:
writePointer(slot, ptr): // 1.8 之前 shade(ptr) *slot = ptr writePointer(slot, ptr): // 1.8 之后 shade(*slot) if current stack is grey: shade(ptr) *slot = ptr
混合写屏障会有一点小小的代价,就是上图中如果 C 没有赋值给 L,用户执行
B.next = nil
后,C 的确变成了垃圾,而我们却把它置灰了,使得C只能等到下一轮 GC 才能被回收了。GC 过程创建的新对象直接标记成黑色也会带来这个问题,即使新 object 在扫描结束前变成了垃圾,这次 GC 也不会回收它,只能等下轮。
-
何时出发GC
- 一般是当 heap 上的内存达到一定数值后,会触发一次GC,这个数值我们可以通过环境变量
GOGC
或者debug.SetGCPercent()
设置,默认是 100,表示当内存增长 100% 执行一次 GC。 - 每隔 2 分钟,如果期间没有触发 GC,也会强制触发一次
- 用户手动触发,调用
runtime.GC()
强制触发一次
- 一般是当 heap 上的内存达到一定数值后,会触发一次GC,这个数值我们可以通过环境变量
-
优化
- 扫描过程最多使用 25% 的CPU进行标记,这是为了尽可能降低 GC 过程对用户的影响。而如果 GC 未完成,下一轮 GC 又触发了,系统会等待上一轮 GC 结束。
- 对于 tiny 对象,标记阶段是直接标记成黑色了,没有灰色阶段。因为 tiny 对象不存放引用类型数据(指针)的,没必要标记成灰色再检查一遍。
-
小结
- GC 会不断演进,尽管现在的最新版本已经有了很大的提升,但 GC 还是大家吐槽的焦点之一。用户能做到的就是尽可能在代码上避开GC,比如尽量少用存在多级引用的数据结构
chan map[string][]*string
这种糟糕的数据结构。应用的层级越多,GC的成本也就越高。 - 估计Go 后续也会引入分代机制的,个人认为这会很大程度提升效率。之前的内存模型中提到过金字塔模型,分代机制本质上就是构造金字塔结构,将GC工作分成几级来完成。像JVM那样将内存分成新生代,老生代,永生代,不同生代投入不同的计算资源。
- 现在这样每次都要全局扫描所有对象,进行标记回收,效率确实不怎么高。
- 还有一种方法是直接申请一块大内存空间(大于32K),这样对于GC来说它就是一个
largespan
; 但对这个大空间的分配使用就需要我们自己写代码管理了,我们将会遇到和OS内存管理类似的问题,比如内存碎片、指针问题、并发问题等等,非常麻烦,写的不好性能反而会更差。好在已经有成熟的开源项目freecache和bigcache可直接使用。
- GC 会不断演进,尽管现在的最新版本已经有了很大的提升,但 GC 还是大家吐槽的焦点之一。用户能做到的就是尽可能在代码上避开GC,比如尽量少用存在多级引用的数据结构