noalloc:在 Golang 中强制执行零堆分配的工具与库
摘要
noalloc 是一个用 AI 编写的 Go 工具链,可通过 `//noalloc:root` 等指令检查函数调用树中的堆分配,并提供固定容量的零分配数据结构(含 JSON 编解码器)以帮助编写无堆分配代码。
查看缓存全文
缓存时间: 2026/10/29 16:36
theconsensuslabs/noalloc
来源:https://github.com/theconsensuslabs/noalloc
noalloc
免责声明:本仓库完全由 AI 编写。README 主要由 AI 撰写。由人类指导,并已在 The Consensus 的 Web 服务和代码搜索引擎中投入生产使用。这是一个实验性项目,可能会有大幅变化。
能写出不在堆上分配内存的代码是一回事,能在代码变更后继续保持不分配则是另一回事。使用 noalloc,你可以断言某个 Go 函数不应产生内存分配,noalloc CLI 会检查该函数整个调用树中的堆分配。noalloc 会告诉你每一处产生分配的代码行,让你可以修复或豁免这些行。noalloc 还提供了零分配的定长数据结构和算法(包括 JSON 编码器和解码器),让编写不分配内存的代码变得更简单、更干净。
go install github.com/theconsensuslabs/noalloc/cmd/noalloc@latest
你需要 Go 1.27 或更高版本。例如,用 //noalloc:root 标记一个函数,然后在其所在模块中运行 noalloc。
//noalloc:root
func (c *Counter) Add(name string, n int) string {
c.counts[name] += n
return name + "=" + strconv.Itoa(c.counts[name])
}
$ noalloc
counter.go:11: (*example.com/counter.Counter).Add: map write
counter.go:12: (*example.com/counter.Counter).Add: calls strconv.Itoa; string concatenation; escapes: name + "=" + ~r0 escapes to heap
noalloc 以 1 退出(发现了分配),或以 2 退出(完全无法运行)。它允许在结束根函数调用链的错误或 panic 路径上产生分配,也允许首次使用时的初始化操作,下面的注意事项中会详细说明。
指令
//noalloc:root检查一个函数及其调用的所有函数。//noalloc:exempt豁免它所在的那一行以及该行调用的所有内容。如果单独占一行,则适用于下一行。//noalloc:fixed标注在一个类型上,表示其方法从不增长其切片,因此 noalloc 会将其视为可复用内存。//noalloc:append标记一个行为类似append的函数,对它的调用会被当作对append的调用来判定。
复用内存是指在请求之间保留的内存,例如长期存活结构体中的缓冲区。只有在经过比较确认有足够空间后,才能对其进行 append 操作。你也可以用 -scratch pkg/path.Type 将某个类型的字段标记为复用内存。
当你调用模块外的函数时,如果该函数自身的代码不产生任何分配(noalloc 会从其源码推断,如果是汇编编写的或属于运行时的一部分,则从其编译后的代码推断),则该调用被允许。使用 -source(接受一个 import 路径前缀)以源码方式检查的包会被当作你模块的一部分来检查。用这种方式检查 noalloc 自身的库:-source github.com/theconsensuslabs/noalloc/bounded,对 encoding/json 也一样,这样它们的 //noalloc:fixed 类型和其他指令才会生效,否则在模块外部的代码中这些指令是不生效的。
运行 noalloc -h 查看所有标志。
使用 bounded 进行设计
bounded 提供了四个具有固定最大容量的容器:Buf、Vec、Slice 和 Map。当写入超出容量时,写入操作不会生效并将容器标记为已满,因此你可以在最后检查一次,而不是每次写入后都检查。
容器的内存可以是你自己的,也可以通过 Region 从 Pool 借用。
以下示例也包含在 example_test.go 中,go test 会验证它们的输出。
Buf
Buf 是一个字节缓冲区。用 Reset 赋予它内存,一旦某次写入放不下,它就保持已满状态:
var b bounded.Buf
b.Reset(make([]byte, 32), nil)
b.WriteString("id=")
b.WriteInt(42)
fmt.Println(string(b.Bytes()), b.Full()) // id=42 false
b.WriteString(strings.Repeat("x", 40))
fmt.Println(string(b.Bytes()), b.Full()) // id=42 true
如果你还给它一个 writer,它就会在填满时将内容刷入 writer:
var b bounded.Buf
b.Reset(make([]byte, 8), os.Stdout)
b.WriteString("hello, ")
b.WriteString("world\n") // 先刷出 "hello, "
b.Flush() // 输出 hello, world
Vec
Vec 是一个任意类型的列表。使用你赋予它的内存,它最多能容纳该内存容量所能放下的元素数量:
var v bounded.Vec[int]
v.Reset(make([]int, 0, 3))
for i := range 5 {
v.Push(i * i)
}
fmt.Println(v.Items(), v.Full()) // [0 1 4] true
使用 ResetFrom 时,它可以在填满过程中从池中借用内存块,最多不超过 region 的预算。它的元素不是连续存储的,因此用 At 读取,或用 Chunks 按块读取:
pool := bounded.NewPool[int](4, 8)
var r bounded.Region
r.Reset(2 * pool.ChunkBytes())
defer r.Release()
var v bounded.Vec[int]
v.ResetFrom(&r, pool)
for i := range 10 {
v.Push(i)
}
fmt.Println(v.Len(), v.Full(), v.At(6)) // 8 true 6
for c := range v.Chunks() {
fmt.Println(c) // [0 1 2 3],然后是 [4 5 6 7]
}
Slice
Slice 是连续存储的。当它填满一个内存块后,可以迁移到下一个更大池的内存块,就像 append 将切片迁移到更大的数组一样:
classes := bounded.NewClasses(bounded.NewPool[int](4, 8), bounded.NewPool[int](16, 8))
var r bounded.Region
r.Reset(1 << 10)
defer r.Release()
var s bounded.Slice[int]
s.ResetFrom(&r, classes)
for i := range 10 {
s.Push(i)
}
fmt.Println(s.Items(), s.Cap()) // [0 1 2 3 4 5 6 7 8 9] 16
Map
Map 最多容纳你用 Reset 指定的条目数量。超出该数量的新键不会被添加,但已有的键仍然可以更新:
var m bounded.Map[string, int]
m.Reset(2)
m.Set("a", 1)
m.Set("b", 2)
fmt.Println(m.Set("c", 3), m.Full()) // false true
v, ok := m.Get("a")
fmt.Println(v, ok, m.Len()) // 1 true 2
使用 Grow 时,它从小容量开始,随着填充可以迁移到更大的槽位池:
maps := bounded.NewMapClasses(
bounded.NewMapPool[string, int](4, 8),
bounded.NewMapPool[string, int](64, 8),
)
var r bounded.Region
r.Reset(1 << 20)
defer r.Release()
var m bounded.Map[string, int]
m.Grow(&r, maps)
for i := range 20 {
m.Set(strconv.Itoa(i), i)
}
fmt.Println(m.Len(), m.Limit()) // 20 64
Pool 和 Region
Pool 借出固定数量的内存块。当全部借出后,Take 返回 nil,直到某个 region 归还内存块为止:
pool := bounded.NewPool[byte](1<<10, 2)
var a, b bounded.Region
a.Reset(4 << 10)
b.Reset(4 << 10)
bounded.Take(&a, pool) // a 得到第一个块
bounded.Take(&a, pool) // 以及第二个
bounded.Take(&b, pool) // nil:池已空
a.Release()
bounded.Take(&b, pool) // b 得到 a 的其中一个块
b.Release()
st := pool.Stats()
fmt.Println(st.Lent, st.Empty) // 3 1
用 Wait 代替 Take 可以等待内存块直到 channel 关闭,用 Reserve 可以一次借多个或一个都不借。
处理动态输入
请求的输入大小直到输入到达才知道,因此在请求开始时给每个请求分配一个具有固定预算的 Region。在该预算内,它根据输入需要从共享的 Pool 借用内存块,请求结束后释放 region 即可归还所有内存块:
var rows = bounded.NewPool[byte](16<<10, 64)
// nowOnly 初始就是关闭状态,所以 Reserve 取一个空闲块而不会等待。
var nowOnly = func() chan struct{} {
c := make(chan struct{})
close(c)
return c
}()
func (p *page) serve(w io.Writer) error {
p.region.Reset(4 * rows.ChunkBytes())
defer p.region.Release()
if !bounded.Reserve(nowOnly, &p.region, rows, 1) {
return errBusy
}
p.out.ResetFrom(&p.region, rows, w)
for p.next() {
p.writeRow(&p.out)
}
p.out.Flush()
return p.out.Err()
}
这里页面通过一个填满即刷出的块进行流式传输,因此长页面不需要比短页面更多的内存。必须整体保存的输出可以改用 Buf.Borrow,它每次填满时都会迁移到更大的内存块,直到 region 的预算耗尽。
每一个可能耗尽内存预算的地方都需要有应对方案。例如,你可以:
- 提前结束页面,让客户端从游标位置继续。
- 在慢速路径仍能给出正确答案时跳过某个优化。
- 忘记备忘录中的某个条目。
- 在开始解析之前拒绝过大的输入。
- 通过刷入响应的
Buf流式输出,使其内存不随响应增长。 - 在准入控制放行请求时预留其启动所需的资源,并将池的大小设置为能容纳所有同时被放行的请求。
JSON 编解码
encoding/json 在不分配内存的情况下编解码 JSON,包括首次调用,方法由其 jsongen 命令为你的类型生成:
//go:generate go run github.com/theconsensuslabs/noalloc/cmd/jsongen -write Reply -read Request
WriteJSON 通过 Writer 写入,写入你赋予它并在多次调用之间保留的内存,填满时刷入 io.Writer:
var w json.Writer
w.Reset(mem, out)
reply.WriteJSON(&w)
if w.Flush() != nil {
return errReply
}
ReadJSON 通过 Reader 读取,从内存或从 io.Reader 通过你赋予它的缓冲区读取。字符串解码到 Reader 的 Arena 中(基于你的内存),填满时会调用其 More 获取另一个内存块,在你复用该内存之前一直有效。切片解码到其现有容量内,指针解码到其指向的目标;超出任一限制时,解码以 Full 失败而不是分配内存:
req := Request{Tags: make([]string, 0, 16)}
var r json.Reader
r.Arena.Reset(stringMemory)
r.ResetBytes(body)
req.ReadJSON(&r)
if r.Finish() != nil {
return errRequest
}
Writer 或 Reader 可以放在局部变量中,在那里不产生任何分配,但你赋予它的内存不行:Reset 保留了切片,这会将背后的数组移到堆上。
Err、Flush 和 Finish 返回的错误指向 Writer 或 Reader 内部,因此应与 nil 比较(如上面的代码),而不是返回或记录它,那样会将整个值移到堆上。
WriteJSON 写出的内容用标准库的 encoding/json 解码,与 json.Marshal 输出解码的结果相同;ReadJSON 解码的内容与 json.Unmarshal 一致,唯一区别是时间保留其原始时间点但以 UTC 解码。
jsongen 拒绝无法在不分配内存的情况下编解码的类型,例如 map 和 interface,并说明原因。
一个类型也可以自己实现 WriteJSON 或 ReadJSON,encoding/json/jsontest 会通过往返测试将这两种实现与标准库进行对比验证。
性能
以下是 jsongen 生成的代码与标准库及其他四个库的对比,每个库使用各自最快的有文档记录的路径:编解码一个 500 条目的 feed(约 130 KB)以及一个大小相当于 session cookie 的小对象,每次解码都复用同一个值。每个库都将两者解码为与 encoding/json 相同的值。
每个数字表示在该库自己的进程中执行 3,000 次 feed 操作或 3,000,000 次 cookie 操作的 CPU 秒数(用户态 + 系统态),包含垃圾回收和固定的设置开销。数据为在配备 10 核 Apple M4 和 Go 1.27.0 的机器上,负载平均值在 18 到 37 之间的四轮交替测试的中位数。
| 库 | Feed 编码 (s) | Feed 解码 (s) | Cookie 编码 (s) | Cookie 解码 (s) | 每次 Feed 解码的分配次数 |
|---|---|---|---|---|---|
| goccy/go-json v0.11.2 | 0.64 | 0.74 | 0.68 | 0.67 | 4 |
noalloc 的 jsongen | 0.87 | 1.16 | 0.75 | 1.08 | 0 |
| go-faster/jx v1.2.0 | 0.98 | 1.54 | 0.80 | 1.13 | 3,500 |
| mailru/easyjson v0.9.0 | 0.97 | 1.77 | 0.87 | 1.88 | 4,001 |
| bytedance/sonic v1.15.5 | 1.52 | 1.04 | 1.89 | 1.82 | 3 |
encoding/json/v2 | 1.58 | 2.63 | 1.82 | 2.11 | 3,196 |
encoding/json | 1.52 | 2.94 | 1.77 | 2.65 | 1,696 |
sonic 的最快路径是为 amd64 构建的,因此在 M4(一台 arm64 机器)上,这些数字可能低估了它的性能。
HTML 和 URL 转义
html/template 为 html/template 从中选取的十六个转义器中的六个提供了 append 版本:HTML 文本和属性值的两个转义器,URL 过滤器、规范化器和转义器,以及给定一个已编码为 JSON 的值后完成 script 中值的处理步骤。它不解析或执行模板,也不转义 CSS、JavaScript 字符串、注释或 srcset。
net/url 提供了 url.QueryEscape 的 append 版本。
每个函数都写入与其标准库对应版本返回的相同字节,仅在传入的切片需要增长时才分配内存。
常见错误
以下示例共享这些声明:
var (
pool = bounded.NewPool[byte](4<<10, 8)
classes = bounded.NewClasses(bounded.NewPool[int](16, 8), bounded.NewPool[int](256, 8))
r bounded.Region
b, next bounded.Buf
s bounded.Slice[int]
)
在 Region.Release 之后使用内存
在 region 释放后使用 Vec 或 Slice 会导致 panic。Buf 或 Map 无法察觉这种情况,会读取下一个借用者写入的内容:
r.Reset(1 << 20)
b.ResetFrom(&r, pool, nil)
b.WriteString("alice's page")
r.Release()
r.Reset(1 << 20)
next.ResetFrom(&r, pool, nil)
next.WriteString("bob's page..")
fmt.Printf("%q\n", b.Bytes()) // "bob's page.."
只有当请求不再需要它借用的所有内容后才释放 region,在 Reset 之后紧跟 defer 即可做到这一点。-race 可以捕获一个请求读取另一个请求已借出内存的情况。
在写入后保留 Items 或 Bytes
当 Slice 或可借用的 Buf 达到容量时,可能会迁移到更大的内存块并将旧块归还其池。在迁移之前从 Items 或 Bytes 获取的切片仍指向旧块:
s.ResetFrom(&r, classes)
s.Push(1)
first := s.Items()
s.Extend(make([]int, 100)) // 迁移到更大的块
first[0] = 2 // 写入了 s 已归还的块
每次写入后重新调用 Items 或 Bytes。
在准入之外预留
region 在等待其余内存块时会保留已持有的块。因此,两个各自需要两块内存池中两个块的 region,可能各取一块而死锁:
two := bounded.NewPool[byte](64, 2)
for range 2 {
go func() {
var r bounded.Region
r.Reset(1 << 20)
bounded.Reserve(nil, &r, two, 2) // 可能死锁
r.Release()
}()
}
只在请求被准入后才预留,并将池的大小设置为能容纳所有被放行请求同时持有的预留量。
每个条目都借用
借用和归还都需要加锁,因此每个请求借用一次而非每个条目借用一次:
for _, line := range lines {
r.Reset(1 << 20)
b.ResetFrom(&r, pool, w)
b.WriteString(line)
b.Flush()
r.Release()
}
在 goroutine 之间共享 Region
Pool 可以安全地在 goroutine 之间共享,但 Region 不行。-race 可以捕获这个问题:
r.Reset(1 << 20)
for range 4 {
go bounded.Take(&r, pool) // 对 r 存在竞态
}
将 ctx.Done() 传给 Wait 或 Reserve
可取消 context 的第一次 Done 调用会为其 channel 分配内存:
bounded.Reserve(ctx.Done(), &r, pool, 1) // 一次分配
noalloc 将此计为首次使用初始化而不报告,但每个新的 context 都会分配自己的 channel。改传一个你只创建一次的 channel,比如服务器在关闭时关闭的那个。
期望内存块已清零
池只会对含有指针的类型在块归还时将其清零。否则你会得到上一个借用者留下的内容:
first := bounded.Take(&r, pool)
first[0] = 'x'
r.Release()
r.Reset(1 << 20)
again := bounded.Take(&r, pool)
fmt.Printf("%q\n", again[0]) // 'x'
如果需要清零,请自己清除块的内容。
为了消除检查结果而豁免某行
豁免覆盖该行调用的所有内容。这里 noalloc 从不检查 format 内部,因此它分配的任何内容都不会被报告:
//noalloc:root
func (p *page) render(w io.Writer) {
p.format(w) //noalloc:exempt response: the page
}
如果只想豁免该行本身,将原因的类型(这里是 response)传给 -line-only。豁免仍然会显示它放行了什么。-report 会在每个豁免下列出该行及其仅通过该行调用的函数中的分配,-write-exemptions 写出的列表也会包含这些内容,缩进在豁免下面,因此如果豁免覆盖的内容发生变化,检查会失败,直到你审查并重新写出该列表。
被豁免的行如果实际没有放行任何内容,也会导致检查失败:删除它。
注意事项
使用汇编编写的函数
相似文章
Go Empty Interface Explained & The Hidden Heap Allocation
深入解析 Go 空接口 any 的底层实现:eface 与 iface 的两指针结构、类型字与数据字的含义,以及编译器如何通过常量、zeroVal、staticuint64s 等手段避免不必要的堆分配。
zalloc: 在你的 C 代码中使用 Zig 分配器
zalloc 将 C 模块中的 malloc、calloc、realloc 和 free 替换为 Zig 分配器,从而在 C 代码中实现 Zig 风格的内存管理。
观察 Go 的新垃圾回收器在堆中的移动
Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。
OxCaml 中其他语言应该借鉴的功能 - The Consensus
OxCaml 是 Jane Street 的 OCaml 超集,引入了一个 `[@zero_alloc]` 注解,强制编译器在调用树中任何函数在堆上分配内存时拒绝构建,将内存分配检测从运行时分析转变为编译时强制执行。
C 语言中的 Arena 分配器
一篇讲解 C 语言中 Arena 分配器的技术指南,介绍如何通过批量分配内存块来替代传统的 malloc/free 模式,从而提升性能并减少内存泄漏。