Blog

Asking the compiler what escapes

I was reviewing a parser and found a function returning a pointer to a local variable:

func parseCount(s string) (*int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return nil, err
	}
	return &n, nil
}

My C reflex announced that this must be invalid. Go quite deliberately permits it. The compiler sees that n must outlive the call and arranges for it to live on the heap. The pointer remains valid; the cost is allocation and later garbage-collection work.

The useful question was not “is taking a local address legal?” but “where did this value end up, and why?” The compiler can explain with optimization diagnostics. With the current toolchain I use a command along these lines:

$ go build -gcflags=-m
./count.go:8: moved to heap: n
./count.go:8: &n escapes to heap

The wording changes as the compiler evolves, but the report is far more reliable than guessing from syntax. Taking an address does not always force a heap allocation. If the compiler proves the pointer does not outlive its frame, the value can remain on the stack.

For example:

func addOne(p *int) { *p++ }

func work() int {
	n := 4
	addOne(&n)
	return n
}

If addOne is visible and its parameter does not escape, n need not move to the heap. Conversely, code without an obvious new can allocate because a value flows somewhere the analysis cannot bound.

Interfaces are a common source of surprises. I had a logging helper:

func debug(v interface{}) {
	fmt.Fprintln(os.Stderr, v)
}

Passing a local struct through interface{} and into formatting made it harder for the compiler to prove the lifetime. Formatting itself uses reflection and retains enough generality that values may escape. Replacing the call in a hot parser with a concrete, disabled-by-default trace path removed allocations. I did not replace every fmt call in the program; human time is also a resource, occasionally.

Closures can extend lifetimes too:

func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

The returned closure needs n after counter returns, so the captured state cannot remain in the departed frame. That is the semantics I asked for. Rewriting it merely to satisfy an allocation count would be silly unless profiles identify this path as important.

I built a benchmark around two count parsers, one returning *int and one returning int plus error:

func BenchmarkPointer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		p, _ := parseCount("42")
		sink = *p
	}
}

func BenchmarkValue(b *testing.B) {
	for i := 0; i < b.N; i++ {
		n, _ := parseCountValue("42")
		sink = n
	}
}

The reduced result looked like:

BenchmarkPointer-4    20000000    92.1 ns/op    8 B/op    1 allocs/op
BenchmarkValue-4      30000000    55.7 ns/op    0 B/op    0 allocs/op

I included a package-level sink so the compiler could not discard the result. I also checked the generated diagnostics after changing code; benchmarks without understanding can reward removal of the work being measured.

Returning the value was the better API anyway. The pointer had been introduced to signal absence, but error already represented failure and every successful parse had a number. One unnecessary state created one unnecessary allocation.

Large values complicate the slogan “prefer values.” Copying a large struct can cost more than indirect access, and a pointer may express shared mutable identity. Escape analysis may also keep a pointer target on the stack when its lifetime is bounded. API semantics come first; diagnostics and benchmarks settle performance questions.

Stack allocation is attractive because reclaiming it is nearly free when a function returns. Heap allocation requires allocator work and gives the garbage collector another object to trace. Yet avoiding every heap object is neither possible nor desirable in a program with goroutines, closures, shared state, and dynamic data structures.

Goroutine arguments deserve the same attention. A closure launched with go can outlive the current function, so captured locals commonly escape. Passing a value as an explicit parameter clarifies what is copied, though it does not guarantee stack allocation when the new goroutine itself needs durable storage. Lifetime, not spelling, drives the decision.

Compiler boundaries matter too. Analysis is strongest when function bodies and call behavior are visible. Calls through interfaces, reflection, or assembly can force conservative decisions because the compiler cannot prove retention does not occur. A future compiler may prove more, another reason not to encode today’s diagnostics as an API religion.

I keep the compiler version beside benchmark results. An escape decision is an implementation result, not a language guarantee, and a toolchain upgrade can legitimately move the allocation again.

My current method is deliberately unromantic. I profile to find an allocation-heavy path, reduce it to a benchmark, ask -gcflags=-m what escapes, and change ownership or API shape if the result remains clear. I do not hunt ampersands by sight. The compiler has a data-flow analysis; I have prejudices from another language. One of those scales better.

What Go 1.3 did to GC pauses

I upgraded a memory-heavy worker to Go 1.3 expecting the garbage collector to become invisible. It did not. What changed was more specific and more useful than my expectation.

Go 1.3 performs stack scanning precisely. The collector knows which stack slots contain pointers instead of treating pointer-shaped integers as possible references. This avoids keeping some dead heap objects alive accidentally and enables the runtime to copy growing stacks while correcting pointers.

The release also adds concurrent sweeping. After marking determines which heap objects remain reachable, reclaiming unmarked spans can overlap with application execution rather than keeping all sweep work inside one stop-the-world interval. Allocation may assist or encounter sweep work later, so the cost has moved and changed shape rather than vanished.

I compared GC traces from the same batch input:

Go 1.2: collections less regular, several pauses near 20ms
Go 1.3: shorter observed pauses, sweep work visible during execution

Those are observations from one program, not a portable benchmark. Heap layout, processor count, allocation pattern, and runtime revisions all matter. The collector still stops the world for portions of collection. Go 1.3 is not the future fully concurrent collector some discussions seem to have installed by enthusiasm.

One accidental retention did disappear after the upgrade because a pointer-looking value on a stack no longer kept an object alive. I did not count that as permission to ignore memory profiles. The worker also retained a 16 MB byte slice through a 40-byte subslice, and precise scanning quite correctly kept the whole backing array reachable.

The fix for that case remained a copy:

key := append([]byte(nil), largeBuffer[start:end]...)

Copying 40 bytes allowed the large buffer to die. Runtime precision can distinguish pointers from integers; it cannot infer that I only care about a tiny section of a live array.

Marking begins from roots such as globals and goroutine stacks and follows pointers through reachable heap objects. Precise stack maps improve the root set, while concurrent sweeping deals with memory already proven dead. They improve different phases; calling both simply “the new GC” hides why a workload changes.

I keep pause percentiles and total CPU in view. Moving sweep work out of a pause can reduce tail latency while leaving processor work to be paid during ordinary execution. That is usually a good trade for my service, but a batch program may value throughput differently.

My practical conclusion is to upgrade for the collector improvements, then measure again. Lower pauses do not make allocation free, concurrent sweep does not eliminate all latency, and a precise collector will faithfully preserve every object my program still references, including the embarrassingly large ones.

Growing stacks without segments

After installing Go 1.3 I reran a recursive parser benchmark that had always behaved oddly at certain depths. The new runtime changed how goroutine stacks grow, and the result finally matched the shape I expected.

Before 1.3, a goroutine stack could grow by adding another segment. Function entry checked available space and called runtime machinery when a split was needed. Repeatedly crossing a boundary in a call pattern could produce the notorious “hot stack split”: allocate a segment, return and release it, then need it again on the next iteration.

Go 1.3 replaces segmented growth with a contiguous stack that is copied to a larger allocation. Pointers into the old stack must be adjusted to point into the copy. Stack addresses therefore are not permanent, which is one reason converting a Go pointer to an integer and treating it as a stable address is a rotten idea.

My synthetic test recursively visited a nested expression:

func depth(n *node) int {
	if n == nil {
		return 0
	}
	return 1 + depth(n.child)
}

The benchmark result on my machine was illustrative rather than universal:

Go 1.2:  18400 ns/op
Go 1.3:  12100 ns/op

Normal request depths showed little difference. The improvement appeared near stack-growth behavior, exactly where a microbenchmark can exaggerate it. I am not claiming every recursive function became one third faster.

The copying design depends on knowing which stack words are pointers. Go 1.3 has precise stack scanning, allowing the runtime to update real pointers rather than conservatively treating any pointer-looking word as one. This also helps garbage collection avoid retaining objects merely because an integer happens to resemble an address.

Contiguous does not mean fixed. A goroutine begins with a modest stack and grows when a function’s stack check says more room is required. Growth now entails allocation and copying, so unexpectedly enormous stacks still cost memory and time. Deep recursion can still fail; it merely has a better growth mechanism on the way there.

Stack copying happens at safe points where the runtime understands frame layout. Code that passes Go pointers through unsafe integer storage defeats assumptions needed to relocate them. unsafe was never a promise that undocumented runtime addresses would survive stack growth; Go 1.3 merely makes that bad bargain fail more creatively.

I removed a manual recursion limit that existed solely to dodge segmented-stack performance, but kept the limit that rejects maliciously deep input. A runtime improvement is not an input-validation policy. It is, however, a welcome deletion of one workaround.

Chasing a Go latency spike

An HTTP service of mine usually answered in a few milliseconds but occasionally took more than 100. Average latency looked healthy, which is one of the average’s less charming habits. I wanted to know whether requests were waiting on the network, the scheduler, or garbage collection.

My first attempt added timers around every handler. That established that a slow request was slow, a breakthrough unlikely to threaten the scientific establishment. I needed observations below the handler.

I started with GODEBUG=gctrace=1. A run under load printed lines resembling:

gc1(1): 0+6+1 ms, 18 -> 22 MB 12033 -> 8411 (123004-114593) objects
gc2(1): 0+9+1 ms, 25 -> 29 MB 15020 -> 9170 (171220-162050) objects

The exact format is runtime-version dependent, but the useful correlation was clear: several latency spikes lined up with collections. The Go collector at this point includes stop-the-world work. During those pauses, goroutines do not make application progress. More processors do not turn a global pause into concurrent work.

I then reduced allocation rate rather than immediately tuning the collector. A JSON path decoded first into interface{} and then into a struct. Removing that intermediate object graph reduced collection frequency. Reusing a scratch byte slice within a request also helped, provided I kept ownership local and did not retain a huge backing array for a tiny value.

Heap size and pause time are related but not identical. Setting GOGC lower collects more often with a smaller heap; setting it higher collects less often while retaining more memory. Neither setting fixes accidental allocation. I leave GOGC at its default until measurements show a reason to trade memory for collection frequency.

The scheduler was the next suspect. Go multiplexes goroutines onto operating-system threads. Runnable goroutines wait in scheduler queues; goroutines blocked in syscalls or channel operations allow others to run. GOMAXPROCS controls how many processors may execute Go code simultaneously, not how many sockets the program may hold and not a magic goroutine limit.

I had one CPU-heavy goroutine parsing a large report. Function calls provide scheduling opportunities in the current runtime, but preemption is only partial. A long computation with few safe points can delay other runnable work, particularly with a low GOMAXPROCS. Breaking the report into bounded jobs improved fairness and made cancellation by closing a work channel practical. Adding runtime.Gosched inside the innermost loop helped one test and made the algorithm uglier, so I did not keep it.

Network waiting has a different path. On supported systems, the runtime’s network poller integrates non-blocking descriptors with mechanisms such as kqueue or epoll. A goroutine waiting for socket readiness can park without consuming an operating-system thread for the duration. When the descriptor becomes ready, the poller makes the goroutine runnable again.

This is why one goroutine per connection can be practical. It does not mean each connection has a dedicated thread. It also does not mean network code is immune to thread exhaustion: file operations and syscalls that the poller cannot manage may block threads, DNS behavior varies by platform and resolver path, and cgo calls have their own costs.

I made the wait visible by taking a goroutine profile from net/http/pprof while the service was loaded. Most goroutines were parked in network reads, which was normal. The interesting stack was a growing set waiting on one mutex around a response cache. My “thread-safe map” protected JSON encoding under the lock, turning a short critical section into serialized CPU work.

The repair copied the cached value’s reference while holding the lock, unlocked, and encoded afterward. That shortened scheduler queues during bursts. I verified the data was immutable before doing this; moving mutable state outside a lock would have exchanged latency for corruption, a poor bargain even by my standards.

One test run then looked roughly like this:

before: p50=4ms  p95=37ms  p99=128ms
after:  p50=3ms  p95=11ms  p99=42ms

The remaining tail still correlated with collections. This was not a proof that all pauses were GC pauses: kernel scheduling, packet loss, disk activity, and clients contribute too. I kept wall-clock request histograms beside allocation and GC observations rather than assigning every gap to the runtime.

The mechanism suggests a practical diagnostic order. First, establish whether time is spent runnable, blocked on synchronization, parked on network I/O, or stopped globally. Goroutine profiles reveal blocking locations, mutex and channel structure reveal serialization, GC traces reveal collection timing, and CPU profiles reveal actual execution. Then change the source of delay.

I also capped the work queue. Before that change, a burst created more live request state than workers could consume, raising heap size and GC cost after traffic subsided. Back pressure made overload visible to callers instead of translating it into a delayed collector problem. A goroutine waiting in a queue is still retained work.

My direct opinion is that “goroutines are cheap” is useful sales material and terrible capacity planning. Their stacks consume memory, their work must be scheduled, their objects burden the collector, and their shared locks serialize. The runtime makes concurrency remarkably approachable, but it cannot make an unbounded queue or a giant critical section reasonable. Mine was a runtime mystery only until I measured my own code.

The two words inside an interface

I changed a formatter from a concrete parameter to an interface and expected the call to behave like a generic wrapper around the value. The program worked, but looking at method dispatch made the cost and behavior less mysterious.

Conceptually, an interface value is two machine words: information describing the dynamic type and a word referring to the dynamic value. For a non-empty interface, the type information also leads to the method table needed to dispatch calls. An empty interface needs no method table, but it still carries dynamic type and value.

Copying an interface copies those two words, not the object they describe:

var src io.Reader = bytes.NewBufferString("hello")
dst := src

Both interfaces refer to the same buffer. Reading through dst advances the state later observed through src; the assignment did not clone the buffer. This is easy to miss because the interface itself is a small value while the dynamic value may be shared mutable state.

Calls through an interface use the implementation selected by the dynamic type. Go’s implicit satisfaction means the concrete type does not declare which interfaces it implements; the compiler checks the method set at assignment or call sites. Pointer and value method sets still matter, so T and *T need not satisfy the same interface.

The two-word representation does not promise a two-word operation. A call still dispatches to the dynamic type’s method, and whether a value escapes depends on how the compiler can follow it through that call. Interface size alone says little about allocation.

An interface-to-interface assignment can change the static contract while preserving the same dynamic value. A type assertion asks whether that dynamic type satisfies another interface; it does not convert the object into a new representation. I use the two-result form when failure is ordinary:

c, ok := w.(io.Closer)

The one-result form panics on failure, which belongs only where the invariant is truly guaranteed.

I kept the interface where it represented a real behavioral boundary and restored the concrete parameter in the private formatter. Interfaces are contracts, not performance annotations or automatic copies of the values inside them.