Go语言的整个内存管理子系统主要由两部分组成——内存分配器和垃圾收集器(gc)。十一小长假期为了避开我泱泱大国的人流高峰,于是在家宅了3天把Go语言的内存分配器部分的代码给研究了一番,总的来说还是非常酷的,自己也学到了不少的东西,就此记录分享一下。整个内存分配器完全是基于Google自家的tcmalloc的设计重新实现了一遍,因此,想看看Go语言的内存分配器实现的话,强烈建议先读一读tcmalloc的介绍文档,然后看看Go runtime的malloc.h源码文件的注释介绍,这样基本就大概了解Go语言内存分配器的设计了。
Go的内存分配器主要也是解决小对象的分配管理和多线程的内存分配问题。(后面提到的内存分配器都是指代的Go语言实现的内存分配器)。内存分配器以32k作为对象大小的定夺标准,小于等于32k的内存对象一律视为小对象,而大于32k的对象就是大对象了。为何是32k作为分界线呢?这个我也不知道,我觉得这是一个经验值吧,如果你知道有其他更加科学的理由,麻烦告知我一下。
内存分配器会将分配的小对象使用一个cache组件给缓存起来,只要是分配小对象就先到cache中查询一下,有空闲的内存就直接返回使用,不用向操作系统申请内存。内存分配器的这个cache组件可能同时存在多个,也就是每个实际线程都会有一个cache组件,这样一来,从cache里查询、获取空闲内存的时候就不需要加锁了,每次小对象的申请直接访问本线程对应的cache即可。我们再写程序的时候,其实绝大多数的内存申请都是小于32k的,属于小对象,因此这样的内存分配全部走本地cache,不用向操作系统申请显然是非常高效的。
有cache,必然就有cache不命中的情况,内存分配器在面对Cache查找不到空闲内存的时候,就会试图从Central中申请一批小对象内存到本地缓存住,这里的Central是所有线程共享的一个组件,不是独占的,因此需要加锁操作。我们需要知道Central组件其实也是一个缓存,但它缓存的不是小对象内存块,而是一组一组的内存page(一个page占4k大小)。如果Central中没有缓存的空闲内存page的话,就从Heap中申请内存来填充Central。当然对Heap的操作也是需要加锁,所有线程共享一个Heap。Heap中没有缓存的内存,当然就直接从操作系统拿取内存了。
小对象的内存分配是通过一级一级的缓存来实现的,目的就是为了提升内存分配释放的速度以及避免内存碎片等问题。大于32k的大对象内存分配就没这么麻烦了,不用一层一层的查询各个缓存组件,而是直接向Heap申请。上图是大概描述了一下整个内存分配器的组件结构,Cache、Central、Heap是三个核心组件,也是后面将重点分析的对象。
内存分配器的实现我需要拆成多篇文章来写,没精力一口气写完,其实按组件拆开写也方便阅读嘛。后面的文章将陆续写完各个核心组件。
题外话,在基础系统软件的世界里,内存管理是一个永恒的话题,所以存在tcmalloc和jemalloc这类非常优秀的内存分配器实现。据说,jemalloc在cpu核数较多的情况下,性能还要优于tcmalloc,但估计它们之间是不相伯仲的,主体设计都差不多。jemalloc也是纯C代码,应该是非常值得一看的。不知道为何,现在对C++项目,总有研究拖延症,没有强烈的动力去第一时间看源码。