蛮荆

Go 内存管理概述

2023-06-13

TCMalloc

TCMalloc 是由 Google 开发的一种高效的、线程安全的内存分配器,采用多级分配算法和线程缓存来提升内存分配效率。 它具有现代化内存分配器的基本特征:减少内存碎片、在多核处理器能够规模扩展。

具体来说,TCMalloc 将内存分配视为一个分层并且递进的过程:

  1. 首先,它会将大块的内存分成若干小块,每个小块的空间大小都是 2 的幂次
  2. 然后,对于不同空间大小的内存请求,TCMalloc 会使用不同的分配策略:
    • 对于较小的内存请求,TCMalloc 会将内存分配到线程缓存中,以避免从全局内存池中分配产生的竞争
    • 对于较大的内存请求,TCMalloc 会直接从全局内存池中分配

本文主要来一下 Go 内存分配 的内部实现,相关文件目录为 $GOROOT/src/runtime,笔者的 Go 版本为 go1.19 linux/amd64

GMP 回顾

在分析具体的代码实现之前,先来回顾下 GMP 调度的概览图,这有助于我们更好地理解内存管理中多级缓存机制。

GMP 调度概览图

Go 语言的内存管理

Go 语言的底层内存分配实现方案参考了 TCMalloc, 通过多级缓存机制、 内存对象大小分类来完成不同的分配策略来提升性能。

多级缓存

  1. 线程缓存
  2. 中心缓存
  3. 页堆

Go 的内存对象大小分类

  1. 微对象: < 16B
  2. 小对象: >= 16B && <= 32 KB
  3. 大对象: > 32KB

内存分配时,会优先从 线程缓存 获取,当 线程缓存 没有足够的内存时,会尝试从 中心缓存 获取,如果申请内存超过 32KB 时,会直接从 页堆 获取。

多级缓存概览图

数据结构

常量

const (
	// 基础内存页大小 8KB
    pageSize = 8192 

    // 不同的平台对应不同的值
	// 64 位: 64MB
    heapArenaBytes = 1 << logHeapArenaBytes  

    // 64 位: goarch.PtrSize * 8 / 2 = 32
	// 64 位: heapArenaBytes / 32 = 64 * 1024 * 1024 / 32 = 2097152
	heapArenaBitmapBytes = heapArenaBytes / (goarch.PtrSize * 8 / 2)
	
	// 64 位: 8192
	pagesPerArena = heapArenaBytes / pageSize

	// 硬编码跨度类型数量
    numSpanClasses = 136

	//   OS               | FixedStack | NumStackOrders
	//   -----------------+------------+---------------
	//   linux/darwin/bsd | 2KB        | 4
	//   windows/32       | 4KB        | 3
	//   windows/64       | 8KB        | 2
	//   plan9            | 4KB        | 3
	_NumStackOrders = 4 - goarch.PtrSize/4*goos.IsWindows - 1*goos.IsPlan9
)

内存大小跨度类

定义在文件 sizeclasses.go 中,一共有 67 个跨度类。

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
//     1          8        8192     1024           0     87.50%          8
//     2         16        8192      512           0     43.75%         16
//     3         24        8192      341           8     29.24%          8
//     4         32        8192      256           0     21.88%         32
//     5         48        8192      170          32     31.52%         16
//     6         64        8192      128           0     23.44%         64
//     7         80        8192      102          32     19.07%         16

...

//    67      32768       32768        1           0     12.50%       8192

span 内存浪费计算公式

maxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)

class = 7 (上述表格第 7 行) 为例:

maxWaste := ((80-64-1)*102+32) / 8192 = 19.07%

对上面这个公式做一个简单说明:申请范围在 65B - 80B 之间的内存,都会分配 80B,这里假设出现的是极端情况,刚好申请了 65B, 那么每个分配对象会浪费 15B 内存,再加上尾部浪费的内存 (不够分配一个对象的内存),那么一个 页面 (Page) 浪费掉的内存等于 15 * 102 + 32 = 1562, 最后除以页面大小等于 1562 / 8192 = 19.07%

mspan 对象

mspan 对象表示 内存管理的基本单元,每个 mspan 表示大小为 8 KB, 数量为 npages 的一组连续内存页。

type mspan struct {
	next *mspan         // 下一个元素, 双向链表结构
	prev *mspan         // 上一个元素, 双向链表结构
	list *mSpanList     // Debug 时使用

	startAddr uintptr   // 起始地址
	npages    uintptr   // 页面数量
	
	// 开始扫描时的空闲对象索引
	// 每次分配从 freeindex 开始扫描 allocBits, 直到遇到表示空闲对象的 0, 然后调整 freeindex 的值
	// 如果 freeindex == nelem, 当前 span 已经没有空闲对象了
	freeindex uintptr
	
	nelems uintptr      // span 内存储的对象数量
	
	allocCache uint64   // allocBits 的补码,可以用于快速查找未使用的内存
	
	allocBits  *gcBits  // 是一个 bitmap, 用于标识当前 span 中已分配的对象
	gcmarkBits *gcBits  // 是一个 bitmap, 用于标识当前 span 中已标记的对象

	spanclass spanClass     // 跨度大小
	state     mSpanStateBox // 存储内存管理单元的状态
}

下面是一个跨度为 1, 单个对象大小为 8B, 对象数量为 1024,总空间大小为 8KB 的 mspan 对象。

跨度为 1, 大小为 8KB mspan 对象

mSpanList 对象

mSpanList 对象表示 mspan 对象的双向链表结构。

type mSpanList struct {
	first *mspan // 链表头节点
	last  *mspan // 链表尾节点
}

heapArena 对象

heapArena 对象表示内存管理的元数据,存储在 Go 堆内存之外,通过 mheap_.arenas 字段访问。

type heapArena struct {
	// bitmap 存储了区域中哪些地址保存了对象
	// 主要用于垃圾回收,两种标记方式: 
	//    1. 标记地址中是否有对象 
	//    2. 标记对象是否被 GC 标记
	bitmap [heapArenaBitmapBytes]byte

	// spans 存储了指向内存管理单元的指针
	// 多个数组元素可能指向同一个 mspan 对象指针
	spans [pagesPerArena]*mspan

	// pageInUse 是一个 bitmap, 用于标识哪些 span 状态为 mSpanInUse
	// 该 bitmap 根据页面编号进行索引,但只使用每个 span 中与第一页对应的位
	pageInUse [pagesPerArena / 8]uint8

	// pageMarks 是一个 bitmap, 用于标识哪些 span 有标记对象
	pageMarks [pagesPerArena / 8]uint8

	// pageSpecials 是一个 bitmap, 用于标识哪些 span 有特殊标记
	pageSpecials [pagesPerArena / 8]uint8
	
	checkmarks *checkmarksMap
	
	// 指向该对象管理的内存起始地址
	zeroedBase uintptr
}
  • bitmap 字段数据类型为数组,一共 2097152 个元素,每个元素可以表示 32 bit 内存,一共可以表示 64MB 内存
  • spans 字段数据类型为数组,一共 8192 个元素,每个元素表示 8KB 内存,一共可以表示 64MB 内存

heapArena 数据结构

mcache 对象

mcache 对象表示 线程缓存,每个处理器 P 都会分配一个 mcache 对象,用于处理 微对象小对象 的分配,因为是每个处理器独有的,不存在并发访问,所以访问时不需要加锁。

type mcache struct {
    // tiny 指向当前对象表示的内存块的起始地址,如果当前未分配内存块,则为 nil
	// tiny 指向堆内存,由于 mcache 是在非 GC 内存中,所以标记终止时调用 releaseAll 方法进行清除
	
	// tinyoffset 表示下一个空闲内存块的偏移量
	// tinyAllocs 表示持有当前对象的处理器 P 分配的微对象数量
	tiny       uintptr
	tinyoffset uintptr
	tinyAllocs uintptr

	alloc [numSpanClasses]*mspan // mspan 指针数组,元素数量: 136
}

mcache 数据结构

spanClass 数据类型

spanClass 类型表示 mspan 对象的跨度类的大小以及是否包含指针。

type spanClass uint8

// spanClass 最后 1 位表示是否包含指针
func (sc spanClass) noscan() bool {
    return sc&1 != 0
}

mcentral 对象

mcentral 对象表示指定跨度大小的 中心缓存,每个 mcentral 会管理某个跨度大小 (根据 spanclass 字段决定) 的内存。

type mcentral struct {
	spanclass spanClass
	partial [2]spanSet  // 空闲对象的 span 列表
	full    [2]spanSet  // 非空闲对象的 span 列表
}

spanSet 对象

spanSet 对象表示一个 mspan 对象的指针集合。

type spanSet struct {
	spineLock mutex          // 访问中心缓存时需要用到互斥锁
	spine     unsafe.Pointer // *[N]*spanSetBlock, accessed atomically
	spineLen  uintptr        // Spine array length, accessed atomically
	spineCap  uintptr        // Spine array cap, accessed under lock
}

mheap 对象

mheap 对象表示 全局内存分配概况,堆上初始化的所有对象都由该对象统一管理

type mheap struct {
    // 锁
	lock mutex  

	// allspans 切片保存已经创建的 mspan 对象指针, 每个 mspan 只会出现一次
	allspans []*mspan

	// 指向 heapArena 对象的二维数组 (表示整个虚拟内存的 heapArena 元数据)
	// 64位: 1 << arenaL1Bits = 1
	//       1 << arenaL2Bits = 4194304
	//      4194304 个 heapArena 对象, 每个 heapArena 对象可以表示 64 MB 内存,一共可以表示 256 TB 内存 (虚拟内存)
	arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena

	// 全局中心缓存空闲列表
	central [numSpanClasses]struct {
		mcentral mcentral
		pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
	}

	spanalloc             fixalloc // span* 分配器
	cachealloc            fixalloc // amcache* 分配器
	specialfinalizeralloc fixalloc // specialfinalizer* 分配器
	specialprofilealloc   fixalloc // specialprofile* 分配器
	specialReachableAlloc fixalloc // specialReachable 分配器
	arenaHintAlloc        fixalloc // arenaHints 分配器
}

mheap 数据结构

操作系统接口

操作系统提供的内存管理接口定义在文件: $GOROOT/src/runtime/mem.go,接口方法列表如下:

// 从操作系统中获取一大块可用的内存空间
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {}

// 通知操作系统虚拟内存对应的物理内存已经不再需要,可以重用物理内存
func sysUnused(v unsafe.Pointer, n uintptr) {}

// 通知操作系统应用程序需要使用该内存区域,保证内存区域可以安全访问
func sysUsed(v unsafe.Pointer, n, prepared uintptr) {

// 向操作系统给出一个提示,该内存区域使用 huge page 性能会更高
func sysHugePage(v unsafe.Pointer, n uintptr) {}

// 如果在分配过程中检测到内存不足错误 (OOM),无条件返回内存
func sysFree(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {}

// 将内存区域转换成保留状态,主要用于运行时的调试
func sysFault(v unsafe.Pointer, n uintptr) {}

// 保留操作系统中的一片内存区域,访问这片内存会触发异常
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {}

// 保证内存区域可以快速转换至就绪状态
func sysMap(v unsafe.Pointer, n uintptr, sysStat *sysMemStat) {}

内存管理接口具体的实现方是与平台相关的,实现在 $GOROOT/src/runtime/mem_* 文件中,以笔者使用的 Linux 为例,具体实现在 $GOROOT/src/runtime/mem_linux.go 文件中,其他平台以此类推。

内存分配

newobject 方法

所有分配到堆上的对象都会使用 newobject 方法分配内存。

func newobject(typ *_type) unsafe.Pointer {
	// 调用 mallocgc 方法
	return mallocgc(typ.size, typ, true)
}

mallocgc 方法

mallocgc 方法会分配指定大小的内存空间对象,内部会依次尝试从 线程缓存 中心缓存 页堆 上分配。

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
	
	// 判断申请的内存是否有指针
	noscan := typ == nil || typ.ptrdata == 0

    // maxTinySize : 16 B
    // maxSmallSize: 32 KB
	
	if size <= maxSmallSize {
		if noscan && size < maxTinySize {
			// 微对象分配
			// 依次尝试从线程缓存、中心缓存、堆上分配
			
			// 微对象分配器将几个小的分配请求合并到一个内存块中
			// 当所有子对象都不可访问时,释放产生的内存,子对象必须不包含指针,这确保了内存浪费是有限的
			
			// 微对象分配器分配的内存不能被显式释放
			// 微对象分配器的主要目标是小的字符串和逃逸变量
			// 在一个 json 基准测试中,性能提升了大约 12%, 堆大小减少了大约 20%
			
			...
			
			// 存在空闲的内存
			if off+size <= maxTinySize && c.tiny != 0 {
				// 将对象分配到现有的小块中,然后返回
                ...
			}
			
			// 没有空闲的内存
			// 分配一个新的块
            ...
			
			// 根据剩余的空间,决定是否需要将已有的小块替换为一个新的块
            ...
		} else {
			// 小对象分配
			// 确定分配对象的大小以及跨度大小
			// 依次尝试从线程缓存、中心缓存上分配
			...
		}
	} else {
		// 大对象分配,直接在堆上分配
        ...
	}

	// GC 期间分配的黑色对象
	// 所有对象都是 nil, 无需扫描
	...

	// 统计内存碎片
    ...
}

nextFreeFast 方法利用 mspan.allocCache 字段,快速查找参数 mspan 中一个空闲的对象,没有找到时返回 0。

func nextFreeFast(s *mspan) gclinkptr {
	...
}

线程缓存

创建线程缓存

初始化一个处理器 P 时,调用 allocmcache 方法初始化 线程缓存,并返回一个初始化后的 mcache 对象指针。

// 空 mspan 类型
var emptymspan mspan

func allocmcache() *mcache {
	var c *mcache
	
	...
	
	// 初始化后的 mspan 都是空的 emptymspan 类型
	for i := range c.alloc {
		c.alloc[i] = &emptymspan
	}
	c.nextSample = nextSample()
	return c
}

nextFree 方法尝试从 线程缓存 查找一个空闲的对象,如果没有找到,调用 refill 方法将一个可用的 mspan 填充进 线程缓存

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
	...
}

allocLarge 方法计算指定参数大小的 大对象 需要的内存页数,然后分配一个拥有指定页数的 mspan 对象指针。

func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
    ...
}

获取指定跨度大小的内存

refill 方法从 中心缓存 里面为 线程缓存 分配一个指定跨度大小的 mspan 对象进行填充,这个 mspan 至少有一个空闲对象。

func (c *mcache) refill(spc spanClass) {
	// 将当前 span 放入中心缓存
	s := c.alloc[spc]

	if s != &emptymspan {
        mheap_.central[spc].mcentral.uncacheSpan(s)
		...
    }
	...
	
	// 从中心缓存获取一个新的 span
	s = mheap_.central[spc].mcentral.cacheSpan()

	...

	c.alloc[spc] = s
}

中心缓存

获取新的 mspan 对象

线程缓存 没有足够的内存时,会调用 mcentral.cacheSpan 方法获取一个 mspan 对象指针。

func (c *mcentral) cacheSpan() *mspan {
	...
}

该方法会依次尝试从下列空间获取 mspan 对象指针:

  1. 从清理过的空闲对象中查找
  2. 从未清理过的空闲对象中查找
  3. 从未清理过的非空闲对象中查找可用的,同时调用 sweep 方法进行清理操作

如果上述 3 个方法都没有获取到 mspan, 说明 线程缓存 中暂时没有可用的缓存,接着会调用 mcentral.grow 方法向 中心缓存 申请一个 mspan

grow 方法根据 中心缓存 的跨度大小,计算需要的页数和内存大小,然后从堆上分配一个空的 mspan 对象并返回对象的指针。

func (c *mcentral) grow() *mspan {
    ...
}

内存分配流程

全局页堆

init 方法负责全局页堆的初始化。

func (h *mheap) init() {
	...
}

alloc 方法从 GC 完成后的的堆中分配 mspan 对象, 参数 spanclass 指定 span 对象的跨度大小以及是否需要扫描。

func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
	var s *mspan
	systemstack(func() {
		// 为了防止堆过度增长,在分配 N 个页面之前,需要至少清理和回收 N 个页面
		if !isSweepDone() {
			h.reclaim(npages)
		}
		s = h.allocSpan(npages, spanAllocHeap, spanclass)
	})
	return s
}

allocSpan 方法分配一个数量为 npages 的一组连续内存所对应的 mspan 对象指针。

func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
	...
}

grow 方法尝试申请数量至少为 npage 个内存空间对象,放入堆中。

源代码中,卡在代码行中间的注释很犀利 :-)

func (h *mheap) grow(npage uintptr) (uintptr, bool) {
	// 计算需要的内存大小和内存起始地址
    
	...
	
	// arena 没有足够的内存了,调用 sysAlloc 方法申请 
	// 这个卡在中间的注释很犀利
	if nBase > h.curArena.end || /* overflow */ end < h.curArena.base {
		av, asize := h.sysAlloc(ask)
        
		...

		if uintptr(av) == h.curArena.end {
			// 新申请的的内存和当前内存区域是连续的,直接拼接
			...
		} else {
			// 新申请的的内存和当前内存区域不连续
			// 将当前内存剩余空间切换到新内存空间
			...
		}
	}
	
    ...

	// 将要使用的内存空间状态从 Reserved 切换为 Prepared
	sysMap(unsafe.Pointer(v), nBase-v, &gcController.heapReleased)

	// 更新状态
	// 更新页面分配器对象数据,这部分空间可以再次分配
    
	...
}

小结

本文主要介绍了内存分配的设计思路和核心数据结构,同时简单描述了对象的内存分配逻辑流程,感兴趣的读者可以在此基础上深入探索具体方法的内部实现细节。

Reference

扩展阅读

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。