GitHub - theconsensuslabs/noalloc: Tooling and libraries to enforce zero heap allocations in Golang
Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
MixedArticle text · 1,242 words · 18 segments analyzed
Disclaimer: This repo is entirely written by AI. The README is largely written by AI. It is guided by a human and used in production at The Consensus in both the web service and our code search engine. It is experimental and may change vastly. It's one thing to be able to write some code that doesn't allocate on the heap. It's another thing to be able to keep code from not allocating as the code changes. With noalloc you can assert Golang functions should not allocate and the noalloc CLI will check the entire call tree from that function for heap allocations. noalloc will tell you every line that allocates, allowing you to remediate or exempt lines. noalloc also provides zero-allocating, fixed-sized data structures and algorithms (including a JSON encoder and decoder) to make it easier and cleaner to write code that does not allocate. go install github.com/theconsensuslabs/noalloc/cmd/noalloc@latest You'll need Go 1.27 or later.
For example, mark a function with //noalloc:root and run noalloc in its module.
//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 exits 1 (it found an allocation) or 2 (it couldn't run at all).
It allows allocations on an error or panic that ends the root's call and in first-use setup, which the caveats below cover. Directives //noalloc:root checks a function and everything it calls. //noalloc:exempt <reason> exempts the line it's on and everything that line calls. On a line by itself, it applies to the next line. //noalloc:fixed on a type says its methods never grow its slices, so noalloc treats them as reused memory.
//noalloc:append marks a function that works like append, so a call to it is judged like a call to append.
Reused memory is memory kept between requests, such as a buffer in a long-lived struct. An append to it is only allowed after a comparison proves there's room.
You can also mark a type's fields as reused memory with -scratch pkg/path.Type.
A call to a function outside your module is allowed when that function's own code allocates nothing, which noalloc works out from its source, or from its compiled code when it's written in assembly or is part of the runtime.
Packages you check from source with -source, which takes an import path prefix, are checked as part of your module instead. Check noalloc's own libraries that way: -source github.com/theconsensuslabs/noalloc/bounded, and the same for encoding/json, so their //noalloc:fixed types and other directives count, which they don't in code outside your module.
Run noalloc -h for every flag.
Designing with bounded bounded has four containers with a fixed maximum capacity: Buf, Vec, Slice, and Map.
When a write exceeds it, the write does nothing and marks the container full, so you can check once at the end instead of after every write. A container's memory can be your own, or borrowed from a Pool through a Region.
These examples are also in example_test.go, where go test checks their output. Buf A Buf is a byte buffer.
Give it memory with Reset, and once a write doesn't fit it stays full: 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 Give it a writer too and it flushes to the writer instead of filling up: var b bounded.Buf b.Reset(make([]byte, 8), os.Stdout) b.WriteString("hello, ") b.WriteString("world\n") // flushes "hello, " first b.Flush() // prints hello, world Vec A Vec is a list of any type. With memory you give it, it holds as many items as that memory's capacity: 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 With ResetFrom it can borrow chunks from a pool as it fills, up to the region's budget. Its items aren't contiguous, so read it with At, or a chunk at a time with 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], then [4 5 6 7] } Slice A Slice is contiguous. When it fills a chunk, it can move to a chunk from the next larger pool, the way append moves a slice to a bigger array: 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 A Map holds up to the number of entries you reset it for. A new key past that isn't added, but you can still update the keys it has: 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 With Grow it starts small and can move to larger pools of slots as it fills: 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 and Region A Pool lends a fixed number of chunks. When they're all out, Take returns nil until a region gives one back: 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 gets the first chunk bounded.Take(&a, pool) // and the second bounded.Take(&b, pool) // nil: the pool is empty a.Release() bounded.Take(&b, pool) // b gets one of a's b.Release() st := pool.Stats() fmt.Println(st.Lent, st.Empty) // 3 1 Use Wait instead of Take to wait for a chunk until a channel closes, and Reserve to take several at once or none.
Handling dynamic input A request's input size isn't known until the input arrives, so give each request a Region with a fixed budget when it starts.
Within that budget it borrows chunks from a shared Pool as its input calls for them, and releasing the region when the request ends gives them all back: var rows = bounded.NewPool[byte](16<<10, 64) // nowOnly starts closed, so Reserve takes a free chunk and never waits. 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() } Here the page streams through one chunk that flushes as it fills, so a long page needs no more memory than a short one.
Output that must be held whole can call Buf.Borrow instead, which moves the buffer to a larger chunk each time it fills, until the region's budget runs out. Every site that can exhaust its memory budget needs a plan for when it does.
For example, you might: End the page early and let the client resume from a cursor. Skip an optimization when the slower path still gives the right answer. Forget an entry in a memo. Refuse input that's too large to parse before you start parsing it.