go之内存分配器

🔗内存管理组件

🔗内存布局

const numSpanClasses = 68x2

//线程缓存
type mcache struct {
...
	alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
...
}

//中心缓存
//每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元。
type mcentral struct {
	spanclass spanClass //跨度类
	partial [2]spanSet // list of spans with a free object
	full    [2]spanSet // list of spans with no free objects
}

//页堆
type mheap struct {
...
	central [numSpanClasses]struct {
		mcentral mcentral
		pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
	}
...
}

//全局页堆
var mheap_ mheap

//从中心缓存中申请新的runtime.mspan存储到线程缓存中
func (c *mcache) refill(spc spanClass) {
	s := c.alloc[spc]
	s = mheap_.central[spc].mcentral.cacheSpan()
	c.alloc[spc] = s
}

图 7-10 Go 程序的内存布局

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器(P)都会分配一个线程缓存 [runtime.mcache] 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan

每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存。

在 amd64 的 Linux 操作系统上,runtime.mheap 会持有 4,194,304 runtime.heapArena,每个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。

🔗内存管理单元

type mspan struct {
   next *mspan     // next span in list, or nil if none
   prev *mspan     // previous span in list, or nil if none
   list *mSpanList // For debugging. TODO: Remove.

   startAddr uintptr // address of first byte of span aka s.base()
   npages    uintptr // number of pages in span

   allocCache uint64
   allocBits  *gcBits
   gcmarkBits *gcBits
   allocCount  uint16        // number of allocated objects
   spanclass   spanClass     // size class and noscan (uint8)
   。。。
}
// A spanClass represents the size class and noscan-ness of a span.
//
// Each size class has a noscan spanClass and a scan spanClass. The
// noscan spanClass contains only noscan objects, which do not contain
// pointers and thus do not need to be scanned by the garbage
// collector.
type spanClass uint8

每个 runtime.mspan 都管理 npages 个大小为 8KB 的页

🔗跨度类

Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_sizeruntime.class_to_allocnpages 等变量中:

const	_NumSizeClasses = 68 //68种跨度类,第一个为特殊的跨度类
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}//每种跨度类对应管理的对象大小
var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}//每种跨度类对应管理几个大小为8KB的页
classbytes/objbytes/spanobjectstail wastemax waste
1881921024087.50%
2168192512043.75%
3248192341029.24%
4328192256046.88%
54881921703231.52%
6648192128023.44%
78081921023219.07%
6732768327681012.50%

表 7-3 跨度类的数据

上表展示了对象大小从 8B 到 32KB,总共 67 种跨度类的大小、存储的对象数以及浪费的内存空间,以表中的第四个跨度类为例,跨度类为 5 的 runtime.mspan 中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页(每页8KB)进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时,最多会浪费 31.52% 的资源:

图 7-14 跨度类浪费的内存

除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。

🔗runtime.spanClass

runtime.spanClass是一个 uint8 类型的整数,它的前 7 位存储着跨度类的 ID(即class_to_size数组的下标),最后一位是一个 noscan 标记位,表示是否包含指针,垃圾回收会对包含指针的 runtime.mspan 结构体进行扫描

type spanClass uint8

const (
	numSpanClasses = _NumSizeClasses << 1
	tinySpanClass  = spanClass(tinySizeClass<<1 | 1)
)

func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

func (sc spanClass) sizeclass() int8 {
	return int8(sc >> 1)
}

func (sc spanClass) noscan() bool {
	return sc&1 != 0
}

🔗1、线程缓存 - 67x2类(单个Goroutine独享,不需要加锁,效率高)

[runtime.mcache]是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 68 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中。

图 7-15 线程缓存与内存管理单元

实际上67x2类

在Go的调度器模型里,每个线程M会绑定给一个处理器P,在单一粒度的时间里只能运行一个goroutine,每个P都会绑定一个上面说的本地缓存mcache。当需要进行内存分配时,当前运行的goroutine会从mcache中查找可用的mspan。从本地mcache里分配内存时不需要加锁,这种分配策略效率更高。

那么有人就会问了,有的变量很小就是数字,有的却是一个复杂的结构体,申请内存时都分给他们一个mspan这样的单元会不会产生浪费。其实mcache持有的这一系列的mspan并不都是统一大小的,而是按照大小,从8字节到32KB分了67类的msapn

🔗初始化

运行时在初始化处理器时会调用 runtime.allocmcache 初始化线程缓存,该函数会在系统栈中使用 runtime.mheap 中的线程缓存分配器初始化新的 runtime.mcache 结构体:

func allocmcache() *mcache {
	var c *mcache
  //systemstack 的作用是切换到 g0 栈执行作为参数的函数
	systemstack(func() {
		lock(&mheap_.lock)
		c = (*mcache)(mheap_.cachealloc.alloc())
		c.flushGen = mheap_.sweepgen
		unlock(&mheap_.lock)
	})
	for i := range c.alloc {
		c.alloc[i] = &emptymspan
	}
	c.nextSample = nextSample()
	return c
}

就像我们在上面提到的,初始化后的 runtime.mcache 中的所有 runtime.mspan 都是空的占位符 emptymspan

🔗替换

线程缓存中指定跨度类的mspan不够了,从中心缓存获取

runtime.mcache.refill 会为线程缓存获取一个指定跨度类的内存管理单元,被替换的单元不能包含空闲的内存空间,而获取的单元中需要至少包含一个空闲对象用于分配内存:

//mheap_为全局变量
func (c *mcache) refill(spc spanClass) {
	s := c.alloc[spc]
	s = mheap_.central[spc].mcentral.cacheSpan()
	c.alloc[spc] = s
}

如上述代码所示,该方法会从中心缓存中申请新的 runtime.mspan 存储到线程缓存中,这也是向线程缓存插入内存管理单元的唯一方法。

🔗微分配器

线程缓存中还包含几个用于分配微对象的字段,下面的这三个字段组成了微对象分配器,专门管理 16 字节以下的对象:

type mcache struct {
	tiny             uintptr
	tinyoffset       uintptr
	local_tinyallocs uintptr
}

微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny 会指向堆中的一片内存,tinyOffset 是下一个空闲内存所在的偏移量,最后的 local_tinyallocs 会记录内存分配器中分配的对象个数

🔗2、中心缓存 - 67x2类(所有Goroutine共享,需要加锁)

runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:

type mcentral struct {
	spanclass spanClass
	partial  [2]spanSet //list of spans with a free object 尚有空闲object的mspan链表
	full     [2]spanSet //list of spans with no free objects 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表
}

每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.spanSet,分别存储包含空闲对象不包含空闲对象的内存管理单元

mcentral的作用是为所有mcache提供切分好的mspan资源。每个central会持有一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。每个mcentral对应一种mspan,当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral 去获取。mcentral被所有的工作线程共同享有,存在多个goroutine竞争的情况,因此从mcentral获取资源时需要加锁。

从mcentral里申请特定大小的mspan

🔗3、页堆-大蛋糕

每页大小是8KB

页堆中包含一个长度为 136 的 runtime.mcentral 数组,其中 68 个为跨度类需要 scan 的中心缓存,另外的 68 个是 noscan 的中心缓存:

图 7-17 页堆与中心缓存列表

我们在设计原理一节中已经介绍过 Go 语言所有的内存空间都由如下所示的二维矩阵 runtime.heapArena 管理,这个二维矩阵管理的内存可以是不连续的:

🔗内存分配(临界点:16B,32KB)

堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	mp := acquirem()
	mp.mallocing = 1

	c := gomcache()
	var x unsafe.Pointer
	noscan := typ == nil || typ.ptrdata == 0
	if size <= maxSmallSize {
		if noscan && size < maxTinySize {
			// 微对象分配
      // 小于16B的非指针类型对象
		} else {
			// 小对象分配
      // 16B到32KB的对象以及所有小于16B的指针类型的对象
		}
	} else {
		  // 大对象分配
      // 大于32KB的对象
	}

	publicationBarrier()
	mp.mallocing = 0
	releasem(mp)

	return x
}

图 7-19 三种对象

  • 微对象(非指针类型对象) (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
  • 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存;
  • 大对象 (32KB, +∞) — 直接在堆上分配内存;

🔗微对象

Go 语言运行时将小于 16 字节的非指针类型对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。

微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小 maxTinySize 是可以调整的,在默认情况下,内存块的大小为 16 字节maxTinySize 的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize 越小,内存浪费就会越少,不过无论如何调整,8 的倍数都是一个很好的选择。

图 7-20 微分配器的工作原理

微对象分配步骤:

  • 如果当前块中还包含大小合适的空闲内存,运行时会通过基地址和偏移量获取并返回这块内存
  • 从线程缓存中找到跨度类对应的内存管理单元 runtime.mspan,调用 runtime.nextFreeFast 获取空闲的内存
  • 调用 runtime.mcache.nextFree 从中心缓存或者页堆中获取可分配的内存块
  • 返回新的16字节内存块,用于分配微对象

🔗小对象

小对象是指大小为 16 字节到 32,768 字节的对象以及所有小于 16 字节的指针类型的对象,小对象的分配可以被分成以下的三个步骤:

  1. 确定分配对象的大小以及跨度类 runtime.spanClass;

    func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    	...
    	if size <= maxSmallSize {
    		...
    		} else {
    			var sizeclass uint8
    			if size <= smallSizeMax-8 {
    				sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
    			} else {
    				sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
    			}
    			size = uintptr(class_to_size[sizeclass])
    			spc := makeSpanClass(sizeclass, noscan)
    
  2. 从线程缓存、中心缓存或者堆中获取内存管理单元并从内存管理单元找到空闲的内存空间;

  3. 调用 runtime.memclrNoHeapPointers 清空空闲内存中的所有数据;

🔗大对象

Go没法使用工作线程的本地缓存mcache和全局中心缓存mcentral上管理超过32KB的内存分配,所以对于那些超过32KB的内存申请,会直接从堆上(mheap)上分配对应的数量的内存页(每页大小是8KB)给程序。

  1. 内存分配器 https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/