Skip to content
gohacks.dev
Performance

Memory Pools and Arenas in Go and Their Trade Offs

Memory arenas in Go compared with plain new and a slab allocator on a 1 million node tree: 1.41 s, 1.01 s and 0.60 s, and why the arena is risky

9 min read
Summarize in AI
Flat illustration of a grid of heap cells with the garbage collector sweeping across it.

A garbage collected language gives you something in exchange for losing control of when memory is freed, and now and then a program wants some control back. A compiler that builds a syntax tree, a game that allocates a frame's worth of objects and drops them together, and a request handler that creates thousands of small values that all die when the response is sent all share a shape: many allocations with one lifetime. An arena allocator suits that shape, since it hands out memory by moving a pointer and frees everything at once. Go has an experimental arena package for it. This article measures it against the ordinary allocator and against a slab you can write in twenty lines, and explains why the second option is usually the one to use.

Tested with Go 1.25.6 with GOEXPERIMENT=arenas on Apple M1 Max (10 cores, 32 GB), macOS arm64, 30 rounds of a 1,048,576 node tree

It continues reducing allocations and sync.Pool, the other two answers to the same problem. The collector's side is in GOGC tuning and GC pauses. The cornerstone is the Go performance guide.

Three ways to get the same tree

The program builds a binary tree of 1,048,576 nodes, 48 bytes each, adds up the keys, and repeats this 30 times, so a total of 31 million allocations. All three versions run the same build function, which receives an allocation function:

  1. new(Node): the normal allocator and collector.
  2. A slab: a small type that allocates []Node chunks of 4,096 nodes and hands out &chunk[i]. The garbage collector frees a chunk when nothing points into it, so a whole tree is freed in bulk when the root is dropped.
  3. arena.New[Node]: the experimental arena package, with Free at the end of each round. It builds only with GOEXPERIMENT=arenas.

All three produce the same checksum, 16,492,690,145,280, so they built the same trees.

main.goGo
package main

import (
	"arena"
	"fmt"
	"os"
	"runtime"
	"runtime/debug"
	"time"
)

type Node struct {
	Left, Right *Node
	Key         int
	Pad         [3]int
}

const nodes = 1 << 20 // about 1M nodes of 48 bytes
const rounds = 30

func build(alloc func() *Node) *Node {
	root := alloc()
	root.Key = 0
	queue := []*Node{root}
	made := 1
	for len(queue) > 0 && made < nodes {
		n := queue[0]
		queue = queue[1:]
		n.Left, n.Right = alloc(), alloc()
		n.Left.Key, n.Right.Key = made, made+1
		made += 2
		queue = append(queue, n.Left, n.Right)
	}
	return root
}

func sum(n *Node) int {
	if n == nil {
		return 0
	}
	return n.Key + sum(n.Left) + sum(n.Right)
}

type slab struct {
	chunks [][]Node
	cur    []Node
}

func (s *slab) new() *Node {
	if len(s.cur) == 0 {
		s.cur = make([]Node, 4096)
		s.chunks = append(s.chunks, s.cur)
	}
	n := &s.cur[0]
	s.cur = s.cur[1:]
	return n
}

func run(name string, f func() int) {
	runtime.GC()
	var a, b runtime.MemStats
	runtime.ReadMemStats(&a)
	t := time.Now()
	total := 0
	for i := 0; i < rounds; i++ {
		total += f()
	}
	d := time.Since(t)
	runtime.ReadMemStats(&b)
	fmt.Printf("  %-34s %8v  %3d GCs  GC pause total %8v  checksum %d\n", name, d.Round(time.Millisecond), b.NumGC-a.NumGC,
		time.Duration(b.PauseTotalNs-a.PauseTotalNs).Round(time.Microsecond), total)
}

func main() {
	if len(os.Args) > 1 && os.Args[1] == "uaf" {
		a := arena.NewArena()
		n := arena.New[Node](a)
		n.Key = 42
		a.Free()
		fmt.Println("after Free, the old pointer still reads:", n.Key)
		for i := 0; i < 3; i++ {
			b := arena.NewArena()
			m := arena.New[Node](b)
			m.Key = 7
			fmt.Println("a new arena allocated a node with Key 7; the old pointer now reads:", n.Key)
			b.Free()
		}
		return
	}
	debug.SetGCPercent(100)
	fmt.Printf("build a tree of %d nodes (%d bytes each) %d times and add up the keys\n", nodes, 48, rounds)
	run("new(Node), the garbage collector", func() int {
		return sum(build(func() *Node { return new(Node) }))
	})
	run("slab: []Node chunks of 4096", func() int {
		s := &slab{}
		return sum(build(s.new))
	})
	run("arena.New[Node], freed per round", func() int {
		a := arena.NewArena()
		defer a.Free()
		return sum(build(func() *Node { return arena.New[Node](a) }))
	})
}
Shell
set -e
GOEXPERIMENT=arenas go build -o app .
./app
echo
echo "what a pointer into a freed arena sees:"
./app uaf 2>&1 | head -5
rm -f app
Text
build a tree of 1048576 nodes (48 bytes each) 30 times and add up the keys
  new(Node), the garbage collector     1.409s  125 GCs  GC pause total  6.462ms  checksum 16492690145280
  slab: []Node chunks of 4096           603ms  102 GCs  GC pause total  7.232ms  checksum 16492690145280
  arena.New[Node], freed per round     1.012s   29 GCs  GC pause total  4.901ms  checksum 16492690145280

what a pointer into a freed arena sees:
after Free, the old pointer still reads: 42
a new arena allocated a node with Key 7; the old pointer now reads: 42
a new arena allocated a node with Key 7; the old pointer now reads: 42
a new arena allocated a node with Key 7; the old pointer now reads: 42

Results

AllocatorTime (30 rounds)CollectionsGC pause total
new(Node)1.409 s1256.5 ms
arena, Free each round1.012 s294.9 ms
slab of 4,096 node chunks0.603 s1027.2 ms

The arena helps, and a slab helps more

The arena was 28 percent faster than new and ran a quarter of the collections (29 against 125): memory it hands out is invisible to the collector's mark phase and is returned in one call. That is the promised benefit. But the slab, ordinary Go with no experiment, was 2.3 times faster than new and 1.7 times faster than the arena, while still being collected 102 times. A slab turns a million small allocations into 256 large ones, so the cost of the allocator is paid once per 4,096 nodes, and neighbouring nodes sit next to each other in memory, which helps the traversal. The arena pays for each arena.New call, which is a generic function that goes through the runtime.

The number of collections is not the whole story. The slab's pointers are still scanned, so the collector marks all of it when it runs, but the allocation that triggers it is much cheaper.

Pauses did not move

Total pause time was 4.9, 6.5 and 7.2 ms across 30 rounds of a heap of tens of megabytes, which is 160 to 240 µs a round, and nothing separates them. The arena saved collections and mark work, as the table shows, and that appears as time, not as pauses (the pause article found the same: Go's pauses are short whatever the heap).

The risk, measured

An arena is only safe if nothing touches its memory after Free. The program shows what a violation looks like:

Go
a := arena.NewArena()
n := arena.New[Node](a)
n.Key = 42
a.Free()
fmt.Println(n.Key)

In this run it printed 42. After the free, three new arenas allocated nodes with Key set to 7, and the old pointer still read 42. No crash, no race report, no panic: the stale pointer returned the value it had been given. The arena documentation says that using memory after Free has undefined behaviour and may fault, but the lab shows the failure mode that worries people most, which is that it can appear to work. A program that reads freed arena memory and gets away with it today may corrupt data tomorrow when the runtime reuses the memory, and no tool will point at the line.

This is a problem of ownership that Go does not model. A pointer that escapes the arena into a long lived structure (a cache, a map, a channel) becomes a dangling pointer when the arena is freed. The arena.Clone function exists to copy a value out to the ordinary heap. Getting that right across a code base is the cost.

The status of the package

The arena package was added in Go 1.20 as an experiment and has not left it. It is not in the standard build, and it compiles only with GOEXPERIMENT=arenas, so a library that imports it cannot be used by anyone who builds without the flag, and the proposal to make it a supported package has been on hold. The discussion (issue 51317 in the Go repository) records the concerns: the API is unsafe, it interacts with every library that takes a pointer, and its benefit is real but modest next to the complexity. I would not depend on it in production code. If it changes or goes away, your code stops compiling.

What to use instead

A slab, as in the lab, for many objects with one lifetime. It is plain Go and the collector reclaims the chunks automatically, so there is nothing to free and nothing to get wrong. Its weakness is that one surviving pointer keeps a whole 4,096 node chunk alive, the same retention as a slice that keeps its array alive, so chunks are best sized to the data that lives and dies together.

sync.Pool for objects that are created, used and discarded repeatedly, such as buffers. A pool reuses the same object across calls and cuts allocation to nearly zero in a steady state, but the collector may empty it (sync.Pool).

Index based structures, where a tree or a graph stores its nodes in one []Node and refers to them by int32 index instead of pointer. This removes the pointers the collector must follow, halves node size, and makes the whole structure one allocation. It is the technique behind many fast parsers and compilers written in Go, and it is the safest way to cut GC cost for a structure with millions of nodes.

Reuse of a whole value (a scratch struct that the caller keeps and resets), as in the allocation article.

Tune the collector with GOGC and GOMEMLIMIT when the problem is how often it runs, not how much garbage there is (GOMEMLIMIT).

When an arena style allocator is worth the trouble

The pattern justifies the complexity in a narrow set of cases: a batch job that builds a large structure, uses it and exits, where the memory never needs returning in the middle; a compiler phase or a parser with a clear boundary; and request scoped work where a handler's objects all die with the request and profiling shows the collector at 20 percent or more of CPU. In each of those a slab or an index based layout gets most of the benefit without the unsafe part.

If you want an arena anyway, keep the boundary tight. Allocate everything for one unit of work from one arena. Never store an arena pointer in anything that outlives the unit. Copy results out with arena.Clone before Free. Test under -race. And isolate the use behind one package so it can be swapped for a slab by changing one file.

Checklist

  • 1.409 s with new, 1.012 s with an arena, 0.603 s with a slab on the same tree.
  • The arena cut collections from 125 to 29 and pauses did not change.
  • The arena package is experimental and needs GOEXPERIMENT=arenas.
  • A read after Free returned stale data silently, with no crash.
  • Prefer a slab, an index based layout or sync.Pool before an arena.
  • Measure the collector's share first; if it is under 10 percent, stop.

The experiment is documented in the arena package, and the discussion of its future is in the Go proposal issue.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.