Go's New arena Package: Manual Lifetime for a Faster GC
The arena package lets you allocate Go values into large chunks and free them all at once, sidestepping the garbage collector's per-object bookkeeping by turning thousands of objects into one large heap object.
TL;DR:
arena.Arenais a manual-lifetime allocator. You allocate values into it, and when you callFree()the runtime returns the underlying chunks to the heap without waiting for a GC cycle. The trick is that a whole arena chunk is modelled as a single large heap object, so the GC has one thing to mark instead of a million. Freed chunks are quarantined until the collector proves nothing points into them, then set to fault on access.
The Go garbage collector is very good at knowing when an object is dead. It is not good at telling you so early, and it never lets you say “I am done with these ten thousand objects, take them now”.
Sometimes you want exactly that. You are parsing a request, or decoding a message, or running a batch job over a chunk of input. You allocate a pile of short-lived objects, use them for a moment, and then want them gone — not “gone at the next GC cycle, whenever that is”, but gone now, so the next cycle does not have to trace them at all.
That is what the arena package is for. It is still an experiment in this
tree — you need GOEXPERIMENT=arenas to build it — but the runtime support
is fully implemented, and it is a genuinely interesting design.
The package
It is small. The whole public API is four functions and one type:
// src/arena/arena.go:44
type Arena struct {
a unsafe.Pointer
}
// NewArena allocates a new arena.
func NewArena() *Arena {
return &Arena{a: runtime_arena_newArena()}
}
// Free frees the arena (and all objects allocated from the arena) so that
// memory backing the arena can be reused fairly quickly without garbage
// collection overhead. Applications must not call any method on this
// arena after it has been freed.
func (a *Arena) Free() {
runtime_arena_arena_Free(a.a)
a.a = nil
}
// New creates a new *T in the provided arena. The *T must not be used after
// the arena is freed. Accessing the value after free may result in a fault,
// but this fault is also not guaranteed.
func New[T any](a *Arena) *T {
return runtime_arena_arena_New(a.a, reflectlite.TypeOf((*T)(nil))).(*T)
}
// MakeSlice creates a new []T with the provided capacity and length. The []T must
// not be used after the arena is freed. Accessing the underlying storage of the
// slice after free may result in a fault, but this fault is also not guaranteed.
func MakeSlice[T any](a *Arena, len, cap int) []T {
var sl []T
runtime_arena_arena_Slice(a.a, &sl, cap)
return sl[:len]
}Plus one escape hatch, for when a value has to outlive its arena:
// src/arena/arena.go:78
// Clone makes a shallow copy of the input value that is no longer bound to any
// arena it may have been allocated from, returning the copy. If it was not
// allocated from an arena, it is returned untouched. This function is useful
// to more easily let an arena-allocated value out-live its arena.
// T must be a pointer, a slice, or a string, otherwise this function will panic.
func Clone[T any](s T) T {
return runtime_arena_heapify(s).(T)
}Clone is the function that makes arenas usable in real code. Without it,
anything you allocate into an arena is dead the moment you free the arena,
and you would have to copy it out by hand. Clone does that copy for you,
into the ordinary heap.
Note what is not there: no MakeMap, no MakeSliceCopy. This tree’s
version of the package has New, MakeSlice and Clone, and that is the
entire surface.
The runtime’s design
The runtime implementation lives in src/runtime/arena.go, and it opens with
a description of the whole design:
// src/runtime/arena.go:5
// Implementation of (safe) user arenas.
//
// This file contains the implementation of user arenas wherein Go values can
// be manually allocated and freed in bulk. The act of manually freeing memory,
// potentially before a GC cycle, means that a garbage collection cycle can be
// delayed, improving efficiency by reducing GC cycle frequency. There are other
// potential efficiency benefits, such as improved locality and access to a more
// efficient allocation strategy.
//
// What makes the arenas here safe is that once they are freed, accessing the
// arena's memory will cause an explicit program fault, and the arena's address
// space will not be reused until no more pointers into it are found. There's one
// exception to this: if an arena allocated memory that isn't exhausted, it's placed
// back into a pool for reuse. This means that a crash is not always guaranteed.
//
// While this may seem unsafe, it still prevents memory corruption, and is in fact
// necessary in order to make new(T) a valid implementation of arenas. Such a property
// is desirable to allow for a trivial implementation. (It also avoids complexities
// that arise from synchronization with the GC when trying to set the arena chunks to
// fault while the GC is active.)Two promises, and they are the whole safety story:
- After
Free(), touching the arena’s memory faults. You get a crash and a stack trace instead of silent corruption. - The address space is not reused until the GC has proved that nothing points into it any more.
The second one is the subtle one. If the runtime returned the memory to the allocator immediately, a stale pointer somewhere in your program would turn into a use-after-free that corrupts other data. So the freed chunk goes into a “quarantine” state and waits for the collector.
Layers: arena, chunks, spans
The implementation stacks three concepts on top of each other.
arena.Arena (user-facing handle)
|
v
runtime.userArena one active chunk + a list of full chunks
|
v
user arena chunk 8 MiB, one mspan, modelled as ONE large object
|
v
heapArena + page allocator ordinary Go heap underneathThe chunk size is fixed:
// src/runtime/arena.go:190
const (
// userArenaChunkBytes is the size of a user arena chunk.
userArenaChunkBytesMax = 8 << 20
userArenaChunkBytes = uintptr(int64(userArenaChunkBytesMax-heapArenaBytes)&(int64(userArenaChunkBytesMax-heapArenaBytes)>>63) + heapArenaBytes) // min(userArenaChunkBytesMax, heapArenaBytes)
// userArenaChunkPages is the number of pages a user arena chunk uses.
userArenaChunkPages = userArenaChunkBytes / pageSize
// userArenaChunkMaxAllocBytes is the maximum size of an object that can
// be allocated from an arena. This number is chosen to cap worst-case
// fragmentation of user arenas to 25%. Larger allocations are redirected
// to the heap.
userArenaChunkMaxAllocBytes = userArenaChunkBytes / 4
)A chunk is the smaller of 8 MiB and the heap arena size: 8 MiB on 64-bit
Linux, 4 MiB on 32-bit platforms and on Windows, 512 KiB on Wasm. The
userArenaChunkMaxAllocBytes cap is the interesting one: any single
allocation larger than a quarter of a chunk is redirected straight to the
ordinary heap, because otherwise one big allocation could waste a quarter of
a chunk.
The userArena struct itself is tiny:
// src/runtime/arena.go:234
type userArena struct {
// fullList is a list of full chunks that have not enough free memory left, and
// that we'll free once this user arena is freed.
//
// Can't use mSpanList here because it's not-in-heap.
fullList *mspan
// active is the user arena chunk we're currently allocating into.
active *mspan
// refs is a set of references to the arena chunks so that they're kept alive.
//
// The last reference in the list always refers to active, while the rest of
// them correspond to fullList. Specifically, the head of fullList is the
// second-to-last one, fullList.next is the third-to-last, and so on.
//
// In other words, every time a new chunk becomes active, its appended to this
// list.
refs []unsafe.Pointer
// defunct is true if free has been called on this arena.
//
// This is just a best-effort way to discover a concurrent allocation
// and free. Also used to detect a double-free.
defunct atomic.Bool
}Three pointers and a bool. There is no free list per size class, no bitmaps,
no per-object bookkeeping. When the active chunk fills up, it goes on
fullList and a new chunk becomes active.
Bump allocation, from both ends
Allocating into an arena is a bump pointer. The runtime keeps a range of free space in the chunk’s span and hands out slices of it:
// src/runtime/arena.go:449
func (s *mspan) userArenaNextFree(typ *_type, cap int) unsafe.Pointer {
size := typ.Size_
if cap > 0 {
if size > ^uintptr(0)/uintptr(cap) {
// Overflow.
throw("out of memory")
}
size *= uintptr(cap)
}
if size == 0 || cap == 0 {
return unsafe.Pointer(&zerobase)
}
if size > userArenaChunkMaxAllocBytes {
// Redirect allocations that don't fit into a chunk well directly
// from the heap.
if cap >= 0 {
return newarray(typ, cap)
}
return newobject(typ)
}and then the actual bump, which picks a direction based on whether the object contains pointers:
// src/runtime/arena.go:482
var ptr unsafe.Pointer
if !typ.Pointers() {
// Allocate pointer-less objects from the tail end of the chunk.
v, ok := s.userArenaChunkFree.takeFromBack(size, typ.Align_)
if ok {
ptr = unsafe.Pointer(v)
}
} else {
v, ok := s.userArenaChunkFree.takeFromFront(size, typ.Align_)
if ok {
ptr = unsafe.Pointer(v)
}
}Pointer-free objects come off the back, pointer-ful objects off the front. The reason is not aesthetic: the GC has an optimisation where it stops scanning an object once it has found no more pointers in it, and the runtime wants pointer-free arena allocations to be grouped at the end of the chunk so that the “no more pointers” mark is unambiguous and the heap bitmap for the pointer-free tail never has to be written at all.
If the chunk cannot satisfy the request, the arena refills: the current chunk goes on the full list and a new one becomes active. The new chunk comes from the reuse pool if there is one, and otherwise from the operating system.
Freeing: quarantine, then fault
Free() does not just drop the chunks on the floor. It walks the full list
and hands each chunk back to the runtime:
// src/runtime/arena.go:315
func (a *userArena) free() {
// Check for a double-free.
if a.defunct.Load() {
panic("arena double free")
}
// Mark ourselves as defunct.
a.defunct.Store(true)
SetFinalizer(a, nil)
// Free all the full arenas.
//
// The refs on this list are in reverse order from the second-to-last.
s := a.fullList
i := len(a.refs) - 2
for s != nil {
a.fullList = s.next
s.next = nil
freeUserArenaChunk(s, a.refs[i])
s = a.fullList
i--
}The active chunk, if it still has room, is not freed — it goes on a reuse list so the next arena can pick it up:
// src/runtime/arena.go:343
// Put the active chunk onto the reuse list.
//
// Note that active's reference is always the last reference in refs.
s = a.active
if s != nil {
if raceenabled || msanenabled || asanenabled {
// Don't reuse arenas with sanitizers enabled. We want to catch
// any use-after-free errors aggressively.
freeUserArenaChunk(s, a.refs[len(a.refs)-1])
} else {
lock(&userArenaState.lock)
userArenaState.reuse = append(userArenaState.reuse, liveUserArenaChunk{s, a.refs[len(a.refs)-1]})
unlock(&userArenaState.lock)
}
}This is the exception the file header warned about: a partially-used chunk is recycled, so a use-after-free into it will not fault. It might give you someone else’s data instead.
Setting the chunk to fault
When it is safe to release a chunk, the runtime makes the memory fault on access:
// src/runtime/arena.go:855
func (s *mspan) setUserArenaChunkToFault() {
if !s.isUserArenaChunk {
throw("invalid span in heapArena for user arena")
}
if s.npages*pageSize != userArenaChunkBytes {
throw("span on userArena.faultList has invalid size")
}
// Update the span class to be noscan. What we want to happen is that
// any pointer into the span keeps it from getting recycled, so we want
// the mark bit to get set, but we're about to set the address space to fault,
// so we have to prevent the GC from scanning this memory.
//
// It's OK to set it here because (1) a GC isn't in progress, so the scanning code
// won't make a bad decision, (2) we're currently non-preemptible and in the runtime,
// so a GC is blocked from starting. We might race with sweeping, which could
// put it on the "wrong" sweep list, but really don't care because the chunk is
// treated as a large object span and there's no meaningful difference between scan
// and noscan large objects in the sweeper. The STW at the start of the GC acts as a
// barrier for this update.
s.spanclass = makeSpanClass(0, true)
// Actually set the arena chunk to fault, so we'll get dangling pointer errors.
// sysFault currently uses a method on each OS that forces it to evacuate all
// memory backing the chunk.
sysFault(unsafe.Pointer(s.base()), s.npages*pageSize)Two things happen. The span is switched to noscan, so the GC will not try
to read the memory that is about to become unreadable. And sysFault is
called, which on Linux is mprotect(PROT_NONE) plus madvise(MADV_DONTNEED).
Switching the span to noscan is not a detail. The chunk’s address space is about to become unreadable, and the GC walking a pointer into it would take a segfault. Marking it noscan means the GC treats any pointer into the chunk as an opaque reference — it still keeps the chunk alive, but it does not try to read the contents.
The collector’s role
The sweeper is what eventually moves a quarantined chunk to the ready list:
// src/runtime/mgcsweep.go:750
if s.isUserArenaChunk {
if preserve {
// This is a case that should never be handled by a sweeper that
// preserves the span for reuse.
throw("sweep: tried to preserve a user arena span")
}
if nalloc > 0 {
// There still exist pointers into the span or the span hasn't been
// freed yet. It's not ready to be reused. Put it back on the
// full swept list for the next cycle.
mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
return false
}
// It's only at this point that the sweeper doesn't actually need to look
// at this arena anymore, so subtract from pagesInUse now.
mheap_.pagesInUse.Add(-s.npages)
s.state.set(mSpanDead)
// The arena is ready to be recycled. Remove it from the quarantine list
// and place it on the ready list. Don't add it back to any sweep lists.
systemstack(func() {
// It's the arena code's responsibility to get the chunk on the quarantine
// list by the time all references to the chunk are gone.
if s.list != &mheap_.userArena.quarantineList {
throw("user arena span is on the wrong list")
}
lock(&mheap_.lock)
mheap_.userArena.quarantineList.remove(s)
mheap_.userArena.readyList.insert(s)
unlock(&mheap_.lock)
})
return falsenalloc is the count of live objects found in the chunk. If it is non-zero,
somebody still points in there, so the chunk goes back on the sweep list and
we try again next cycle. If it is zero, the chunk is genuinely dead and goes
on the ready list for reuse.
The mheap holds the three lists that make this work:
// src/runtime/mheap.go:235
userArena struct {
// arenaHints is a list of addresses at which to attempt to
// add more heap arenas for user arena chunks. This is initially
// populated with a set of general hint addresses, and grown with
// the bounds of actual heap arena ranges.
arenaHints *arenaHint
// quarantineList is a list of user arena spans that have been set to fault, but
// are waiting for all pointers into them to go away. Sweeping handles
// identifying when this is true, and moves the span to the ready list.
quarantineList mSpanList
// readyList is a list of empty user arena spans that are ready for reuse.
readyList mSpanList
}Three states: chunks waiting for a new arena, chunks waiting for the GC to prove they are dead, and chunks ready to hand out.
Why this is faster
The win comes from three places.
Fewer objects for the GC to track. A chunk is a single mspan with
allocCount == 1. Ten thousand objects allocated into an arena are one
object as far as the heap is concerned. The GC’s bookkeeping — mark bits,
sweep lists, the object count in MemStats — is per chunk, not per value.
Fewer bytes to scan. The GC scans a chunk using a heap bitmap: one bit
per word, saying whether that word holds a pointer. That bitmap is written
once, at allocation time, by userArenaHeapBitsSetType, and then the chunk
is scanned as a unit.
Freeing does not need a GC cycle. Free() hands the chunks back
immediately. The heap goal drops right away, so the next collection is
triggered later, and when it does happen there is much less to trace.
How much this buys you depends entirely on the workload. A program that allocates ten thousand small values in a burst and drops them wholesale gets the full benefit; a program whose objects have varied lifetimes gets the locality benefit and little else.
The concurrency rule
An arena must not be used from more than one goroutine at a time. This is not a soft recommendation:
// src/arena/arena.go:43
// An Arena must never be used concurrently by multiple goroutines.The bump allocator has no atomics in it. Two goroutines bumping the same
pointer would race, hand out the same memory twice, and corrupt the heap
bitmap. The defunct flag is only a best-effort detector for a
concurrent-allocate-and-free race, not a lock.
The rule is easy to satisfy in practice: one arena per goroutine, or one arena per request, with a channel or a mutex handing ownership around.
Building it
The package is behind an experiment flag. The build constraint is right at the top of the file:
// src/arena/arena.go:5
//go:build goexperiment.arenasand the flag itself is declared in the experiment list:
// src/internal/goexperiment/flags.go:86
// Arenas causes the "arena" standard library package to be visible
// to the outside world.
Arenas boolThe generated constants switch on the flag:
// src/internal/goexperiment/exp_arenas_on.go:1
// Code generated by mkconsts.go. DO NOT EDIT.
//go:build goexperiment.arenas
package goexperiment
const Arenas = true
const ArenasInt = 1with the mirror-image file for when the experiment is off:
// src/internal/goexperiment/exp_arenas_off.go:1
// Code generated by mkconsts.go. DO NOT EDIT.
//go:build !goexperiment.arenas
package goexperiment
const Arenas = false
const ArenasInt = 0So to use it:
GOEXPERIMENT=arenas go build ./...Note the asymmetry: src/runtime/arena.go has no build constraint. The
runtime support — the chunk allocator, the quarantine list, the sweeper
integration — is always compiled in. Only the user-facing arena package
and reflect.ArenaNew are gated. So the runtime half of the feature lands
in every binary whether or not you can name the arena type.
Trade-offs
What arenas buy you. A bulk free that does not wait for a GC cycle, a dramatic reduction in the number of heap objects for bulk-allocation workloads, and better locality because everything in a chunk is contiguous.
What they cost. You give up the garbage collector’s safety net. A
dangling pointer into a freed chunk is a bug the compiler cannot find and
the GC will not catch. You have to manage the lifetime yourself, which
means every code path that can outlive the arena needs a Clone.
Why multi-megabyte chunks. A chunk that is too small means constant
chunk refills and a lot of madvise traffic. A chunk that is too large means
your “small” allocation holds megabytes of address space hostage, and the
Free() of a small arena does not give anything back promptly.
Why the two-ended bump. Pointer-free objects at the back mean the GC’s “no more pointers here” mark is unambiguous, so the heap bitmap for that tail never has to be written. It is a small optimisation with a large effect on the scan cost of a pointer-heavy arena.
Caveats
It is an experiment. GOEXPERIMENT=arenas is required, and the API can
change. The package in this tree has New, MakeSlice and Clone; other
versions of the proposal have had MakeMap and MakeSliceCopy as well.
It is not safe for concurrent use. One goroutine per arena, or explicit hand-off.
Free() is not a guarantee that the memory is gone. The chunk goes to
quarantine and is only recycled after a GC cycle proves nothing points into
it. If you call Free() and immediately allocate a lot, you may not get
the memory back.
A partially-used chunk is recycled without faulting. This is the one hole in the safety story: if you free an arena that never filled its active chunk, that chunk goes on the reuse pool and its memory is handed to the next arena without being made unreadable.
The arena must outlive its contents. runtime.KeepAlive is still your
friend. The runtime installs a finalizer on the arena so that an arena that
is simply dropped still gets its chunks freed, but a value that outlives the
arena’s chunk is still your problem.
Sanitizers change the behaviour. With the race detector, MSan or ASan enabled, partially-used chunks are freed rather than recycled, specifically so that use-after-free errors get caught.
Arenas are not a replacement for the GC. They are a tool for a specific shape of workload: allocate a lot, use it briefly, free it in bulk. If your objects have wildly varying lifetimes, the GC’s generational behaviour is exactly what you want and arenas will not help.
Wrapping up
The one thing to remember: an arena chunk is modelled as a single large heap object. That one decision is why arenas are cheap for the GC: a million values in an arena are one object to mark, one span to sweep, and one bitmap to scan.
The part people get wrong: Free() does not make the memory
unreadable. Freed chunks are quarantined and reused only after the GC has
proved that no pointer remains, and a chunk that was not exhausted goes
straight onto the reuse pool without any faulting at all. Use-after-free
into an arena is a silent bug, not a crash.
What to go do about it: build with GOEXPERIMENT=arenas and measure. The
right workload for this is something that allocates a large number of
short-lived objects in one phase and discards them wholesale in the next —
request parsing, decoding, batch processing. Use arena.Clone for anything
that has to survive the arena, and give each goroutine its own arena.
Further reading
src/arena/arena.go— the user-facing packagesrc/runtime/arena.go— the runtime implementation and its design notessrc/runtime/mheap.go—mheap.userArena, the quarantine and ready listssrc/runtime/mgcsweep.go— how the sweeper retires arena chunkssrc/runtime/mem_linux.go—sysFaultOS, themprotectthat makes chunks faultsrc/internal/goexperiment/flags.go— theArenasexperiment flagsrc/internal/goexperiment/exp_arenas_on.go— the generated constantssrc/reflect/arena.go—reflect.Valuesupport for arenas