小对象分配总从mcache开始,因为它是每个P私有的无锁缓存,≤32KB时直接按size class查本地alloc[],命中即返回;若耗尽则触发refill向mcentral加锁申请,易引发锁竞争。
小对象分配为什么总是先查 mcache
因为它是每个 p 私有的、无锁的缓存层。只要对象 ≤ 32kb,运行时就直接算出对应的 size class(比如 24b、48b),然后去当前 p 的 mcache.alloc[spc] 里找空闲块——命中就秒返回,不触发任何锁。
常见错误现象:pprof 显示 runtime.mallocgc 调用集中在少数 goroutine,但 CPU 使用率不高 → 很可能不是计算瓶颈,而是某个 P 的 mcache 频繁耗尽,被迫反复调用 refill 向 mcentral 申请新 mspan,卡在锁等待上。
实操建议:
-
mcache初始为空,第一次分配才会触发填充;它每size class通常只缓存 1–2 个mspan,不是无限兜底 - 字段重排或加
_ [0]bytepadding 让 struct 落入更紧凑的size class(例如从 56B 压到 48B),能显著提升mcache命中率 - 别混淆
mcache和sync.Pool:前者是运行时自动管理的底层缓存,后者是你代码里手动控制的对象复用机制
mcache.refill 触发后到底发生了什么
当 mcache 某个 size class 的 mspan 空了,就会调用 refill(spc),核心逻辑是:s := mheap_.central[spc].mcentral.cacheSpan() —— 即向对应 size class 的 mcentral 发起请求。
这个过程会触发真正的锁竞争,但锁粒度极细:mcentral 全局有 67×2 个实例(区分指针/非指针),每个只锁自己那一类 spanClass 的链表。也就是说,分配 24B 对象和分配 32B 对象完全不抢同一把锁。
容易踩的坑:
- 以为“高并发”就一定争锁 —— 实际只在「多 P 同时请求同一
size class」时才争,混用不同大小对象反而能摊薄竞争 - 盲目调大
GOMAXPROCS(比如设成 128)会让mcache实例暴增,单个 cache 命中率下降,refill 更频繁 -
cacheSpan()可能阻塞:若该mcentral的nonempty链表也空了,就得进一步向mheap申请新span,路径拉长、延迟上升
怎么确认你真被 mcentral 锁拖慢了
不能只看 GC 时间变长或堆增长快 —— 那些是结果,不是根因。得定位到分配器锁本身。
实操验证方式:
- 用
go tool pprof -http=:8080 ./binary打开火焰图,重点盯runtime.mcentral.cacheSpan和runtime.mcentral.fullSpan的占比。若持续 >5%,且集中在某几个size class(如sizeclass=12),就是信号 - 访问
http://localhost:6060/debug/pprof/goroutine?debug=2,搜索runtime.mcentral是否大量出现在 goroutine 的阻塞栈中(尤其是runtime.semasleep上游) - 对比
runtime.MemStats中的MCentralInuse和MCacheInuse:若前者增速明显快于后者,说明mcache命中失效频繁,refill 成了常态
真正棘手的从来不是锁本身,而是它背后暴露的分配模式:比如一个 HTTP handler 每次都 &struct{...} 出一个 64B 的临时对象,用完即弃 —— 这种「高频+短命+固定尺寸」组合,才是让 mcentral 成为瓶颈的根源。优化顺序永远是:砍掉不必要分配 > 复用(sync.Pool) > 对齐大小 > 调整 P 数。别让工具链替你掩盖设计缺陷。


















