04 · 动手:用 Go 写一个内存分配器(上)
“
malloc你天天用,但你写过吗?“
开场:一个看似不可能的任务
老陈:给你一个任务——用 Go 写一个内存分配器。要求:能分配、能释放、不能泄漏、不能太慢。
小林:……Go 不是自带内存分配器吗?而且 Go 有 GC,我们自己管内存,不会跟 GC 打架吗?
老陈:Go 有 GC,恰恰是我们要自己管理内存的理由之一。 你想想:如果你要写一个数据库,Buffer Pool 里有 20GB 的页面,你希望 GC 每次都去扫描这 20GB 吗?
小林:……不希望。GC 会疯掉。
老陈:对。所以真实的做法是:向 OS 要一大块内存,然后自己在这块内存里做分配管理。 GC 只看到一个
[]byte,而且 Go 对不含指针的类型有特殊优化——根本不扫。小林:那”地址”怎么表示?没有指针的话……
老陈:用偏移量。 我们不用
*Object,而是用uint32表示”距离 arena 起始位置多少字节”。这样:GC 完全看不见内部结构(全是整数,没有指针);内存布局完全可控;还能把整个 arena dump 到磁盘。小林:这不就是……自己实现了一套指针?
老陈:对。而且这是数据库、游戏引擎、高性能中间件的标准做法。 TiDB 的 chunk、Redis 的 ziplist、很多序列化库的 arena,都是这么干的。开始写吧。
版本 1:Bump Allocator(最简单的分配器)
思路
维护一个指针,分配就往后推。像在纸上写字,写完一行接下一行。
初始状态
┌────────────────────────────────────────┐
│ │ arena (1MB)
└────────────────────────────────────────┘
▲ offset = 0
分配 32 字节后
┌────┬───────────────────────────────────┐
│ 32B│ │
└────┴───────────────────────────────────┘
▲ offset = 32
再分配 64 字节
┌────┬────────────┬──────────────────────┐
│ 32B│ 64B │ │
└────┴────────────┴──────────────────────┘
▲ offset = 96
实现
package main
// ============ 基础设施 ============
// Arena 是一大块连续内存,我们用偏移量(uint32)代替指针
type Arena struct {
data []byte
}
func NewArena(size int) *Arena {
return &Arena{data: make([]byte, size)}
}
func align8(n int) int { return (n + 7) &^ 7 }
// ============ 版本 1:Bump Allocator ============
type BumpAllocator struct {
arena *Arena
offset uint32
allocs int
}
func NewBump(arena *Arena) *BumpAllocator {
return &BumpAllocator{arena: arena}
}
// Alloc 返回分配到的内存偏移量,0 表示失败
func (b *BumpAllocator) Alloc(size int) uint32 {
size = align8(size)
if size == 0 {
size = 8
}
if b.offset+uint32(size) > uint32(len(b.arena.data)) {
return 0 // OOM
}
off := b.offset
b.offset += uint32(size)
b.allocs++
return off
}
// Free 什么都不做 —— 这就是它的最大问题
func (b *BumpAllocator) Free(off uint32) {
// no-op
}
func (b *BumpAllocator) Stats() (used, total int) {
return int(b.offset), len(b.arena.data)
}优点与致命缺陷
| 评价 | |
|---|---|
| 分配速度 | ⭐⭐⭐⭐⭐ 极快,就一次加法,O(1) |
| 空间局部性 | ⭐⭐⭐⭐⭐ 完美,连续分配的对象物理相邻 |
| 实现复杂度 | ⭐⭐⭐⭐⭐ 20 行 |
| 能否释放 | ❌ 完全不能 |
| 内存利用率 | ❌ 只要不重置,内存只增不减 |
老陈:别小看这个”不能释放”的分配器。它在真实系统里用得极广。
小林:真的吗?不能释放怎么能用?
老陈:想想什么场景下”不需要单个释放,只需要一次性全部释放”。
小林:……编译器的 AST?一次编译完就全扔了?
老陈:对。还有:Web 服务器的一次请求处理(请求进来,从 arena 分配所有临时对象,请求结束整个 arena 重置)、数据库的查询执行器(一个查询的所有中间结果)、游戏引擎的一帧渲染。
这种模式叫 Arena Allocation / Region-based Memory Management。核心洞察是——如果一批对象的生命周期相同,就不需要单独管理每个对象的生命周期。
小林:所以”不能释放”在某些场景下反而是优点?
老陈:对。它把 O(n) 次 free 变成 O(1) 次 reset。这是”用约束换性能”的经典案例——你接受了”生命周期必须一致”这个约束,换来了极致的分配性能和完美的局部性。
版本 2:Free List Allocator(能释放了)
思路
用一条链表把所有空闲块串起来。分配时从链表里找一块够大的,释放时把块插回链表。
关键技巧:侵入式链表(Intrusive Linked List)
空闲块的头部本身就是链表节点,不需要额外内存来存链表结构。
┌────────┬──────────────────┬────────┬─────────────┬──────────────┐
│ next|sz│ 已分配数据 │ next|sz│ 空闲块 │ next|sz │
└───┬────┴──────────────────┴───┬────┴─────────────┴───┬──────────┘
│ │ │
└───────────────────────────┴───────────────────────┘
空闲链表
块头 8 字节:低 4 字节 = 下一个空闲块偏移,高 4 字节 = 块大小(最高位标记”已分配”)。
实现
import "encoding/binary"
const headerSize = 8
type FLAllocator struct {
arena *Arena
freeList uint32 // 第一个空闲块的偏移,0 表示链表为空
bump uint32 // 从未使用过的区域的起点
total uint32
}
func NewFL(arena *Arena) *FLAllocator {
return &FLAllocator{
arena: arena,
bump: 8, // 偏移 0 保留为 "null"
total: uint32(len(arena.data)),
}
}
func (a *FLAllocator) sizeAt(off uint32) uint32 {
return binary.LittleEndian.Uint32(a.arena.data[off+4:])
}
func (a *FLAllocator) nextAt(off uint32) uint32 {
return binary.LittleEndian.Uint32(a.arena.data[off:])
}
func (a *FLAllocator) setHeader(off, next, size uint32) {
binary.LittleEndian.PutUint32(a.arena.data[off:], next)
binary.LittleEndian.PutUint32(a.arena.data[off+4:], size)
}
func (a *FLAllocator) Alloc(n int) uint32 {
need := uint32(align8(n + headerSize))
if need < 16 {
need = 16 // 最小块,保证释放后能放下 header
}
// 首次适应(First Fit):遍历空闲链表找够大的块
var prev, cur uint32 = 0, a.freeList
for cur != 0 {
if a.sizeAt(cur) >= need {
blkSize := a.sizeAt(cur)
next := a.nextAt(cur)
const minSplit = 32
if blkSize >= need+minSplit {
// 剩余空间够大,分裂
remain := blkSize - need
splitOff := cur + need
a.setHeader(splitOff, next, remain)
a.setHeader(cur, 0, need|0x80000000) // 标记已分配
if prev == 0 {
a.freeList = splitOff
} else {
a.setHeader(prev, splitOff, a.sizeAt(prev))
}
} else {
// 整块分配(避免产生太小的碎片)
a.setHeader(cur, 0, blkSize|0x80000000)
if prev == 0 {
a.freeList = next
} else {
a.setHeader(prev, next, a.sizeAt(prev))
}
}
return cur + headerSize // 返回数据区(跳过 header)
}
prev = cur
cur = a.nextAt(cur)
}
// 空闲链表里没有,从未使用区域 bump 一块
if a.bump+need > a.total {
return 0 // OOM
}
off := a.bump
a.bump += need
a.setHeader(off, 0, need|0x80000000)
return off + headerSize
}
func (a *FLAllocator) Free(ptr uint32) {
if ptr == 0 {
return
}
off := ptr - headerSize
size := a.sizeAt(off) &^ 0x80000000 // 清掉已分配标记
// 头插法插入空闲链表
a.setHeader(off, a.freeList, size)
a.freeList = off
}问题:外部碎片
跑一个压力测试:
func stressFL() {
arena := NewArena(4 << 20) // 4MB
alloc := NewFL(arena)
live := make([]uint32, 0)
failCount := 0
for round := 0; round < 2000; round++ {
for i := 0; i < 50; i++ {
size := 1 + rand.Intn(200)
p := alloc.Alloc(size)
if p == 0 {
failCount++
break
}
live = append(live, p)
}
// 随机释放一半
if len(live) > 20 {
for i := 0; i < len(live)/2; i++ {
j := rand.Intn(len(live))
alloc.Free(live[j])
live[j] = live[len(live)-1]
live = live[:len(live)-1]
}
}
}
used, _ := alloc.Stats()
fmt.Printf("分配失败次数: %d\n", failCount)
fmt.Printf("存活对象: %d\n", len(live))
fmt.Printf("bump 指针位置: %d / %d (%.1f%%)\n",
used, len(arena.data), float64(used)/float64(len(arena.data))*100)
}典型输出:
分配失败次数: 847
存活对象: 1234
bump 指针位置: 4194304 / 4194304 (100.0%)
分配失败了 847 次,而存活对象只占 1234 × 平均 100 字节 ≈ 123KB。4MB 内存,只用了 123KB 就分配失败了。
为什么?外部碎片(External Fragmentation)。
内存布局(■=已分配 □=空闲)
■□■■□■□■□□■■□■□■□□■■■□■□□□■□■■□■□■□□■■□■□■□□■■□■□■□□■
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
全是小空洞,每个都放不下新的大请求
空闲内存总量可能有 3.8MB,但被切成了成千上万个小块,没有任何一块能满足当前的分配请求。
老陈:这就是外部碎片的本质:不是”空间不够”,而是”空间不连续”。
小林:那怎么办?定期整理?
老陈:整理需要移动对象,移动对象需要更新所有指向它的引用——但在我们的设计里,引用是
uint32偏移量,散落在内存各处,你找不到它们。这就是”手动内存管理”的死穴。小林:所以这就是为什么需要 GC?
老陈:对。GC 的核心能力就是”追踪所有引用”。有了这个能力,才能做内存整理(compaction)。这是带 GC 的语言相对于手动管理语言的一个根本性优势——不是”不用写 free”,而是”可以做移动式 GC”。
小林:那 malloc 怎么解决这个问题?
老陈:malloc 不”解决”,它”缓解”。 用了一个非常聪明的办法——下一版就实现它。
版本 3:Size Class + Segregated Free List(工业级方案)
核心洞察
老陈:外部碎片的根源是什么?
小林:块大小不一,切来切去就碎了。
老陈:那如果我们规定:所有块的大小只能是固定的几种呢?
小林:比如只能分配 8、16、32、64 字节?那要分配 20 字节就浪费 12 字节……
老陈:是的,这叫内部碎片(Internal Fragmentation)。我们用内部碎片换外部碎片。这笔交易划算吗?
小林:……要看浪费多少?
老陈:关键问题。如果 size class 是”2 的幂”,最坏浪费接近 50%。如果是”8, 16, 24, 32, 48, 64, 80, 96…”这种细粒度分档,最坏浪费可以控制在 12.5% 以内。
小林:那 size class 多一点不就好了?
老陈:但 size class 越多,管理开销越大,每个 size class 都要有自己的空闲链表,小 size class 的链表可能大部分时间是空的,占用空间却照占。所以这是个权衡。
Go 的 size class 设计
Go 有 67 个 size class(Go 1.21),从 8 字节到 32KB:
class size class size class size
1 8 10 112 20 320
2 16 11 128 21 384
3 24 12 144 22 448
4 32 13 160 23 512
5 48 14 176 24 576
6 64 15 192 25 640
7 80 16 208 26 704
8 96 17 224 27 768
9 112 18 240 28 896
19 288 29 1024
67 32768
观察规律:小尺寸(8128)间隔 8 字节,浪费率 ≤ 12.5%;中等(128512)间隔 16 字节;大尺寸间隔逐渐变粗。
设计原则:让最坏情况下的内部碎片率大致恒定,而不是让 size class 数量最少。
实现
// ============ 版本 3:Size Class + Segregated Free List ============
// 简化的 size class 表(真实的 Go 有 67 档)
var sizeClasses = []int{
8, 16, 24, 32, 48, 64, 80, 96, 112, 128,
160, 192, 224, 256, 320, 384, 448, 512,
640, 768, 896, 1024, 1280, 1536, 1792, 2048,
2560, 3072, 3584, 4096, 5120, 6144, 7168, 8192,
10240, 12288, 14336, 16384, 20480, 24576, 28672, 32768,
}
func sizeToClass(size int) int {
for i, sc := range sizeClasses {
if size <= sc {
return i
}
}
return -1 // 超过最大 class,走大对象路径
}
type SegAlloc struct {
arena *Arena
freeLists []uint32 // 每个 size class 一条空闲链表
bump uint32
total uint32
stats struct {
allocCount int
internalWaste int64 // 内部碎片总量
}
}
func NewSeg(arena *Arena) *SegAlloc {
return &SegAlloc{
arena: arena,
freeLists: make([]uint32, len(sizeClasses)),
bump: 8,
total: uint32(len(arena.data)),
}
}
func (a *SegAlloc) Alloc(size int) uint32 {
cls := sizeToClass(size)
if cls < 0 {
return a.allocLarge(size)
}
blkSize := uint32(sizeClasses[cls])
// 1. 快速路径:从该 class 的空闲链表取(O(1),无链表遍历)
if a.freeLists[cls] != 0 {
off := a.freeLists[cls]
a.freeLists[cls] = binary.LittleEndian.Uint32(a.arena.data[off:])
a.stats.allocCount++
a.stats.internalWaste += int64(blkSize) - int64(size)
return off
}
// 2. 慢路径:从 bump 区域批量切一批
return a.refill(cls, blkSize, size)
}
func (a *SegAlloc) refill(cls int, blkSize uint32, size int) uint32 {
const batchCount = 16
batchSize := blkSize * batchCount
if a.bump+blkSize > a.total {
return 0 // OOM
}
// 尽可能批量切;空间不够就退化成切一块
count := batchCount
if a.bump+batchSize > a.total {
count = int((a.total - a.bump) / blkSize)
batchSize = blkSize * uint32(count)
}
if count <= 0 {
return 0
}
base := a.bump
a.bump += batchSize
// 把第 1 ~ count-1 块串成链表,第 0 块直接返回
for i := 1; i < count-1; i++ {
cur := base + uint32(i)*blkSize
next := base + uint32(i+1)*blkSize
binary.LittleEndian.PutUint32(a.arena.data[cur:], next)
}
if count > 1 {
binary.LittleEndian.PutUint32(
a.arena.data[base+uint32(count-1)*blkSize:], 0)
a.freeLists[cls] = base + blkSize
}
a.stats.allocCount++
a.stats.internalWaste += int64(blkSize) - int64(size)
return base
}
func (a *SegAlloc) Free(ptr uint32, size int) {
if ptr == 0 {
return
}
cls := sizeToClass(size)
if cls < 0 {
return
}
binary.LittleEndian.PutUint32(a.arena.data[ptr:], a.freeLists[cls])
a.freeLists[cls] = ptr
}
func (a *SegAlloc) allocLarge(size int) uint32 {
need := uint32(align8(size + 16))
if need%4096 != 0 {
need = (need/4096 + 1) * 4096
}
if a.bump+need > a.total {
return 0
}
off := a.bump
a.bump += need
return off + 16
}关键优化:批量分配
注意 refill 里一次切 16 块。
老陈:回想第 03 节讲的”层次化缓存”模式。这里同样适用:
- 从空闲链表取一块:O(1),最快
- 从 bump 切一批:O(16),但每 16 次分配才做一次
- 向 OS 要内存:最慢,几乎不发生
批量分配把”慢操作”的频率降低了 16 倍。这就是 Go 的
mcentral向mcache补充 span 时做的事——一次给一批,而不是一次给一个。
版本 4:Thread Cache(解决并发)
问题
上面的实现在多线程下有个致命问题:freeLists 是共享的,多个 goroutine 同时分配要加锁。
老陈:加个锁能工作,但你想想——64 核机器上每秒几百万次分配,全都要抢这一把锁。锁争抢的开销会远远超过分配本身。
小林:那怎么办?
老陈:分片。每个线程(Go 里是每个 P)有自己的小缓存,绝大多数分配从自己的缓存走,完全无锁。
小林:这不还是三层吗?
老陈:对,跟 CPU 的 L1/L2/L3/内存一模一样。你发现了吗——这就是同样一个模式,第三次出现了。
Go 的三层结构
┌─────────────────────────────────────────────────────────┐
│ mcache (per-P, 每个 P 一个) │
│ · 无锁,L1 级别 │
│ · 每个 size class 有独立的 free list │
└─────────────────────┬───────────────────────────────────┘
│ 批量补充(加锁)
┌─────────────────────▼───────────────────────────────────┐
│ mcentral (per size class, 全局) │
│ · 有锁(每个 size class 独立锁,不是全局锁) │
│ · 管理 span 的 full / partial / empty 三个链表 │
└─────────────────────┬───────────────────────────────────┘
│ 批量申请(加锁)
┌─────────────────────▼───────────────────────────────────┐
│ mheap (全局,管理 64MB 的 heap arena,向 OS mmap 要内存) │
└─────────────────────┬───────────────────────────────────┘
│ mmap
┌─────────────────────▼───────────────────────────────────┐
│ OS │
└─────────────────────────────────────────────────────────┘
为什么 mcentral 按 size class 分锁?
因为不同 size class 的分配请求互不相关。分配 32 字节的请求,没必要跟分配 4096 字节的请求抢同一把锁。这叫锁分片(Lock Sharding),是降低锁争抢的通用手段。
实现
// ============ 版本 4:Thread Cache ============
type CentralFreeList struct {
mu sync.Mutex
blocks []uint32 // 空闲块池(跨所有 thread cache 共享)
cls int
blkSize int
seg *SegAlloc
}
const (
batchSize = 32 // 一次从 central 批量取多少块
maxCacheSize = 256 // 本地缓存上限,超过就还给 central
)
type SegAllocMT struct {
*SegAlloc
centrals []*CentralFreeList
heapMu sync.Mutex
}
func NewSegMT(arena *Arena) *SegAllocMT {
s := &SegAllocMT{SegAlloc: NewSeg(arena)}
s.centrals = make([]*CentralFreeList, len(sizeClasses))
for i := range s.centrals {
s.centrals[i] = &CentralFreeList{cls: i, blkSize: sizeClasses[i], seg: s.SegAlloc}
}
return s
}
// 多协程安全的大块切分(原 SegAlloc 的 bump 需要加锁)
func (s *SegAllocMT) carve(cls int, count int) []uint32 {
blkSize := uint32(sizeClasses[cls])
s.heapMu.Lock()
defer s.heapMu.Unlock()
out := make([]uint32, 0, count)
for i := 0; i < count; i++ {
if s.bump+blkSize > s.total {
break
}
off := s.bump
s.bump += blkSize
out = append(out, off)
}
return out
}
type ThreadCache struct {
seg *SegAllocMT
freeList []uint32 // 每个 class 一条本地空闲链表
count []int // 每个 class 本地缓存了多少块
}
func NewThreadCache(seg *SegAllocMT) *ThreadCache {
return &ThreadCache{
seg: seg,
freeList: make([]uint32, len(sizeClasses)),
count: make([]int, len(sizeClasses)),
}
}
func (tc *ThreadCache) Alloc(size int) uint32 {
cls := sizeToClass(size)
if cls < 0 {
tc.seg.heapMu.Lock()
defer tc.seg.heapMu.Unlock()
return tc.seg.allocLarge(size)
}
// 快速路径:本地缓存有货,完全无锁
if tc.freeList[cls] != 0 {
off := tc.freeList[cls]
tc.freeList[cls] = binary.LittleEndian.Uint32(tc.seg.arena.data[off:])
tc.count[cls]--
return off
}
// 慢路径:从 central 批量补充
return tc.refill(cls)
}
func (tc *ThreadCache) refill(cls int) uint32 {
c := tc.seg.centrals[cls]
c.mu.Lock()
got := 0
var head uint32
for got < batchSize && len(c.blocks) > 0 {
off := c.blocks[len(c.blocks)-1]
c.blocks = c.blocks[:len(c.blocks)-1]
binary.LittleEndian.PutUint32(tc.seg.arena.data[off:], head)
head = off
got++
}
c.mu.Unlock()
if got == 0 {
// central 也空了,从堆上切一批
blocks := tc.seg.carve(cls, batchSize)
if len(blocks) == 0 {
return 0 // OOM
}
// 第一个留给自己,其余进本地链表
first := blocks[0]
head = 0
for i := len(blocks) - 1; i >= 1; i-- {
binary.LittleEndian.PutUint32(tc.seg.arena.data[blocks[i]:], head)
head = blocks[i]
}
tc.freeList[cls] = head
tc.count[cls] = len(blocks) - 1
return first
}
first := head
tc.freeList[cls] = binary.LittleEndian.Uint32(tc.seg.arena.data[first:])
tc.count[cls] = got - 1
return first
}
func (tc *ThreadCache) Free(ptr uint32, size int) {
cls := sizeToClass(size)
if cls < 0 {
return
}
binary.LittleEndian.PutUint32(tc.seg.arena.data[ptr:], tc.freeList[cls])
tc.freeList[cls] = ptr
tc.count[cls]++
// 本地缓存太多,还一部分给 central(防止某个线程囤积)
if tc.count[cls] > maxCacheSize {
tc.flush(cls, tc.count[cls]/2)
}
}
func (tc *ThreadCache) flush(cls int, n int) {
c := tc.seg.centrals[cls]
c.mu.Lock()
for i := 0; i < n && tc.freeList[cls] != 0; i++ {
off := tc.freeList[cls]
tc.freeList[cls] = binary.LittleEndian.Uint32(tc.seg.arena.data[off:])
c.blocks = append(c.blocks, off)
tc.count[cls]--
}
c.mu.Unlock()
}小结(上篇)
四个版本,对应四种设计取舍:
| 版本 | 分配复杂度 | 能否释放 | 碎片 | 并发 |
|---|---|---|---|---|
| Bump | O(1) | ❌ | 无 | 需锁 |
| Free List | O(n) | ✅ | 严重外部碎片 | 需锁 |
| Size Class | O(1) | ✅ | 内部碎片 <12.5% | 需锁 |
| Thread Cache | O(1) 无锁 | ✅ | 内部碎片 <12.5% | ✅ |
核心思想三条:
- 用约束换性能——接受”生命周期一致”的约束,就能用最简单的 bump allocator 替代复杂的 GC。
- 用内部碎片换外部碎片——size class 的本质是一笔交易。
- 层次化缓存——越快的路径越专用、越靠近使用者;慢的操作批量做。这个模式在 CPU 缓存、TLB、Go 分配器里反复出现。
下一节(下篇)
下篇会给出完整的 benchmark 对比(含与 Go 原生 make 的对比)、工业级分配器(tcmalloc / jemalloc / Go runtime)的差异分析,以及三道思考题。
老陈的预告:我们写的玩具分配器会比 Go 原生的
make快 3 倍。但我要告诉你——这恰恰证明了你不应在业务代码里用它。想知道为什么吗?下篇揭晓。