为什么是 TCMalloc,而不是别的
提到内存分配器,很多人的第一反应是 malloc 或者 ptmalloc。在多线程环境下,传统 malloc 的全局锁会在高并发时迅速变成瓶颈,分配请求都要排队。TCMalloc 之所以能脱颖而出,核心思路是给每个线程一个私有的小对象缓存,绝大多数分配和释放都在本地完成,根本不用碰全局锁。这种以空间换时间、多级缓存分摊竞争的思路,天然适合高并发场景。

Go 从设计之初就要支持成千上万个 goroutine,分配器不能成为调度器之外的另一堵墙。因此 Go 在 runtime 里引入了一套与 TCMalloc 极其相似的分层内存池结构,并且针对 goroutine 调度和 GC 的回收机制做了不少定制。理解 TCMalloc 的设计思想,是打开 Go 内存分配器知识大门的钥匙。
TCMalloc 的三层架构和 Go 的对应物
TCMalloc 把内存管理分为三层:ThreadCache 是线程私有的小对象缓存,CentralCache 是全局中央缓存层,PageHeap 负责管理零散物理页和大块内存。每个线程分配小对象时先从 ThreadCache 的空闲列表里取,取不到时才去 CentralCache 批量搬一些对象过来,而大对象或页面级别的分配则直接找 PageHeap。
Go 的分配器几乎复刻了这套骨架,但做了语言级的改造。mcache 对应 ThreadCache,但粒度不是线程而是 P;mcentral 按 size class 维护空闲 span 链表;mheap 对应 PageHeap,管理堆上的页资源。下面这张表直观地展示了它们的映射关系。
| 概念 | TCMalloc | Go 分配器 |
|---|---|---|
| 本线程缓存 | ThreadCache | mcache(绑 P) |
| 中央缓存 | CentralCache | mcentral |
| 页级堆 | PageHeap | mheap |
| 对象大小分级 | Size Class | Size Class |
| 本线程缓存阈值 | 默认 256KB 以下 | 32KB 以下走 mcache |
| 大对象分配 | 直接 PageHeap | 直接 mheap |
| 回收驱动方式 | 线程缓存过大主动归还 | GC sweep 驱动回流 |
最关键的差异在绑定对象上。TCMalloc 的 ThreadCache 是线程粒度的,线程创建销毁以及调度都会影响缓存的有效性。Go 则把 mcache 绑定到 P,因为 P 是调度器里的虚拟处理器,同一时刻只能有一个 M 在它上面跑,所有权清晰,goroutine 切换不需要刷新缓存,线程阻塞也无关紧要。这样一来,缓存和调度器解耦,分配效率更稳定。
size class 的设计意图
无论是 TCMalloc 还是 Go,size class 都是这套体系的中枢。它把请求的内存大小映射到有限的几个固定档位,每个档位对应一个独立的空闲列表。如果不做分级,分配任意大小的对象就会面临大量外部碎片;如果每个大小都精确匹配,又将产生巨量的内部碎片。
Go 的 size class 表并不是简单的 2 的幂,很多档位之间跨度不一样,这是反复调优的结果。比如 16 字节、24 字节、32 字节、48 字节,跨度从 8 到 16 不等,就是为了在常见对象大小附近有更细的档位。当你调用 mallocgc 时,首先会根据请求大小计算出该走哪个 class,然后直接在对应列表里取对象。
这种设计让分配动作变得非常固定:一次指针弹出加一次指针偏移,没有任何复杂的内存搜索。代价是,分配 17 字节会拿到一个 24 字节的槽位,内部碎片率有时达到百分之二三十,但是与整体吞吐收益相比,这个代价是值得的。
一次内存分配在 Go 里到底怎么走
把流程简化一下,一次普通的小对象分配会是这个样子:
// 简化的 mallocgc 路径(实际代码复杂得多)
func mallocgc(size uintptr) unsafe.Pointer {
if size <= maxSmallSize {
class := sizeToClass(size)
c := getMCache()
if v := c.alloc(class); v != 0 {
return v
}
// 本地缓存不够,批量从 mcentral 获取 span
c.refillAndAlloc(class)
return c.lastAlloc
}
// 大对象直接向 mheap 要页
return mheap.allocSpan(size)
}
mallocgc 一开始会算出对应的 size class,然后去当前 P 的 mcache 里找对应的空闲对象。mcache 里每个 class 维护着一个空闲对象链表,取第一个节点就完成了分配。如果链表已经空了,mcache 会从 mcentral 取来一个完整的 span,然后从这个 span 里切出一个个大小相同的对象。正因为每次都批量获取一个 span,而不是单次要一个,锁竞争就被大幅平摊了。
等分配超过 32KB 时,小对象缓存就不适用了。Go 会直接走 mheap,按页分配 span,这类分配通常频率低、体积大,需要更加谨慎地处理页对齐和虚拟内存的映射。大对象走 mheap 还意味着它不经过 mcentral,所以它不被 size class 束缚,碎片控制完全靠页级分配器的算法保证。
Go 对 TCMalloc 做了哪些改造
虽然借鉴了 TCMalloc 的架构,Go 在实现上有几个非常明显的差异点。
第一,GC 直接参与内存回收。TCMalloc 的缓存回收主要靠分配器自身的启发式条件,比如线程缓存超过阈值时交还的对象给 central。Go 则把内存回收和垃圾回收牢牢绑在一起。GC 的 sweep 阶段会清理不再被引用的对象,清理完的 span 会还到 mcentral 或 mheap,空闲链表可能被重新组装。这意味着分配器的可用内存是多变的,管理者必须和 GC 的 mark/sweep 状态打交道。
第二,页的管理策略更复杂。TCMalloc 的 PageHeap 用按大小分类的 free list 和 range 树来管理页。Go 的 mheap 则引入了 heapArena 和 pageAlloc 结构,把整个堆抽象成一组 arena,用位图记录哪些页是可用的,这样在查找合适地址空间时更加灵活,也减少了碎片。尤其当 Go 服务长时间运行、堆里散落着大量不连续空洞时,这种设计的优势会更明显。
第三,大对象和小对象的边界不同。TCMalloc 通常把 256KB 以上的对象直接交给 PageHeap,而 Go 把这个阈值设置得小很多,32KB 以上就算大对象。这背后的考虑是,Go 的小对象分配路径非常快,一旦超过这个值,分配频率已经不高,走 mheap 不会造成明显损失。
容易被误解的细节
很多开发者初次接触 Go 内存分配器时,会基于 TCMalloc 的经验产生一些误判。我整理了几个比较常见的:
- Go 分配器并不是 TCMalloc 的克隆版。它只继承了分层缓存思想,具体的数据结构、并发模型和回收流程都和 GC 深度耦合,许多机制是 TCMalloc 没有的。
- mcache 并不等于“完全无锁”。单次分配确实无锁,但当 mcache 需要向 mcentral 索要 span 时,会进入 mcentral 的临界区,所以它只是让锁的开销变得很低,而不是消除了锁。
- size class 的映射不是简单的四舍五入。因为档位跨度不均匀,分配 25 字节可能落到 32 字节的 class,分配 32 字节却可能落到 48 字节,具体要看表。
- Go 的内存并不是用完就立刻还给操作系统。GC 清理出的 span 会留在分配器的缓存中,以便后续分配复用。只有长期空闲的资源才会被 mheap 考虑归还给 OS,这也是为什么 Go 服务的 RSS 经常比堆内活跃对象大。
关于最后一点,实际中有很多困惑。一个典型的场景是:服务在低峰期内存占用下降不明显,看起来像“内存泄漏”。其实只要堆内存处于 mcache、mcentral 或 mheap 的空闲列表里,它就仍然算作 runtime 持有的内存,不会被立即释放。如果你用 pprof 看堆对象,这些空闲内存不会出现在 inuse 里,但会在 Sys 中体现。要区分到底是真泄漏还是分配器缓存,需要看 HeapIdle 和 HeapReleased 的区别。
对日常调优的影响
理解分配器的内部逻辑,做性能调优才有方向感。举个真实的场景:一个处理大量小对象请求的服务,每个请求都会反复创建临时结构体。一开始团队习惯把所有小对象分配都放到堆上,结果 GC 压力高,停顿时间长。了解分配器后,他们开始注意逃逸分析,尽可能让短生命周期对象留在栈上,同时使用 sync.Pool 复用高频对象。这里 sync.Pool 本身也是利用 per-P 缓存降低竞争,但要注意它会被 GC 清空,并不是一个无限大的对象池。
另一个常见问题是容器内存限制。Go 服务部署在 256MB 内存的容器里,但程序运行久了 RSS 一直涨,最终被 OOM Kill。这可能不是因为堆对象真的占满,而是分配器在 mcache 和 mcentral 里缓存了大量 span。此时设置 GOMEMLIMIT=200MiB 或调用 debug.SetMemoryLimit(200 << 20) 可以给 GC 一个软限制。GC 会在评估后更积极地触发,帮助分配器归还缓存内存,让 RSS 稳定下来。这个机制正是建立在分配器与 GC 协作的基础上,没有内部的回收入口,光调 GOGC 往往不够直接。
如果你还想自己诊断分配热点,可以用 go test -bench 配合 go tool pprof -alloc_space 看每次分配的调用链,或者看 runtime.MemStats 的 HeapAlloc、HeapIdle、HeapReleased 字段,从宏观上判断当前分配器处于什么状态。注意 HeapReleased 代表真正还给 OS 的数量,如果你发现 HeapIdle 很高但 HeapReleased 很低,说明分配器还藏着很多待复用内存,并非真的出了故障。
最后说一句
回到最开始的问题:Go 的内存分配器里到底还有多少 TCMalloc?骨架是它的,血肉是 Go 自己的。多级缓存、按大小分类、批量搬移,这些 TCMalloc 的经典设计思想,在 Go 中得到了保留并重新定义为 runtime 的一部分。由于 GC 的存在,Go 分配器必须统筹分配和回收,让内存管理成为一个完整的闭环。
这也给了我们一个启示:学习分配器不能只看分配路径,还要看谁在还内存、什么条件下还。理解了这套协作机制,你才能准确判断一个内存问题到底是泄漏、是缓存,还是垃圾回收策略的自然现象。希望这篇文章能帮你把 TCMalloc 与 Go 分配器之间的关系梳理清楚,下次再看到 mcache 时,不只是知道它叫 mcache。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/620/