Golang内存管理

2019-03-24

基础知识

虚拟内存

首先需要明确的是,内存分配器分配的不是物理内存,而是虚拟内存。

1
2
3
[root@VM_0_12_centos ~]# ps aux|grep docker
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 4982 0.0 0.0 194612 2632 ? Sl 08:55 0:00 /usr/bin/docker-proxy

如上,是linux中docker进程的资源使用情况,其中VSZ(进程虚拟内存大小)表示虚拟内存,RSS(常驻物理内存大小)表示物理内存。

对于64位的CPU,其可寻址2的64次方的地址,也就是可访问256TB大小的地址空间,但实际中的电脑内存大小是有限制的。所以虚拟内存出现了,当CPU要执行内存中的一条指令时,首先通过MMU(内存管理单元)把VMA(虚拟内存区域)中的逻辑地址转换为线性地址。由于虚拟内存太大难以管理,引入了页(page)的概念,将内存分为相对较小的页(通常4K),页时虚拟内存管理中最小的单位。

需要注意的是,MMU掌管虚拟内存到物理内存的映射表(Page Table),每一页包含一个PTE,同时MMU还有一个物理缓存TLB。

内存分配器

操作系统提供四种申请内存的方式:

  • mmap/munmap 分配/接触分配修复堵塞内存页
  • brk/sbrk 改变或者设置data字段的大小
  • madvise 建议操作系统如何管理内存
  • set_thread_area/get_thread_area 设置获取线程本地内存

一般程序调用使用brk(sbrk/mmap/madvise)来获得更多内存,内核仅更新虚拟内存区域(VMA),并没有实际的物理内存操作。

TCMalloc

tcmalloc是google推出的一种内存分配器,常见的内存分配器还有glibc的ptmalloc和google的jemalloc。tcmalloc有比ptmalloc和jemalloc更高的效率。tcmalloc减少了多线程程序中的锁,对于小对象分配,几乎没有锁,对于大对象分配,使用效率更高的自旋锁(spinlocks)。

TCMalloc原理

1
2
3
graph LR
C[Central Heap]-->B[Thread Cache]
C[Central Heap]-->A[Thread Cache]

对于小内存(<32K)的申请,使用线程的本地缓存,当线程内存不足时,从全局缓存(central heap)中申请更大的内存。大内存(>32K),直接从全局缓存(central heap)申请。其中小内存对象被映射为170个类型大小。

小对象的分配方法:

  1. 将该对象映射到相应的大小级别(size-class)。
  2. 在当前线程的本地缓存中查找对应类型大小的空闲内存列表(free list)。
  3. 如果找到空闲列表(free list)不为空,则删除列表的第一个对象,并返回给应用。

如果列表为空:

  1. 我们从central free list中获取这个类型大小的内存对象。(所有的线程共享一个central free list)
  2. 将获取到的对象列表放入线程本地缓存中。
  3. 返回新获取到的一个内存对象给应用。

如果central free list也为空:

  1. 从页面分配器分配一组页面。
  2. 将分配出来的页面分割为该类型大小的一系列内存对象。
  3. 将切割后的内存对象放入central free list中。
  4. 同上一部,将部分内存对象放到线程本地free list中。

大对象(>32K)由多个页(4K)组成,由全局页堆(central page heap)来处理。全局页堆也是由一系列的空闲列表数组组成的。

central page heap

比如大对象需要k页,全局页堆能满足:

  1. 在页堆的空闲列表中查找。
  2. 如果空闲列表为空,查找下一个空闲列表,依次如此。
  3. 直到查找到最后一个空闲列表。
  4. 如果仍没有找到,则向系统申请内存。
  5. 如果找到的结果大于k页,则将多余的页回插到页面堆存储中。

TCMalloc使用Span来管连续的分页,一个span可以包含多个连续的内存分页。Span可以是被分配的(allocated)或者未分配的(free).

  • 未分配时,span是页面堆中的一个链表
  • 被分配时,span要么作为一个交给应用的大对象,要么是分配给小对象的一系列的页。

span

以上就是TCMalloc的内存处理方式,go语言的内存分配和TCMalloc大致相似,但又有不同。

  • 局部缓存不是分给线程或者进程,而是分给P
  • GC的STW,并不是每个进程单独进行GC
  • span的管理更有效率

Go 内存分配器

Go中的内存分配分为3中情况:迷你(Tiny)大小的内存申请(<16b),小对象的申请(<32kB),大对象的申请(>32kB)。

迷你对象

当分配迷你的内存对象时:

  1. 在P(Processor)的mcache中查找迷你大小的内存对象。
  2. 根据新对象的大小将现有子对象(如果存在)的大小舍入为8,4或2个字节。
  3. 如果对象与现有的子对象匹配,则使用该对象。

如果上一步没有合适的:

  1. 在P(Processor)的mcache中查找相应的mspan
  2. 从mcache中获取新的mspan
  3. 扫描mspan的空闲bitmap,寻找空闲的位置
  4. 如果有空闲位置,则将其作为迷你对象

以上的步骤是不需要锁的。

如果mspan中没有空闲的位置:

  1. 从mcentral的mspan列表中获取新的满足类型大小要求的mspan
  2. 获取整个span,分摊了锁定mcentral的成本

如果mcentral的mspan列表为空:

  1. 从mheap中获取mspan来使用

如果mheap为空或者没有足够大小的页:

  1. 从操作系统中分配一组页面(至少1MB)
  2. 分配大量的页面分摊了向操作系统申请资源的消耗

小对象

分配小对象的内存时:

  1. 先确定对象匹配的类型大小(size class)
  2. 在P(Processor)的mcache中查找相应的mspan
  3. 扫描mspan的bitmap查找空闲的位置
  4. 如果mspan中有空闲的位置,分配给应用

以上步骤完全不需要锁

如果没有空闲的位置:

  1. 从mcentral的mspan列表中获取新的满足类型大小要求的mspan
  2. 获取整个span,分摊了锁定mcentral的成本

如果mcentral的mspan列表为空:

  1. 从mheap中获取mspan来使用

如果mheap为空或者没有足够大小的页:

  1. 从操作系统中分配一组页面(至少1MB)
  2. 分配大量的页面分摊了向操作系统申请资源的消耗

除了第一步和迷你对象分配的过程基本一致,和TCMalloc的分配方式类似。

大对象

大对象的内存由mheap直接分配,分配过程和TCMalloc类似,先确定大对象需要多少页,然后在mhaep的空闲列表中查找,如果没找到,则向系统申请内存;如果找到的结果比需要的大,则使用需要的页数,将剩余的页组成一个新的mspan回插到mheap中。

了解了内存分配的过程,我们再来看看内存分配中涉及到的几个数据结构。

数据结构

mspan

GO通过mspan来管理分页,mspan最小是8K。mspan是一个双端链表,包含了页起始地址,span类以及这个类中页的数量。其中的span有三种类型:

  1. idle(空闲)- span中没有对象,可以被释放给OS,或者被堆分配器,栈内存重用。
  2. in use(使用中)- span中至少有一个堆对象
  3. stack(栈)- 被用作goroutine的栈。这个span既可以存活在栈中,也可以存活在堆中

mspan

和TCMalloc不同,mspan有67种块。

mcache

对应TCMalloc中的线程本地缓存,Go内存分配器为每个逻辑处理器(Processor)提供一个本地线程缓存,也就是mcache。mcache中包含了所有类大小的msapn。

mcache

由于mcache是基于CPU存在的,从machae中获取内存时不需要使用锁。

从上面的步骤可以看出,微小对象、小对象分配时都是直接从mcache中相应大小的mspan申请内存,当mcache中没有可用空间时,会向mcentral申请分配新的mspan。

mcentral

mcentral收集了所有给定大小的span,每一个mcentral 都包含了两个 mspan 列表:

  1. empty mspanList- 没有空闲对象或者已经被 mcache 缓存的 mspans 列表。
  2. noempty mspanList- 所有空闲对象的 span 列表。

mcentral
mcentral没有可用内存时,会向mheap申请新的mspan

mheap

mheap是一个全局变量,管理Go种所有的虚拟地址空间,所有的 spans 都是通过 mheap_ 申请,所有申请过的 mspan 都会记录在 allspans。

mheap

mheap和TCMalloc中的页堆比较相似,有两个127长度的SpanList数组(一个free,一个busy,对应未分配和已分配),每个SpanList里面的mspan由1~127(_MaxMHeapList - 1)个page组成,比如free[3]是一个包含了3个页的mspan链表。还有两个mspan组成的链表(freelarge mSpanList和busylarge mSpanList),链表中的每个mspan的页数大于127。

mcentral没有可用内存时,会向操作系统申请新的内存页(至少1M)

如果需要申请更到的内存块(arena),会转向操作系统申请。

Arena

Go 的虚拟内存其实由一系列的 arena 构成,初始堆映射也是一个 arena,如 go 1.11.5采用了 64MB 的 arena 内存块。

当前 Go 内存分配器是按照程序需要逐步增加内存映射的,初始只预留留了一个 arena 的大小(约 64MB)。arenas集合组成了堆,Go中每个arena都是按照8KB的粒度进行管理的。
arena

总结

综上,Go内存管理的核心本质是:针对不同大小的内存对象,使用不同的内存结构,不同的缓存级别来分配内存;将从系统中获得的一块连续内存分割为多层次的cache,以减少锁的使用以提高内存分配效率;申请不同类大小的内存块来减少内存碎片,同时加速内存释放后的垃圾回收。

参考:

https://povilasv.me/go-memory-management/#fn-1784-7

https://weibo.com/ttarticle/p/show?id=2309404347690665261491#_0

http://legendtkl.com/2017/04/02/golang-alloc/