《Go 语言原本》

12.1 设计原则

至此我们已看清 Go 程序如何启动、调度器如何把 goroutine 摊到操作系统线程上执行。 那些被调度的代码做的最频繁的一件事,是申请内存:每一次 new、每一个逃逸到堆上的局部变量、 每一次 slice 扩容,最终都落到同一个入口 runtime.mallocgc。本章讲这个入口背后的分配器, 本节先不碰它的零件,而是回答一个更靠前的问题:一个为 Go 服务的内存分配器,究竟要满足哪些 互相拉扯的目标,又是依凭哪几个判断把这些目标安顿下来的。读懂了这套取舍,后面几节 (12.212.6)的结构与路径就都有了来由。

12.1.1 四个互相拉扯的目标

先把范围划清。本章谈的「分配」专指堆分配,即 runtime.mallocgc 经手的那部分。栈上的 局部变量随函数返回自然回收,不归分配器管;只有逃逸到堆上的对象才进入这套机制。一个对象是 留在栈上还是逃逸到堆,由编译器的逃逸分析在编译期判定:

1
2
3
4
5
6
7
8
9
func f() *int {
    y := 2     // y 的地址被返回,逃逸到堆 → 编译器插入 newobject
    return &y
}

func g() int {
    x := 2     // x 不逃逸,留在栈上 → 不触发堆分配
    return x
}

f 中的 y 会被编译器改写为一次 runtime.newobject 调用(new 关键字同样落到这里), 而 newobject 只是带上类型信息调一下 mallocgc。换言之,本章讨论的全部分配路径,入口都是 这一个函数。逃逸分析的细节属于编译器(15),这里只需记住:进入分配器 的,是那些编译器判定无法安放在栈上的对象

明确了对象,再看分配器要满足的目标。一个通用内存分配器要同时讨好四方,而这四方彼此并不和睦:

  • 。分配在热路径上,一个服务每秒可能分配数百万次对象。单次分配若要走一趟加锁、 查表、系统调用,整个程序的吞吐就被它拖住。理想的分配应当是几条不加锁的指令。
  • 可扩展。Go 程序天生多核并发,几十上百个 goroutine 可能同时申请内存。若所有分配 共用一把全局锁,核数越多争用越烈,加机器反而更慢。分配器必须让并发的分配尽量互不相扰。
  • 低碎片。把对象塞进比它大的格子会浪费空间(内部碎片),把堆切得七零八落、留下大量 装不下大对象的空隙也是浪费(外部碎片)。一个长期运行的服务,碎片会随时间累积成实打实的 内存膨胀。
  • 与 GC 协作。Go 没有显式的 free,对象何时回收由垃圾回收器(13)判定。 这要求分配器在分配的同时就把回收所需的信息(这块内存是什么类型、含不含指针、是否存活) 一并记下,并且让分配的「节奏」能驱动 GC 适时启动。

这四者两两冲突。最快的做法是每个线程攥着一大块内存自管自用,但这样每个线程都囤一份, 碎片就上去了;最省内存的做法是所有对象挤在一个紧凑的全局结构里按需切分,但那必然要全局加锁, 可扩展性就没了。分配器的设计,本质上是在这张四角拉锯的网里找一个工程上的平衡点。Go 的答案 不是发明一套全新机制,而是站在一个成熟设计的肩膀上,再为 GC 这一角做专门的改造。

12.1.2 tcmalloc 的血脉:用每线程缓存换掉全局锁

Go 的分配器脱胎于 Google 的 tcmalloc(thread-caching malloc)[1]。tcmalloc 解决「快」与 「可扩展」这对矛盾的核心洞见只有一句:给每个线程一份本地缓存,让绝大多数分配在本地完成, 根本不碰全局锁。线程要分配时先看自己的缓存,命中就直接返回,是一串无锁操作;只有本地缓存 见底,才去摸那把保护全局结构的锁,而那把锁此刻是冷的,因为它很少被碰到。全局争用就这样 被「分摊到每个线程各自的本地」化解掉了。

这正是 Go 运行时反复使用的同一招式。调度器给每个 P 一条本地运行队列 (9.2),只有本地队列空了才去全局队列或别的 P 那里偷;sync.Pool 给每个 P 一份本地分片(11.6), 也是同一个思路。把全局争用拆成每核本地,是高并发运行时最常见的减争手法,读者在书中会一再 遇见它。

Go 在继承 tcmalloc 骨架时改了两处关键。其一,本地缓存不绑在操作系统线程上,而是绑在 P 上 (9.3)。因为同一时刻一个 P 只被一个 M 持有, 访问这份缓存天然无需加锁,且缓存数量随 GOMAXPROCS 而非线程数增长,更可控。其二,也是更 根本的一处:tcmalloc 服务于手动 malloc/free,而 Go 要服务于精确垃圾回收,于是 Go 在 tcmalloc 的每一层结构上都长出了一层 GC 元数据。可以说,Go 的分配器是「tcmalloc 的骨架, 加上为 GC 共生而生的血肉」,这条主线会在 13 与分配器合流。

把这套思路画成一条「代价梯度」,就是整个分配器的脊梁:越靠近 P 本地,命中越频繁、代价越低; 越往全局、往操作系统,代价越高、命中越稀。绝大多数分配止步于最左端那几条无锁指令,只有 逐级落空才向右付出更大的同步代价。

flowchart LR
    A["P 本地缓存<br/>无锁、位运算<br/>命中最频繁"] -->|本地见底| B["全局中心仓库<br/>按尺寸类加锁<br/>批量补货摊薄锁"]
    B -->|仓库见底| C["全局堆<br/>整堆加锁、按页切分"]
    C -->|堆不足| D["操作系统<br/>系统调用、按 MB 批发"]
    classDef cold fill:#eee,stroke:#999;
    class D cold;

这张图刻画的是代价的量级,12.2 会给每一层落实具体的组件(mcache、mcentral、 mheap)并画出请求在它们之间的分流路径。本节余下三小节先把支撑这条梯度的三个判断讲清: 怎样切分尺寸、怎样按对象分流、怎样与 GC 咬合。

12.1.3 尺寸类:用一点内部碎片换来位运算般的分配

把对象一律按其精确大小分配,回收时就会在堆上留下大小各异的空洞,下一次分配要在这些空洞里 找一个合适的,既慢又易留下装不下任何对象的外部碎片。tcmalloc 与 Go 采用的对策是 尺寸类(size class):预先划定一组离散的尺寸档位,分配时把请求向上取整到最近的档位, 同一档位的对象集中放进一段被切成等大槽位的连续内存(即 span,详见 12.2)。

go1.26 划定了 68 个尺寸类(含一个表示「零字节」的 0 号类,实际承载分配的有 67 个,定义在 internal/runtime/gc/sizeclasses.go),覆盖 8 字节到 32 KB。这样设计带来两个直接好处。 其一,分配退化成纯位运算:同一 span 内所有槽位等大,要分配只需在一张空闲位图里找一个 空位,是几条移位与计数指令,无需比较大小、无需遍历空洞(这条快路径见 12.2nextFreeFast)。其二,碎片可控:相邻档位按近似等比的间距 排布,一个对象向上取整到下一档,浪费的比例被这个间距压住。

代价是内部碎片,一个对象总要被塞进不小于它的格子。这里要替读者厘清一个常被误传的数字。 档位的设计目标是把取整造成的相对浪费控制在约 $1/8 = 12.5\%$ 以内,这是较大档位的间距 上界,源码注释里的 max waste 列印证了它,例如 1024 字节档(第 32 类)的最坏浪费是 12.40%:

1
2
3
4
5
6
// 摘自 sizeclasses.go 的生成注释(节选)
// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     5         48        8192      170          32     31.52%
//    10        128        8192       64           0     11.72%
//    18        256        8192       32           0      5.86%
//    32       1024        8192        8           0     12.40%

「分配退化成位运算」这句话值得落到实处。把请求大小映射到档位,本可以是一次除法或循环比较, 但分配在热路径上,连一次除法都嫌贵。运行时改用查表:预先生成两张索引表,覆盖小区间的 按 8 字节为步、覆盖大区间的按 128 字节为步,把「大小 → 尺寸类」压成一次数组下标:

1
2
3
4
5
6
7
// 大小 → 尺寸类:两张预生成的索引表,无除法、无循环(速写,取自 roundupsize)
func sizeToClass(size uintptr) uint8 {
    if size <= smallSizeMax-8 {                 // <= 1024,按 8 字节分桶
        return sizeToSizeClass8[divRoundUp(size, smallSizeDiv)]
    }
    return sizeToSizeClass128[divRoundUp(size-smallSizeMax, largeSizeDiv)] // 按 128 字节分桶
}

拿到尺寸类后,槽位大小、每 span 槽数等都是查 SizeClassToSize 之类的常量表即得;连「地址 落在 span 内第几个槽」这种本需除以槽长的计算,运行时也用预存的 SizeClassToDivMagic (形如 $\lceil 2^{32}/N \rceil$ 的魔数)把除法换成一次乘法加移位。整条小对象快路径上,因此 看不到一次真正的除法或遍历。

回到碎片。这个 12.5% 不是一个适用于全部档位的天花板。最小的几个档位为了对齐,相对浪费反而更高: 1 字节的请求要占满 8 字节的最小档,最坏浪费高达 87.5%。这并不矛盾,绝对浪费只有几个字节, 对齐换来的是更整齐的内存布局;max waste 列里还折入了 span 尾部切不满一格的「尾部浪费」。 准确的说法是:尺寸类把较大对象的取整浪费压在约 12.5% 的间距上界内,对极小对象则以较高的 相对浪费换取对齐与槽位均匀,而不是「碎片一律不超过 12.5%」。把这点说清,是为了让读者读 源码注释时不被那一列 87.5% 吓到。

12.1.4 三条对象路径:按大小与是否含指针分流

确定了「按尺寸类分配」之后,mallocgc 把所有请求按两个维度(大小、是否含指针)分成三条 路径。下面是裁剪掉实验开关与边角分支后的分派骨架,保留的正是这三条主干:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// mallocgc 的三路分派(速写,省去 sanitizer、实验开关等分支)
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    if size == 0 {
        return unsafe.Pointer(&zerobase) // 零大小对象共用一个哨兵地址
    }
    noscan := typ == nil || !typ.Pointers() // 不含指针 → GC 无需扫描
    if size <= maxSmallSize-mallocHeaderSize { // 约 32KB
        if noscan && size < maxTinySize {       // < 16B 且无指针
            return mallocgcTiny(size, typ)      // 路径一:微对象(见 12.6)
        }
        return mallocgcSmall(size, typ, noscan) // 路径二:小对象(见 12.5)
    }
    return mallocgcLarge(size, typ, needzero)   // 路径三:大对象(见 12.4)
}

三条路径各自回应一类对象的特点:

  • 微对象(小于 maxTinySize 即 16 字节、且不含指针,路径见 12.6)。 这类对象极小且数量惊人,典型是单字符的字符串、装箱的小整数。若每个都独占一格,开销主要 浪费在格子的取整上。于是分配器把多个微对象拼进同一个 16 字节块,块内紧凑摆放,直到 填满才换新块。它们必须不含指针,正因如此 GC 才能把整块当作一个无指针单元处理,不必区分 块内各对象的边界。
  • 小对象(16 字节至约 32 KB,路径见 12.5)。这是分配的主力,走的就是 12.1.2 与 12.1.3 描述的快路径:按尺寸类从当前 P 的本地缓存里取一个等大槽位。含指针与 不含指针的对象走略有差异的子路径,因为前者需要额外的指针位图供 GC 扫描。
  • 大对象(大于约 32 KB,路径见 12.4)。这类对象本就稀少,再为它们维护 每 P 缓存与尺寸类得不偿失,缓存里囤一个大对象反而占着大块内存不放。于是大对象绕过本地 缓存,直接向全局堆按页申请。它单次代价高,但因为频率低,那点加锁开销被分摊得无足轻重。

这条分界线上有个工程细节:阈值写作 maxSmallSize - mallocHeaderSize。go1.26 为含指针的 小对象在槽位前置了 8 字节的 mallocHeaderSize 头部以编码扫描信息,故含指针对象的实际上限 略低于 32768。就设计而言,把分界记成「约 32 KB」即可,这层细节留待 12.5

这个 8 字节头部本身是一处演进。早先 Go 把对象的指针位图集中存在 arena 一侧的位图区,扫描 对象要先按地址换算到 arena 位图里去查;Go 1.22 起改为把较小对象的指针信息内联进对象前的 这 8 字节头部(即上面的 mallocHeaderSize),扫描时数据与对象同处一处缓存行,局部性更好, 代价则是每个含指针小对象多占 8 字节。这类「把元数据从集中式挪到对象近旁以换缓存局部性」的 取舍,在 12.213 还会见到。

12.1.5 与 GC 共生:分配即记账

前四目标里,「与 GC 协作」是 Go 分配器区别于普通 malloc 的根本所在,它体现在两处。

其一是元数据。每一段被分配出去的内存,分配器都在它所属的 span 与 arena 上同步记下回收 所需的信息:这块属于哪个 span、是否含指针(指针位图)、是否存活(标记位图)。GC 因此能从 堆上任意一个地址反查出「它是不是指针、指向的对象是否还活着」,这是精确垃圾回收 (13)得以成立的前提。普通 malloc 不必操心这些,因为回收由程序员显式下令。 正是这层元数据,让 12.1.3 提到的含指针与不含指针对象要走不同子路径,也让微对象被强制要求 不含指针。

其二是节奏。GC 何时启动,不是定时触发,而是由分配的快慢决定:堆每涨到一个目标水位就 启动下一轮回收,目标水位又随上一轮存活量动态调整(13 的 pacing)。为此每次 分配都顺手做一笔记账,更新已分配字节数;当 GC 正在进行时,分配的 goroutine 还要按本次分配的 大小「就地」承担一份标记工作(mark assist),分得越快、协助越多,以免分配跑赢回收、堆失控 膨胀。换言之,在 Go 里分配本身就是 GC 的节拍器,这也是为何分配入口叫 mallocgc 而非 malloc,gc 这两个字母不是装饰。

把这五小节合起来看,Go 分配器的设计可以一句话收束:它是一套分层缓存,热路径在每 P 本地 做成无锁的位运算,把加锁与系统调用挡在越来越冷的后方,同时在每一层都编织进 GC 所需的 元数据与节奏。下一节 12.2 给这套分层结构里的零件命名、定位,把本节的 原则落成可以逐一对照源码的具体组件。

延伸阅读的文献

  1. Sanjay Ghemawat, Paul Menage. TCMalloc: Thread-Caching Malloc. https://google.github.io/tcmalloc/design.html (每线程缓存以避免全局锁争用的思想原型)
  2. Sanjay Ghemawat, Paul Menage. TCMalloc (gperftools) 原始设计文档. https://gperftools.github.io/gperftools/tcmalloc.html (Go 早期所参照的版本)
  3. The Go Authors. runtime/malloc.go. https://github.com/golang/go/blob/master/src/runtime/malloc.gomallocgc 三路分派入口)
  4. The Go Authors. internal/runtime/gc/sizeclasses.go. https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go (68 个尺寸类的取整与碎片上界,含 max waste 列)
  5. Jason Evans. A Scalable Concurrent malloc(3) Implementation for FreeBSD (jemalloc). BSDCan, 2006. (arena + tcache 的同构对照)
  6. The Go Authors. runtime: use a per-object header for scanning (Go 1.22). https://github.com/golang/go/issues/60130 (指针位图从 arena 集中式改为对象内联头部的演进)
  7. 本书 12.2 组件12.5 小对象分配12.6 微对象分配12.4 大对象分配.
  8. 本书 13 垃圾回收,分配器与 GC 共生关系的展开。

许可

© 2018-2026 The golang.design Initiative Authors. Licensed under CC-BY-NC-ND 4.0.