Go Runtime Performance

Work through examples, try the reasoning yourself, then put it into practice.

View Slides

Before 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 read

Go 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.

Join Discord