Go语言内存分配器的实现

时间:2022-05-04
本文章向大家介绍Go语言内存分配器的实现,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

前面断断续续的写了3篇关于Go语言内存分配器的文章,分别是Go语言内存分配器设计、Go语言内存分配器-FixAlloc、Go语言内存分配器-MSpan,这3篇主要是本文的前戏,其实所有的内容本可以在一篇里写完的,但内容实在太多了,没精力一口气搞定。本文将把整个内存分配器的架构以及核心组件给详细的介绍一下,当然亲自对照着翻看一下代码才是王道。

内存布局结构图

我把整个核心代码的逻辑给抽象绘制出了这个内存布局图,它基本展示了Go语言内存分配器的整体结构以及部分细节(这结构图应该同样适用于tcmalloc)。从此结构图来看,内存分配器还是有一点小复杂的,但根据具体的逻辑层次可以拆成三个大模块——cache,central,heap,然后一个一个的模块分析下去,逻辑就显得特别清晰明了了。位于结构图最下边的Cache就是cache模块部分;central模块对应深蓝色部分的MCentral,central模块的逻辑结构很简单,所以结构图就没有详细的绘制了;Heap是结构图中的核心结构,对应heap模块,也可以看出来central是直接被Heap管理起来的,属于Heap的子模块。

在分析内存分配器这部分源码的时候,首先需要明确的是所有内存分配的入口,有了入口就可以从这里作为起点一条线的看下去,不会有太大的障碍。这个入口就是malloc.goc源文件中的runtime·mallocgc函数,这个入口函数的主要工作就是分配内存以及触发gc(本文将只介绍内存分配),在进入真正的分配内存之前,此入口函数还会判断请求的是小内存分配还是大内存分配(32k作为分界线);小内存分配将调用runtime·MCache_Alloc函数从Cache获取,而大内存分配调用runtime·MHeap_Alloc直接从Heap获取。入口函数过后,就会真正的进入到具体的内存分配过程中去了。

在真正进入内存分配过程之前,还需要了解一下整个内存分配器是如何创建的以及初始化成什么样子。完成内存分配器创建初始化的函数是runtime·mallocinit,看一下简化的源码:

void
runtime·mallocinit(void)
{
	// 创建mheap对象,这个是直接从操作系统分配内存。heap是全局的,所有线程共享,一个Go进程里只有一个heap。
	if((runtime·mheap = runtime·SysAlloc(sizeof(*runtime·mheap))) == nil)
		runtime·throw("runtime: cannot allocate heap metadata");

	// 64位平台,申请一大块内存地址保留区,后续所有page的申请都会从这个地址区里分配。这个区就是结构图中的arena。 
	if(sizeof(void*) == 8 && (limit == 0 || limit > (1<<30))) {
		arena_size = MaxMem;
		bitmap_size = arena_size / (sizeof(void*)*8/4);
		p = runtime·SysReserve((void*)(0x00c0ULL<<32), bitmap_size + arena_size);
	}

	// 初始化好heap的arena以及bitmap。
	runtime·mheap->bitmap = p;
	runtime·mheap->arena_start = p + bitmap_size;
	runtime·mheap->arena_used = runtime·mheap->arena_start;
	runtime·mheap->arena_end = runtime·mheap->arena_start + arena_size;

	// 初始化heap的其他内部结构,如:spanalloc、cacachealloc都FixAlloc的初始化,free、large字段都是挂载维护span的双向循环链表。
	runtime·MHeap_Init(runtime·mheap, runtime·SysAlloc);
	
	// 从heap的cachealloc从分配MCache,挂在一个线程上。
	m->mcache = runtime·allocmcache();
}

初始化过程主要是在折腾mcache和mheap两个部分,而mcentral在实际逻辑中是属于mheap的子模块,所以初始化过程就没明确的体现出来,这和我绘制的结构图由两大块构造相对应。heap是所有底层线程共享的;而cache是每个线程都分别拥有一个,是独享的。在64位平台,heap从操作系统申请的内存地址保留区只有136G,其中bitmap需要8G空间,因此真正可申请的内存就是128G。当然128G在绝大多数情况都是够用的,但我所知道的还是有个别特殊应用的单机内存是超过128G的。

下面按小内存分配的处理路径,从cache到central到heap的过程详细介绍一下。

Cache

cache的实现主要在mcache.c源文件中,结构MCache定义在malloc.h中,从cache中申请内存的函数原型:

void *runtime·MCache_Alloc(MCache *c, int32 sizeclass, uintptr size, int32 zeroed)

参数size是需要申请的内存大小,需要知道的是这个size并不一定是我们申请内存的时候指定的大小,一般来说它会稍大于指定的大小。从结构图上可以清晰看到cache有一个0到n的list数组,list数组的每个单元挂载的是一个链表,链表的每个节点就是一块可用的内存,同一链表中的所有节点内存块都是大小相等的;但是不同链表的内存大小是不等的,也就是说list数组的一个单元存储的是一类固定大小的内存块,不同单元里存储的内存块大小是不等的。这就说明cache缓存的是不同类大小的内存对象,当然想申请的内存大小最接近于哪类缓存内存块时,就分配哪类内存块。list数组的0到n下标就是不同的sizeclass,n是一个固定值等于60,所以cache能够提供60类(0<sizeclass<61)不同大小的内存块。这60类大小是如何划分的,可以查看msize.c源文件。

runtime·MCache_Alloc分配内存的过程是,根据参数sizeclass从list数组中取出一个内存块链表,如果这个链表不为空,就直接把第一个节点返回即可;如果链表是空,说明cache中没有满足此类大小的缓存内存,这个时候就调用runtime·MCentral_AllocList从central中获取 一批 此类大小的内存块,再把第一个节点返回使用,其他剩余的内存块挂到这个链表上,为下一次分配做好缓存。

cache上的内存分配逻辑很简单,就是cache取不到就到central中去取。除了内存的分配外,cache上还存在很多的状态计数器,主要是用来统计内存的分配情况,比如:分配了多少内存,缓存了多少内存等等。这些状态计数器非常重要,可以用来监控我们程序的内存管理情况以及profile等,runtime包里的MemStats类的数据就是来自这些底层的计数器。

cache的释放条件主要有两个,一是当某个内存块链表过长(>=256)时,就会截取此链表的一部分节点,返还给central;二是整个cache缓存的内存过大(>=1M),同样将每个链表返还一部分节点给central。

cache层在进行内存分配和释放操作的时候,是没有进行加锁的,也不需要加锁,因为cache是每个线程独享的。所以cache层的主要目的就是提高小内存的频繁分配释放速度。