Documentation
¶
Overview ¶
Package arena provides chunk-backed storage for values whose lifetime is one batch.
Values are copied in and handed back as views over the copy; nothing is freed individually. The whole arena is rewound at once — Reset to reuse the chunks, Release to drop them — which is what makes it cheap: allocation is a bump within the current chunk, and a batch's worth of values costs a handful of chunk allocations rather than one per value.
Arena is generic in its element type, so it stores slices of any T. Strings are the one case the generic form cannot express, since a method cannot narrow its receiver's type parameter: StringArena is an Arena[byte] with the string entry points added.
It is not safe for concurrent use. Every view handed out must be dead before Reset or Release, and callers must not mutate what they were handed.
Example ¶
A batch-lifetime workload: intern the batch's values, use the views, then rewind the arena and run the next batch over the same chunks.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
var a arena.StringArena // the zero value is usable, with 64 KiB chunks
for batch := range 2 {
labels := make([]string, 0, 3)
for _, key := range []string{"container_id", "level", "namespace"} {
// A copy, so whatever the decoder underneath reuses its buffer for
// next cannot show through.
labels = append(labels, a.Intern(key))
}
fmt.Printf("batch %d: %v, %d bytes interned\n", batch, labels, a.Size())
// Every view above is dead from here, so the chunks can be handed out again.
a.Reset()
}
}
Output: batch 0: [container_id level namespace], 26 bytes interned batch 1: [container_id level namespace], 26 bytes interned
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Arena ¶
type Arena[T any] struct { // contains filtered or unexported fields }
Arena copies slices of T into chunk-backed storage and hands back views over the copies. A view stays valid until Reset: chunks are never reallocated once allocated (a value that does not fit the current chunk goes into a fresh one), so storing more never invalidates earlier views. Reset rewinds the arena to reuse the existing chunks. It is not safe for concurrent use.
The chunks the arena reuses are all one size. A value too large for one gets a chunk of its own instead, which Reset drops rather than recycling, so a single huge value cannot leave an oversized chunk sitting in the reusable set for the arena's lifetime.
What the arena saves is object count, not scan work: for a T that contains pointers the chunks are still scanned, they are simply a handful of large objects rather than one per value. For a pointer-free T the chunks are noscan and the collector ignores them outright.
Example ¶
The element type is not limited to bytes. An arena over any T stores slices of T and hands back views over the copies, with the same one-batch lifetime.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
type sample struct {
at int64
value float64
}
// The chunk size is a byte budget, divided down to a whole number of elements — so a
// wider T means fewer of them per chunk, not a bigger chunk.
a := arena.New[sample](4096) // 4 KiB, which is 256 samples
window := a.Append([]sample{{at: 1, value: 0.5}, {at: 2, value: 1.5}})
fmt.Println(window, "-", a.Size(), "bytes stored,", a.Retained(), "retained")
}
Output: [{1 0.5} {2 1.5}] - 32 bytes stored, 4096 retained
func New ¶
New returns an arena with a chunk size of its own, given as a byte budget and rounded down to a whole number of T. Use it when the values are large relative to the default chunk — chunk size decides the tail waste, since a value that does not fit the current chunk starts a new one and strands the remainder.
Example ¶
Chunk size decides the tail waste, since a value that does not fit the current chunk starts a new one and strands the remainder. Size it to the values.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
a := arena.New[byte](1 << 20) // 1 MiB, for values that would shred a 64 KiB chunk
row := a.Append([]byte("a row too long to pack well into a small chunk"))
fmt.Printf("%s (%d bytes retained)\n", row, a.Retained())
}
Output: a row too long to pack well into a small chunk (1048576 bytes retained)
func (*Arena[T]) Append ¶
func (a *Arena[T]) Append(v []T) []T
Append copies v into the arena and returns a stable view over the copy. The view stays valid until Reset or Release, and is always ONE contiguous slice: a value that does not fit the current chunk starts a new one rather than being split, so nothing has to be reassembled on the way out.
A value larger than a whole chunk gets a chunk of its own, which Reset drops rather than recycling — see Arena.
func (*Arena[T]) AppendRef ¶
AppendRef copies v into the arena and returns a pointer-free Ref to the copy, for a caller that keeps many descriptors and does not want them scanned (see Ref). It stores exactly as Append does, the chunk of its own an oversized value gets included; the two differ only in what they hand back.
v must be shorter than 2^31 elements, and nothing checks that it is. A longer value is stored intact but comes back from Value as nil or as a truncated prefix, because the Ref describing it cannot count that high — see the limits on Ref for the whole picture, and use Append if a value could approach it.
Example ¶
Ref is the descriptor form: a chunk index and a range, with no pointer in it. A []Ref is allocated noscan, so a caller holding millions of them across a batch costs the collector nothing, where the []byte Append returns would put a pointer in every element and turn that slice into scan work on every cycle.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
var a arena.Arena[byte]
refs := make([]arena.Ref[byte], 0, 3)
for _, line := range []string{`{"n":1}`, `{"n":2}`, ""} {
refs = append(refs, a.AppendRef([]byte(line)))
}
for _, r := range refs {
if r.Empty() { // the zero Ref, which is what empty input returns
fmt.Println("(absent)")
continue
}
fmt.Printf("%s\n", a.Value(r))
}
}
Output: {"n":1} {"n":2} (absent)
func (*Arena[T]) Release ¶
func (a *Arena[T]) Release()
Release drops the chunks outright rather than rewinding them, for a caller that keeps the STRUCTURE around a batch but not the batch's bytes — a pooled writer parks itself drained and releases its arena first, so what is retained is the per-value bookkeeping and never the values. Every view handed out must be dead first, exactly as for Reset.
Example ¶
Reset and Release are both all-at-once rewinds, and differ only in what they keep.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
a := arena.New[byte](4096)
a.Append(make([]byte, 3000))
// Reset drops the bytes and keeps the chunks, for an owner about to refill them.
a.Reset()
fmt.Println("after Reset:", a.Size(), "bytes stored,", a.Retained(), "retained")
// Release drops the chunks too, for an owner that outlives its bytes — a pooled
// writer parked empty between batches.
a.Release()
fmt.Println("after Release:", a.Size(), "bytes stored,", a.Retained(), "retained")
}
Output: after Reset: 0 bytes stored, 4096 retained after Release: 0 bytes stored, 0 retained
func (*Arena[T]) Reset ¶
func (a *Arena[T]) Reset()
Reset rewinds the arena so its chunks are reused for the next round. The caller must ensure every view previously handed out is dead before calling Reset.
Oversized chunks are dropped rather than rewound. One was made to fit a single large value and is the wrong shape for anything else, so recycling it would leave that value's memory in the reusable set for as long as the arena lives. What survives a Reset is uniform, however lumpy the batch that just ran was.
func (*Arena[T]) Retained ¶
Retained reports the bytes the arena is holding — its chunk capacity, which is the real footprint a trim policy should judge (a burst grows the chunk list; Reset rewinds but never shrinks it).
Mid-batch this counts any oversized chunks in flight. Reset drops those, so what it reports between batches is the uniform capacity that carries over.
type Ref ¶
type Ref[T any] struct { // contains filtered or unexported fields }
Ref locates a value in the arena WITHOUT a pointer to it — a chunk index and a range within that chunk. Value resolves it. The type parameter is a phantom: it carries no field, and exists so a Ref cannot be resolved against an arena of some other element type.
The pointer-free part is the point, and it is worth the extra indirection on every read. A []Ref is allocated noscan, so the GC never walks it and a sort never pays a write barrier moving one; the []T form that Append returns puts a pointer in every element, and a caller holding millions of them turns its largest structure into scan work on every cycle. Measured on a production ingest service: the []byte form put runtime.gcBgMarkWorker at 11.1% of the process against 0.5% elsewhere in the tree. It is also 12 bytes against a slice header's 24, which for a caller that retains descriptors is the same saving twice over. Both hold whatever T is, pointer-bearing ones included.
The zero Ref is the ABSENT value: a stored value is never zero elements long (AppendRef returns the zero Ref for empty input), so end <= off cannot name a real one.
Limits ¶
Three int32s are what makes a Ref small, and they are also what bounds it. The bound is 2^31-1 ELEMENTS rather than bytes, so it scales with the width of T: 2 GiB for an Arena[byte], but 16 GiB for an Arena[int64]. Three things have to stay under it.
- Any single value stored through AppendRef or StrRef. An oversized value gets a chunk of its own and is addressed from 0 to its own length, so the value is the offset.
- The chunk capacity, which New fixes at chunkBytes/sizeof(T) — reached by asking an Arena[byte] for a chunk budget past 2 GiB.
- The number of chunks, which takes 2^31 of them. Out of reach at any sane chunk size, but not if one is set to a handful of elements.
None of it is checked, and going past a bound wraps the conversion rather than failing. What that does depends on where it lands:
- A length in [2^31, 2^32) makes end negative, so Empty calls the value ABSENT and Value returns nil. The value is simply gone, with nothing said.
- A length at or past 2^32 wraps to a small positive number, so Value returns a truncated prefix of the value.
- A wrapped offset or chunk index indexes out of range, so Value panics.
Losing the value quietly is both the likeliest of the three and the hardest to notice, which is the argument for staying clear of the bound rather than finding out empirically where a particular value lands.
Append and Intern carry no such limit. They hand back a slice or a string header, neither of which holds an int32 — the bound belongs to the descriptor, not to the arena.
type StringArena ¶
StringArena is an Arena[byte] with the string entry points added. It exists because a method cannot narrow its receiver's type parameter, so entry points that are only meaningful when the element type is byte cannot live on Arena itself.
Everything Arena[byte] does is promoted, so a StringArena stores raw bytes and hands out Ref[byte] descriptors too, and the embedded field is addressable as a.Arena for code that wants the plain arena. The zero value is usable, with 64 KiB chunks.
There are two ways to hold what it stores, matching the two on Arena. Intern hands back a string view, which is what a caller with a bounded number of live values wants; StrRef hands back a pointer-free descriptor that Str resolves, which is what a caller retaining a great many of them wants — a []Ref[byte] is 12 bytes per element and noscan, where a []string is 16 with a pointer in every one.
func NewStringArena ¶
func NewStringArena(chunkBytes int) *StringArena
NewStringArena returns a string arena with a chunk size of its own, as New does. Bytes and elements are the same thing here, so chunkBytes is exactly the chunk capacity.
func (*StringArena) Intern ¶
func (a *StringArena) Intern(s string) string
Intern copies s into the arena and returns a stable string view over the copy. A string larger than a whole chunk gets a chunk of its own, which Reset drops rather than recycling — see Arena.Append.
Example ¶
An interned view stays valid until Reset no matter how much is interned after it: a value that does not fit the current chunk starts a new chunk rather than growing one that already has views pointing into it.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
a := arena.NewStringArena(4096)
first := a.Intern("still here")
for range 1000 {
a.Intern("filler, enough of it to force several new chunks")
}
fmt.Println(first, "-", a.Retained() > 4096, "chunks were added after it")
}
Output: still here - true chunks were added after it
func (*StringArena) Str ¶
func (a *StringArena) Str(r Ref[byte]) string
Str resolves r to a string view over the arena's copy — the string counterpart of Value. The absent descriptor resolves to the empty string.
func (*StringArena) StrRef ¶
func (a *StringArena) StrRef(s string) Ref[byte]
StrRef copies s into the arena and returns a pointer-free descriptor for the copy — the string counterpart of AppendRef, resolved by Str. Reach for it over Intern when the batch retains enough strings for the descriptors themselves to matter (see Ref).
The empty string gives the zero Ref, which Str resolves back to "".
s must be shorter than 2 GiB, the bound a Ref can describe, and nothing checks that it is — a longer string comes back from Str as "" or truncated. Intern has no such limit.
Example ¶
StrRef and Str are the descriptor form of Intern: the arena still owns the bytes, but what the caller retains is 12 pointer-free bytes rather than a string header.
package main
import (
"fmt"
"github.com/JohanLindvall/arena"
)
func main() {
a := arena.NewStringArena(4096)
refs := make([]arena.Ref[byte], 0, 3)
for _, s := range []string{"container_id", "level", ""} {
refs = append(refs, a.StrRef(s))
}
for _, r := range refs {
fmt.Printf("%q empty=%v\n", a.Str(r), r.Empty())
}
}
Output: "container_id" empty=false "level" empty=false "" empty=true