noalloc:在 Golang 中强制执行零堆分配的工具与库

Hacker News Top 工具

摘要

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.20.640.740.680.674
noalloc 的 jsongen0.871.160.751.080
go-faster/jx v1.2.00.981.540.801.133,500
mailru/easyjson v0.9.00.971.770.871.884,001
bytedance/sonic v1.15.51.521.041.891.823
encoding/json/v21.582.631.822.113,196
encoding/json1.522.941.772.651,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

Lobsters Hottest

深入解析 Go 空接口 any 的底层实现:eface 与 iface 的两指针结构、类型字与数据字的含义,以及编译器如何通过常量、zeroVal、staticuint64s 等手段避免不必要的堆分配。

观察 Go 的新垃圾回收器在堆中的移动

Lobsters Hottest

Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。

OxCaml 中其他语言应该借鉴的功能 - The Consensus

Lobsters Hottest

OxCaml 是 Jane Street 的 OCaml 超集,引入了一个 `[@zero_alloc]` 注解,强制编译器在调用树中任何函数在堆上分配内存时拒绝构建,将内存分配检测从运行时分析转变为编译时强制执行。

C 语言中的 Arena 分配器

Lobsters Hottest

一篇讲解 C 语言中 Arena 分配器的技术指南,介绍如何通过批量分配内存块来替代传统的 malloc/free 模式,从而提升性能并减少内存泄漏。