00 · 设计推演:从零造一个内存分配器
“Go 的分配器有 67 个 size class、三层结构、每 P 一个 mcache——这些都不是设计者的偏好,是被一个接一个的具体问题逼出来的。“
开场:先问”为什么不能直接用 mmap”
老陈:需求:分配任意大小的内存块,用完能释放。
小林:直接
mmap不行吗?老陈:可以。但先算两笔账。
第一笔:mmap 有多贵?
小林:……系统调用,大概几百纳秒?
老陈:对,约 500ns-1μs。对比一下:一次函数调用 2ns。
如果一个 HTTP 请求要分配 100 个对象呢?
小林:……50 微秒?那 QPS 最多两万。
老陈:第二笔账:mmap 的最小单位是什么?
小林:……页,4KB?
老陈:对。你只要 8 字节,它也给你 4KB。浪费 512 倍。
小林:那……要有一个”批发 + 零售”的中间层?
老陈:这就是内存分配器的全部意义。开始设计。
v0:直接 mmap
func Alloc(size int) []byte {
// 每次都向 OS 要
addr, _ := syscall.Mmap(-1, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS)
return addr
}能工作。 但前面算过了:
| # | 问题 | 数据 |
|---|---|---|
| 1 | 系统调用开销 | ~500ns/次,是函数调用的 250 倍 |
| 2 | 页粒度浪费 | 要 8 字节给 4096 字节,浪费 512 倍 |
| 3 | 无法精细回收 | munmap 也是页粒度 |
老陈:注意第 3 点。这是最容易被忽略的。
如果你 mmap 了 100 个 8 字节对象,它们可能散布在几个页里。 释放其中 99 个,一个页都还不了 OS——因为每个页里还剩一个在用。
这叫”碎片导致的无法归还”。后面的设计都会跟它斗争。
v1:批发——一次 mmap 一大块,自己切
改动:先向 OS 要一大块(比如 64MB),然后在里面切分。
type Arena struct {
data []byte
used int
}
func (a *Arena) Alloc(size int) []byte {
// ★ 简单的指针碰撞(bump)
off := a.used
a.used += size
return a.data[off : off+size]
}改进巨大:
分配: 从"系统调用"变成"一次加法"
~500ns → ~2ns,快 250 倍
失败:
| # | 问题 | 说明 |
|---|---|---|
| 1 | 不能单独释放 | 只能整体重置 |
| 2 | 内存只增不减 | 长期运行会耗尽 |
问题 1 值得展开——bump allocator 只能”整体重置”。
但它在特定场景是最佳选择:一次请求处理、一帧渲染、一个查询执行——对象生命周期一致。
老陈:这是全书第一次出现”用约束换性能”。
接受”对象生命周期必须一致”这个约束,换来 O(1) 分配 + 零碎片 + 完美局部性。
后面你会反复遇到这笔交易:
- 栈分配(生命周期 = 函数)
- Arena(生命周期 = 批次)
- 不可变段(生命周期 = 直到 compaction)
v2:空闲链表(Free List)
针对 v1 的问题 1,加上”单独释放”能力。
思路:把释放的块串成链表,分配时从链表里找。
type block struct {
next *block
size int
}
var freeList *block
func Alloc(size int) unsafe.Pointer {
// 遍历空闲链表,找第一个够大的(First Fit)
prev, cur := (*block)(nil), freeList
for cur != nil {
if cur.size >= size {
// 找到了,可能分裂
removeFromList(prev, cur, size)
return cur.data()
}
prev, cur = cur, cur.next
}
// 没有合适的,从 arena 切新的
return bumpAlloc(size)
}
func Free(ptr unsafe.Pointer) {
b := blockOf(ptr)
b.next = freeList // ★ 头插法
freeList = b
}改进:能单独释放了。
失败:
| # | 问题 | 数据 |
|---|---|---|
| 1 | 分配是 O(链表长度) | 链表长的时候慢 |
| 2 | ★ 外部碎片 | 下面详细说 |
问题 2 是致命的。 看一个真实测试:
4MB arena,反复随机分配/释放 1-200 字节:
结果: 分配失败 847 次
存活对象只占 123 KB
★ 4MB 内存,只用了 123KB 就分配不出东西了
为什么?
内存布局(■=已分配 □=空闲):
■□■■□■□■□□■■□■□■□□■■■□■□□□■□■■□■□■□□■■□■□■□□■■□■□■□□■
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
全是小空洞,每个都放不下新请求
空闲总量可能有 3.8MB,但不连续 → 不可用
老陈:外部碎片的本质是:不是”空间不够”,而是”空间不连续”。
这是理解后面所有设计的钥匙。
v3:Size Class + 分离空闲链表
核心洞察:碎片的根源是”块大小五花八门”。
那如果规定:块大小只能是固定的几种呢?
// 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 160, ...
var sizeClasses = []int{...}
func Alloc(size int) unsafe.Pointer {
cls := sizeToClass(size) // 找到 >= size 的最小 class
return allocFromClass(cls) // 每个 class 一条独立的空闲链表
}改进:
| 改进 | 原因 |
|---|---|
| 分配 O(1) | 直接取对应 class 链表的头部,不用遍历 |
| 无外部碎片 | 同一 class 的块大小相同,释放后可完美复用 |
| 释放 O(1) | 头插法 |
代价:内部碎片
要 20 字节,给 32 字节 → 浪费 12 字节
★ 这是"用内部碎片换外部碎片"
★ 关键设计问题:size class 怎么划分?
方案 A: 2 的幂(8, 16, 32, 64, 128...)
最坏浪费率: 接近 50%
(要 33 字节给 64 字节,浪费 48%)
size class 数量: 少
方案 B: 细粒度(8, 16, 24, 32, 48, 64, 80, 96...)
最坏浪费率: ~12.5%
size class 数量: 多
★ Go 选 B,共 67 档(8 字节 ~ 32KB)
为什么 Go 愿意用 67 档换 12.5% 的浪费率?
因为 size class 的代价是"管理开销"(每档一条链表),
而内部碎片的代价是"真实的内存浪费"。
内存比管理开销贵得多。
★ 而且注意 Go 的档位不是均匀分布:
8~128: 间隔 8 字节 ← 小对象多,分得细
128~512: 间隔 16 字节
512+: 间隔逐渐变粗
★ 设计原则: 让"最坏情况下的浪费率"大致恒定
老陈:这是”优化目标决定参数分布”的典型例子。
如果你的目标是”size class 数量最少”,你会用 2 的幂。 如果你的目标是”浪费率最低”,你会用细粒度。
Go 的目标是”浪费率恒定在一个可接受的水平”——这直接决定了 67 档这个数字和它的分布。
先明确优化目标,参数几乎是唯一确定的。
v4:三层缓存 + 每 P 一个 cache
针对并发:多线程同时分配,freeList 是共享的 → 要加锁 → 锁竞争。
实测: 8 核机器上,一个全局锁的分配器
吞吐随核数增加几乎不涨(甚至下降)
解法:层次化缓存
第 1 层: mcache(每个 P 一个,无锁)
分配时从这里取,O(1) 无锁
第 2 层: mcentral(每个 size class 一个,加锁)
mcache 空了,从这里批量补充
第 3 层: mheap(全局,加锁)
mcentral 空了,从这里要 span
第 4 层: OS(mmap)
mheap 空了,向 OS 要 64MB arena
★ 关键设计:为什么是三层而不是一层?
一层(全局锁):
每次分配都加锁 → 锁竞争是瓶颈
两层(per-thread + 全局):
大部分分配无锁 ✓
但"补充"操作仍然频繁
三层(per-P + per-class + 全局):
· mcache: 无锁,覆盖 99% 的分配
· mcentral: 按 size class 分锁 ★
· mheap: 全局锁,但极少走到
★ "按 size class 分锁"是关键
分配 32 字节的请求,没必要跟分配 4096 字节的请求抢同一把锁
这叫锁分片(Lock Sharding)
★ 为什么 mcache 绑定 P 而不是 M(OS 线程)?
① M 的数量是动态的
系统调用阻塞时会创建新的 M
一个 8 核机器可能有几百个 M
→ 每个 M 一个 mcache 会浪费大量内存
② P 的数量是固定的(= GOMAXPROCS)
→ 最多 GOMAXPROCS 个 mcache,内存可控
③ G 必须绑定 P 才能执行
→ 从 P 取 mcache 是免费的(P 已在本地缓存)
★ 核心思想: P 是"资源的所有权单位",M 只是"执行能力"
v5:微小对象优化(Tiny Allocator)
还剩一个问题:小于 16 字节的对象。
要分配一个 8 字节的 bool 指针:
size class 最小是 8 字节 → 看起来没问题?
但如果是一个 16 字节的结构体里放两个 8 字节指针:
→ 每个指针 8 字节,各自一个 span
→ 缓存局部性差,且 span 元数据开销大
Go 的解法:Tiny allocator —— 把多个微小对象(<16B 且不含指针)塞进同一个 16 字节块。
// 分配 3 个 int32(各 4 字节)
// 传统: 3 个 8 字节的块 = 24 字节
// Tiny: 塞进 1 个 16 字节块 = 16 字节★ 为什么要求”不含指针”?
因为含指针的对象需要被 GC 扫描。
如果把多个含指针对象塞进一个块,GC 就分不清块里哪些字节是指针。
不含指针的对象(int32、bool、float64):
GC 完全不用管 → 可以随意打包
老陈:这个限制体现了 Go 的一个设计原则:
“能利用的信息尽量利用,能用约束换来的优化尽量换。”
“不含指针”是编译期已知的信息——利用它,换来 33% 的空间节省和更好的局部性。
同样的思路出现在第 1 章第 05 节讲的:
[]byte、[]int64这类无指针类型,GC 完全不扫描。
v6:大对象怎么办?
超过 32KB 的对象不走 size class(因为 67 档已经到 32KB 了)。
直接走 mheap,分配整数个页。
func largeAlloc(size int) *mspan {
npages := (size + pageSize - 1) / pageSize
return mheap.alloc(npages)
}为什么是 32KB 这个阈值?
权衡:
· 阈值小 → size class 少,管理简单,但大对象走 mheap 频繁(慢)
· 阈值大 → size class 多,管理复杂,但更多对象能走快速路径
32KB 是实测的经验值(tcmalloc 也是类似的量级)
v7:还差什么
到这里,Go 的分配器基本成型。剩下的:
| 机制 | 解决的问题 |
|---|---|
| Scavenger | 后台把长期空闲的物理页还给 OS |
| Page reclaimer | 细粒度的页回收(Go 1.16+) |
| MemoryLimit | Go 1.19+ 软内存上限,容器环境必备 |
| span 的 age 字段 | 标记 span 空闲时长,辅助 scavenger 决策 |
★ 这一节真正的收获
需求: 分配任意大小内存,用完释放
│
├─ v0 直接 mmap
│ 失败: 系统调用 500ns;页粒度浪费 512 倍;★ 无法精细归还
↓
├─ v1 批发 + bump 指针 ← 快 250 倍
│ 失败: 不能单独释放;内存只增不减
↓
├─ v2 空闲链表
│ 失败: 分配 O(n);★ 外部碎片(4MB 只用 123KB 就失败)
↓
├─ v3 size class + 分离空闲链表 ← 用内部碎片换外部碎片
│ 失败: 多线程锁竞争
↓
├─ v4 三层缓存 + mcache 绑 P ← 层次化 + 锁分片
│ 剩下: 微小对象浪费;大对象路径
↓
├─ v5 Tiny allocator(无指针打包)
└─ v6 大对象走 mheap
五个可以迁移的思维模式
① 批发零售模式
向慢的下一层请求时,一次要一大批,然后在快的本层零售。 这在第 1 章出现过三次:CPU 缓存行、TLB、Buffer Pool。这是全书法则 #2。
② 用约束换性能
bump allocator 接受”生命周期一致”→ O(1) 分配。Tiny allocator 接受”不含指针”→ 空间打包。 每次问:“我能不能加一个限制,换来更简单的实现?”
③ 碎片有两种,交易方向相反
外部碎片(不连续)vs 内部碎片(分配了没用完)。 size class 是用后者换前者。理解这个交易,你就懂了所有分配器的核心设计。
④ 优化目标决定参数分布
67 个 size class 不是拍脑袋的——是”让最坏浪费率恒定在 12.5%“这个目标推出来的。 先明确目标,参数就唯一确定了。
⑤ 分层 + 分片解决并发
三层结构降低锁的触及频率;按 size class 分锁降低锁的粒度。 这是”降低锁竞争”的两个标准手段,适用于所有并发数据结构。
现在,轮到你了
练习:我们设计的分配器有一个隐含假设——单线程或每个 P 独立。
但如果一个 goroutine 在 P1 上分配了对象,然后被调度到 P2 上执行,最后在 P3 上释放呢?
提示(不要用,先自己想):
- 跨 P 释放会有什么问题?(想想 mcache 的设计前提)
- Go 是怎么处理的?(提示:释放时根据”对象属于哪个 span”来路由)
- 这会不会成为性能问题?什么情况下会?
- 如果是你设计,有没有别的方案?
老陈:答案是:Go 允许跨 P 释放。释放时通过地址找到对应的 span,再路由到正确的 mcentral。
代价是:跨 P 释放比同 P 释放慢(要走 mcentral 的锁)。
这也是为什么”对象在同一个 P 上分配和释放”是性能更优的模式—— 在极致优化的场景(比如高频交易系统),这个细节会造成可观测的差异。
如果你来设计,可以考虑:能不能让”分配时的 P”记录在对象里,释放时快速路由? 或者:能不能接受”延迟归还”(批量把空闲块还给原 P)?
下一节
这一节我们推导了分配器本身,下一节看一个真实事故——当分配器和硬件(NUMA)打架时会发生什么。
带着这个问题去读:分配器认为”内存是均匀的”,但硬件不这么认为。这个错配会带来什么后果?