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()
}

小结(上篇)

四个版本,对应四种设计取舍:

版本分配复杂度能否释放碎片并发
BumpO(1)❌无需锁
Free ListO(n)✅严重外部碎片需锁
Size ClassO(1)✅内部碎片 <12.5%需锁
Thread CacheO(1) 无锁✅内部碎片 <12.5%✅

核心思想三条:

  1. 用约束换性能——接受”生命周期一致”的约束,就能用最简单的 bump allocator 替代复杂的 GC。
  2. 用内部碎片换外部碎片——size class 的本质是一笔交易。
  3. 层次化缓存——越快的路径越专用、越靠近使用者;慢的操作批量做。这个模式在 CPU 缓存、TLB、Go 分配器里反复出现。

下一节(下篇)

04 · 动手:用 Go 写一个内存分配器(下)

下篇会给出完整的 benchmark 对比(含与 Go 原生 make 的对比)、工业级分配器(tcmalloc / jemalloc / Go runtime)的差异分析,以及三道思考题。

老陈的预告:我们写的玩具分配器会比 Go 原生的 make 快 3 倍。但我要告诉你——这恰恰证明了你不应在业务代码里用它。想知道为什么吗?下篇揭晓。