本文用于持续记录 Go GC 相关技术学习笔记。

Go 内存分配器

内存分配器相关笔记,主要是运行时的 mallocinit 函数的初始化以及 mallocgc 函数的动态分配。

设计原则

Go 的内存分配器基于 Thread-Cache Malloc(tcmalloc),tcmalloc 为每个线程实现了一个本地缓存,区分了小对象(小于 32 KB)和大对象分配两种分配模式,其管理的内存单元称为 span。

Go 的内存分配器与 tcmalloc 存在一定差异。 这个差异来源于 Go 语言被设计为没有显式的内存分配与释放, 完全依靠编译器与运行时的配合来自动处理,因此也就造就了内存分配器、垃圾回收器两大组件。

传统意义上的栈被 Go 的运行时霸占,不开放给用户态代码;而传统意义上的堆内存,又被 Go 运行时划分为了两个部分, 一个是 Go 运行时自身所需的堆内存,即堆外内存;另一部分则用于 Go 用户态代码所使用的堆内存,也叫做 Go 堆。 Go 堆负责了用户态对象的存放以及 goroutine 的执行栈。

小于 32KB 内存块的分配策略

当程序里发生了 32KB 以下的小块内存申请时,Go 会从一个叫做 mcache 的本地缓存给程序分配内存。这个本地缓存 mcache 持有一系列的大小为 32KB 的内存块,这样的一个内存块叫做 mspan,它是要给程序分配内存时的分配单元。

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

为了避免浪费,mcache 持有的一系列 mspan 并不是同一大小,而是从 8 字节 到 32KB 分了大概70类。

无可用 mspan

当没有可分配的 mspan 时,会从 mcentral 中获取一个所需大小空间的新的 mspan,从 mcentral中分配会对其进行加锁, 但一次性获取整个 span 的过程均摊了对 mcentral 加锁的成本。mcentral 中有多个全局列表与每个规格的 mspan 对应,所以加锁不会影响其他规格的 mspan

如果 mcentralmspan 也为空时,则它也会发生增长,从而从 mheap 中获取一连串的页,作为一个新的 mspan 进行提供。mheap 里的 arena 区域是真正的堆区,运行时会将 8KB 看作一页,这些内存页中存储了所有在堆上的初始化的对象。

而如果 mheaparena 区域仍然为空,或者没有足够大的对象来进行分配时,则会从操作系统中分配一组新的页(至少 1MB), 从而均摊与操作系统沟通的成本。

大于 32KB 的内存块的分配策略

直接在 mheap 上分配对应数量的内存页。

主要结构

整理总结上面提到的主要结构:

  • **arena:**保留整个虚拟地址空间。
    • Go 堆被视为由多个arena 组成,每个 arena 在 64 位机器上为 64MB,且起始地址与 arena 的大小对齐,所有的 arena 覆盖了整个 Go 堆的地址空间。
  • **mheap:**分配的堆,在也大小为 8KB 的粒度上进行管理。
  • **mspan:**是 mheap 上管理的一连串的页。
    • mspan 是相同大小等级的 span 的双向链表的一个节点,每个节点还记录了自己的起始地址、 指向的 span 中页的数量。
  • **mcentral:**收集了给定大小等级的所有 span
  • **mcache:**per-P 的缓存。
    • 它是一个包含不同大小等级的 span 链表的数组,其中 mcache.alloc 的每一个数组元素 都是某一个特定大小的 mspan 的链表头指针。
mem-struct mem_struct

源码

TODO

总结

总结起来关于Go内存分配管理的策略有如下几点:

  • Go在程序启动时,会向操作系统申请一大块内存,由mheap结构全局管理。
  • Go内存管理的基本单元是mspan,每种mspan可以分配特定大小的object
  • mcache, mcentral, mheapGo内存管理的三大组件,mcache管理线程在本地缓存的mspanmcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。
  • 一般小对象通过mspan分配内存;大对象则直接由mheap分配内存。