Memory

Golang内存

内存分配

概况

内存分配的算法有很多种,比如

  1. 线性分配器
  2. 空闲链表分配器

线性分配器

类似栈的分配,直接移动内存指针,简单高效,

缺点是

  1. 但无法回收中间部分的内存。
  2. 要回收的话,需要通过拷贝来回收,常用回收方法有
    1. 标记压缩(Mark-Compact)
    2. 复制回收(Copying GC)
    3. 分代回收(Generational GC)

线性分配器需要与具有拷贝特性的垃圾回收算法配合,一般比较费时,

此外,C/C++ 等需要直接对外暴露指针的语言就无法使用该策略。

空闲链表分配器

不同的内存块通过指针构成链表,所以可以无需通过拷贝来回收,直接回收那个链表节点即可。

空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的是以下四种:

  • 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
  • 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
  • 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
  • 最差适应算法(worst-fit)— 它从全部空闲区中找出能满足作业要求的、且大小最大的空闲分区,从而使链表中的节点大小趋于均匀。
  • 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;

前面4种都比较简单,适用简单情况,现在的分配器通常类似隔离适应算法。

隔离适应算法在Linux下的slab分配器也有应用。

多核并发

一般来说,现代内存分配都需要考虑并发问题,所以内存分配时,需要尽量减小锁的开销。

一般有一个总的缓存,需要加锁访问。

其子缓存,就是属于每一个核心的缓存,无需加锁访问。

这就是线程缓存(Thread-Caching Malloc,TCMalloc)来减少锁的竞争。

Go中的内存分配基本如此,使用多级大小的缓存讲对象根据大小分类,并按照类别实施不同的分配策略,搭配上线程缓存分配。

对象类别

在Golang中,按照大小,不同的对象可以分为

  • 微对象 (0, 16B)
  • 小对象 [16B, 32KB]
  • 大对象 (32KB, +∞)

不同对象有不同的分配策略。

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

微分配器

Go 语言运行时将小于 16 字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。

微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。

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

分配时,判断是微对象,就会判断微分配器的空间是否足够,足够则直接分配,不过该内存块只有所有对象都被标记为垃圾时才会回收。

分级分配

Go语言中,内存管理单元的具象化结构体是runtime.spanClass,其中含有一个 runtime.mspan ,它标志了该内存管理单元的级别(即管理的内存大小级别)。

type mspan struct {
	...
	spanclass   spanClass
	...
}

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

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

当然,既然是分级的分配,每一级都会有不同程度的浪费。例如,跨度类为 5 的 runtime.mspan 中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时,最多会浪费 31.52% 的资源:
$$
\frac{(48-33) * 170}{8192} = 0.31518
$$
除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。

多级缓存

三大组件

  • mheap

  • mcentral

  • mcache

mheap

Go 在程序启动时,首先会向操作系统申请一大块内存,并交由mheap结构全局管理。

具体怎么管理呢?mheap 会将这一大块内存,切分成不同规格的小内存块,我们称之为 mspan。

mcentral

启动一个 Go 程序,会初始化很多的 mcentral ,每个 mcentral 只负责管理一种特定规格的 mspan。

相当于 mcentral 实现了在 mheap 的基础上对 mspan 的精细化管理。

但是 mcentral 在 Go 程序中是全局可见的,因此如果每次协程来 mcentral 申请内存的时候,都需要加锁。

可以预想,如果每个协程都来 mcentral 申请内存,那频繁的加锁释放锁开销是非常大的。

因此需要有一个 mcentral 的二级代理来缓冲这种压力。

mcache

在一个 Go 程序里,每个线程M会绑定给一个处理器P,在单一粒度的时间里只能做多处理运行一个goroutine,每个P都会绑定一个叫 mcache 的本地缓存。

当需要进行内存分配时,当前运行的goroutine会从mcache中查找可用的mspan。从本地mcache里分配内存时不需要加锁,这种分配策略效率更高。

Go中内存布局如下

参考


Memory
https://messenger1th.github.io/2024/07/24/Golang/Memory/
作者
Epoch
发布于
2024年7月24日
许可协议