Go Runtime Performance
Work through examples, try the reasoning yourself, then put it into practice.
View SlidesBefore you start
- Trace Go byte-slice length/capacity and integer bounds; revisit the binary-protocol lesson for buffer ownership.
What you will practice
- Derive consumed-byte, response-capacity and arena-generation boundaries.
- Compare measured allocation, time and retention while preserving correctness.
In this lesson
Lesson
6 min readGo Runtime Performance: Ownership Before Tuning
Build two bounded components: HandleRequest parses one request into caller-owned
response storage, and Arena hands out slices of one backing buffer. The target
is correct behavior with zero hot-path heap allocations under the tested workload.
Neither exercise is a production HTTP server nor Go's experimental arena API.
Prepare by tracing slice length/capacity, byte indices, and integer bounds.
A request is a byte range and a lifetime
For GET /health HTTP/1.1\r\n\r\n, the request line has 20 bytes, followed by
two CRLF pairs: consumed=24. If a second request follows, it remains available
at req[24:]. Parse only through the first header terminator. The response body
is ok, whose Content-Length is 2; response bytes and consumed request bytes
measure different things.
Model the handler as three stages: locate a complete header block; validate and select response parts; calculate required output size before writing. With no terminator, return ErrNeedMore and zero consumption. If the response will not fit, return ErrShortBuffer and zero consumption so the caller can retry with a larger destination. Cancellation is checked at entry; this synchronous exercise does not promise interruption midway through parsing.
The course accepts bodyless requests and a small set of paths. /echo?msg=a%20b
returns the literal five bytes a%20b; it does not URL-decode a space. Header
names are case-insensitive; the GET method token is case-sensitive. Do not
infer a complete HTTP parser from this limited contract.
Slice views can avoid copies while scanning. They remain aliases to req:
finish reading them before reusing the input buffer. Request and destination
buffers must not overlap. A zero-copy parse is safe only with a clear lifetime;
retaining a small slice can also keep a large backing array reachable.
Derive arena bounds before doing arithmetic
Keep 0 <= off <= len(buf). A successful Alloc(n) reserves [off,off+n) and
returns a full slice expression with length and capacity n. Limiting capacity
prevents an ordinary append from overwriting the next reservation; it does not
make the bytes immutable.
Avoid testing off+n <= len(buf) before checking overflow. For n>0, use
n <= len(buf)-off; only then form the sum. With capacity 16 and off=3,
Alloc(MaxInt) must return nil and leave off=3. Wrapped arithmetic is not a
small successful allocation.
AllocAligned aligns the buffer index, not an arbitrary machine address.
For a positive power-of-two alignment, derive padding as
(align - (off & (align-1))) & (align-1). First check padding against remaining
space, then n against the remaining space after padding.
| Operation on a 16-byte arena | Reservation | New offset | Remaining |
|---|---|---|---|
| Alloc(3) | [0,3) |
3 | 13 |
| AllocAligned(4,8) | [8,12); five bytes padding |
12 | 4 |
| AllocAligned(5,4) | failure | 12 | 4 |
| Alloc(4) | [12,16) |
16 | 0 |
On failure, do not consume even alignment padding. AllocString copies its
input, so its time is O(len(s)); allocation bookkeeping is O(1). Creating the
backing buffer costs O(size), and Reset only rewinds the offset.
Reset is an ownership boundary
Allocate three bytes, write cat, call Reset, allocate three bytes again, and
write dog. The old slice now also reads dog: Reset neither frees the backing
array nor clears it. All old reservations become invalid for continued logical
use when the arena is reset. They must be out of use before reuse starts.
One arena per independent owner is a simple design. Sharing an arena across concurrent requests needs synchronization and a lifetime protocol; a mutex around Alloc alone cannot protect readers from a concurrent Reset. Arena reuse also retains its full capacity, which may increase the live heap after a traffic spike.
Measure one explicit workload
Keep setup outside the timed loop; reuse the same prebuilt input and destination;
check results so the benchmark exercises the successful path. Use the supplied
BenchmarkHandleRequest and BenchmarkArenaAlloc as starting points. In a
solution workspace with its test files, run:
go test -tags=packtest,hidden ./...
go test -tags=packtest -run '^$' -bench . -benchmem -count=5
Record Go version, CPU, input size and buffer sizes beside the results.
ns/op is average time per operation in that benchmark, not request p99.
B/op is bytes allocated per operation, not retained memory. allocs/op=0 does
not mean zero copies or zero CPU cost. Benchmark incomplete input, short output
and cancellation separately; repeatedly taking an early error can look fast
while testing no useful work.
For a concrete comparison, suppose variant A reports 120 ns/op, 64 B/op, 1 alloc/op and B reports 145 ns/op, 0 B/op, 0 allocs/op. B wins allocation count but loses this microbenchmark's time. Neither tuple proves which meets a service latency objective. Repeat measurements, inspect profiles, and test realistic concurrency before choosing. These numbers illustrate interpretation; they are not measured results for the supplied reference.
Explain runtime cost without folklore
Profile where time and allocations occur before changing architecture. Escape
analysis (go test -gcflags=-m) explains compiler decisions in that build;
returning a value, using an interface, or converting bytes to a string does not
by itself prove a heap allocation. Compiler context and optimizations matter.
Generics constraints describe permitted operations, not a promise of specialized
machine code. See Go's profiling guide.
The standard runtime trades GC CPU work against memory use. GOGC affects its
heap target; GOMEMLIMIT is a soft runtime memory limit, not a hard process
RSS cap. Keeping live data above the limit cannot be fixed by repeatedly tracing
it. Leave headroom for memory outside runtime accounting and reduce retained
state where possible. See the Go GC guide.
For scheduler analysis, G is a goroutine, M an OS thread, and P the resource needed to execute Go code. More goroutines do not create more CPU capacity. Profile runnable contention, blocking and mutex costs before adding workers or shards. Race freedom and ownership remain prerequisites for meaningful results.
Predict, implement, and revisit
On a 12-byte arena, Alloc(5) succeeds. Predict AllocAligned(4,8), then Alloc(7). Explain whether removing Reset from a benchmark can invalidate an apparent win.
Check the boundary reasoning
The aligned request uses [8,12) and succeeds, leaving zero bytes; Alloc(7)
fails. Without Reset, a benchmark can spend nearly all iterations returning nil
instead of doing the intended allocation. Check the return path, not only time.
Implement both exercises, then vary request size and arena generation size without changing ownership. Save a baseline and compare one change at a time. As an independent transfer task, propose how an asynchronous consumer would signal that its arena generation can be reused, and identify the race if that signal is omitted.
Practice sequence
Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.
- Step 1
Zero-Alloc HTTP Server
- Step 2
Byte Arena
- Step 3
Go Runtime Performance Checkpoint
Trace fresh cases, diagnose plausible bugs, and separate the exercise model from production guarantees.