机缘巧合,参加了gopher china 2015,见识到各位大牛在go领域的实践经验,会上《go 学习笔记》作者“雨痕”在会上分享了go语言runtime的核心实现,应该是很难得的高质量的关于go runtime的分享了,并且ppt做得非常有品,应该是花了非常大的精力的,感谢@雨痕大侠的分享。本文就@雨痕的分享结合自己的理解谈谈go runtime 1.4。

go runtime 的三大核心组件为:

  • Memory Allocator 内存分配器
  • Garbage Collector 垃圾回收器
  • Goroutine Scheduler 协程调度器

抛开语法go语言语法层面的设计,程序写完之后放到服务器上跑,基本就是这三大核心组建接替完成接下来所有的工作。

1 Go 1.4 Runtime 内存分配器

go语言内存分配并没有使用linux系统原生的内存分配器,而是基于Google 自家的tcmalloc 结合自身的gc系统的设计重新实现了一遍,以实现更加高效的内存分配,回收等,实现内存的自主管理、缓存复用以及无锁分配。

操作系统的内存管理的基本单元都是以页(4K、8K),go 内存分配的基础是基于页的span(块),即多个地址连续的页组合成一个span,如下图所示:

span.jpg

页、Span是用来管理大块的内存的,不适合给对象分配内存的,所以在给对象分配内存的时候需要对大块内存进行切分。在go语言中对象以32K为边界把内存分为大小两种

smallandbig.jpg

  • 大对象:对于大块内存的申请和回收都不可以称之为碎片,没必要对其做特别的优化,另外一个原因是,对于大多数程序而言,大对象非常少,并且生命周期长,一般来说生命周期跟程序的生命周期是一样的,一直在复用的,直接放在heap托管即可。

  • 小对象:几十字节,几百字节,这种分配很容易导致内存碎片话,因为从1字节到32K有非常多的大小规格,所以直接按照其所需要的字节进行分配容易导致内存的复用率会非常差。

所以核心来说,内存分配器主要都是对小对象的分配进行优化。多数内存分配起都会选择按照8字节对齐分成n种等级的方式进行内存分配,因为处理器,指针,内存地址,还有结构题的对齐都是按照8,16对齐或者作为基本处理单元的。

小对象按照8字节对齐之后,32K大小的数据其实分成非常有限的几种。(ps:小于1K的按照8字节对齐,而大于1K的跳跃会比较大一点)

sizeclass.jpg

上面描述的内容是描述如何把内存(原始材料)根据不同的需求和大小划分为不同的大小等级,以下三级结构描述了如何对这些划分的材料进行合理的管理。三级结构示意图如下

treelevel.jpg

  • heap

heap层次做两件事情,在程序内存不够的时候向OS申请内存,并管理空闲的span,全局只有一个heap,所以这层锁事很重的。

  • central

小对象不会直接向堆heap申请内存,而是计算小对象的大小所属的size class,然后从对应的central上批量获取小对象,如果对应central已经分配完,会从heap获取span,把对应span内存全部切分成相同大小的size class,然后进行分配使用,并且管理未全部回收的span。从central层次获取也是要加锁了,但是相对heap而言,锁的粒度被分散到不同的central等级了。

  • cache

从central获取一批已经切分好的大小相等的对象链表,与运行时线程绑定,所有从cache上获取对象是无锁分配。(在并发编程中,最重要的事锁,锁的粒度控制得不好,会导致性能急剧下降)

上面介绍了内存的维度切分和基本的管理模式,但是这一套逻辑和算法(包括后续说的GC),依赖连续地址,所以内存分配器在初始化的时候需要预留比较大的地址空间,如下所示:

init.jpg

在初学go语言的时候,写一个hello world,发现这个程序的vm占用有130多G,实际上现代操作系统任何一个进程看到的实际上都是虚拟地址(VA),虚拟地址只有通过MMU的映射才能够分配实际的内存。在64位系统上面,是可以非常奢侈的使用PB级别的虚拟地址。

go的内存分配器在初始化的时候预留了一个很大的虚拟地址空间,以后所有的内存的分配使用的地址都使用这段地址,因为垃圾回收器和内存分配器依赖于连续地址。这样可以把这段地址用数组的方式进行管理(在寻址上最快的)

  • arena

所有的对象都在arena地址上进行分配,go 1.4最大能够分配的是128G,所有服务器物理内存超过128G时是没有意义的,可以启多个程序实例的方式。

  • bitmap

为arena区域每个地址分配4bit的管理位,用户垃圾回收

当前内存是否在做垃圾回收    
是否是可达对象
当前分配是否为“数组”
当前分配是否为“指针”
  • spans

用于具体的一个object反查对应所属的span块

分析一次内存分配

malloc.jpg

分析完以上内容之后,看一次完整的内存分配流程。
对于大对象,直接从堆(heap)上分配,对于小对象,比如 go程序中执行 myObject := &MyObject{},那么首先会从本线程对应的cache区域查找是否
存在对应size class的内存,如果存在则获取成功,失败,则查找central上是否存在对应size class的内存,如果存在,则获取一批缓存到自身的cache链表中,以备下次使用,如果不存在则需要直接向heap申请内存,heap中有空余的span可以分配,则直接分配给central,然后切分给cache,然后使用,如果heap也没有空余可使用的内存空间,则需要向OS申请内存,1MB起步,64K为最小的递增申请粒度。

分析内存回收过程

sweep.jpg

通过GC扫描之后(后续章节会介绍)标记为不可达的对象是可以回收的,如果是可回收的,并且是大对象,则直接归还给heap(堆),如果是小对象,则查找小对象对应的span,对应span的ref为0,即对应span上所有对象都在使用,则归还给 heap,如果还有空余对象可以使用,则可以继续放入central继续使用。


This article used CC-BY-SA-4.0 license, please follow it.