Go

Where an HTTP timeout actually fires

I added a dial timeout to an HTTP client and assumed I had bounded the entire request. Then a server accepted the connection, sent headers, and dribbled the body slowly enough to occupy a worker for minutes. The timeout had completed its duty before the interesting failure began.

In Go 1.3, the first choice for a simple end-to-end limit is http.Client.Timeout:

client := &http.Client{Timeout: 5 * time.Second}

It covers connection setup, redirects, and reading the response body. If the policy really is “this request gets five seconds,” start there. My narrower transport setting looked like this:

tr := &http.Transport{
	Dial: (&net.Dialer{
		Timeout: 2 * time.Second,
	}).Dial,
}
client := &http.Client{Transport: tr}

The dial timeout covers establishing the network connection. It does not cover waiting for response headers, reading a response body, or the time spent following redirects. DNS resolution may consume part of dialing behavior depending on the platform and resolver path, but the timeout is still not an end-to-end request deadline.

To see the phases, I wrote a deliberately bad server:

func slow(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.WriteHeader(http.StatusOK)
	if f, ok := w.(http.Flusher); ok {
		f.Flush()
	}
	time.Sleep(10 * time.Second)
	fmt.Fprintln(w, "eventually")
}

The client connected immediately and received headers immediately. Its Do call returned a response, then reading resp.Body waited. A connect timeout could not help because the connection was exemplary.

The transport exposes other phase-specific controls. ResponseHeaderTimeout bounds the wait for response headers after the request is written. TLSHandshakeTimeout bounds the TLS handshake. Neither bounds a slowly arriving body. These knobs are useful because failures differ: a slow handshake may indicate different capacity trouble than a handler that never writes headers.

For policies that Client.Timeout cannot express, I can arrange cancellation through the transport’s request cancellation support and a timer. Merely returning from a selecting caller while leaving Do running leaks work: timeout handling must interrupt the network operation, not only stop waiting for its answer.

For direct net.Conn code, deadlines are clearer:

if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
	return err
}

The deadline applies to future reads and writes and causes them to fail after the time. SetReadDeadline and SetWriteDeadline split those directions. A deadline is an absolute time, not an idle timeout that automatically moves after every successful byte. For an idle protocol I refresh it after each accepted unit of progress.

HTTP complicates direct connection deadlines because http.Transport owns and reuses pooled connections. A per-request caller should not reach underneath and set arbitrary deadlines on a connection that may later serve another request. Transport-level controls and cancellation preserve that ownership boundary.

As with the earlier runtime-managed network mechanism, a goroutine can wait without occupying a thread. Cheap waiting is not a timeout policy, though. In-flight requests, response body size, queue length, and elapsed time still need deliberate limits.

Servers have the same issue from the opposite side. A handler can be tied up by a client that sends or receives slowly. Depending on the Go version and server configuration, read and write timeouts establish connection-level deadlines around phases of serving a request. They are blunt instruments, especially for streaming responses, but leaving every deadline at zero means “forever.”

I tested the repaired client with three bad endpoints: one that never completed a TCP connection, one that accepted but withheld headers, and one that streamed a byte periodically. Each exercises a different phase. The output made the distinction visible:

dial stall:    timeout awaiting connection
header stall:  timeout awaiting response headers
body stall:    request deadline reached while reading body

Error strings vary and should not become program logic. I care that each operation terminates, closes its body or cancellation path, and leaves no steadily growing goroutine count.

The temptation is to choose one five-second number and call the system safe. Real policies differ. A metadata request might have a strict total deadline. A large download may allow minutes overall but reject 30 seconds without progress. A streaming endpoint may be intentionally unbounded while still needing heartbeat and cancellation rules.

Timeout errors should remain distinguishable from protocol failures. I check whether a returned network error reports Timeout() rather than matching its text, then include the operation and peer in the higher-level error. Retrying blindly is still wrong: a timed-out request may have reached the server, so non-idempotent operations need application-level rules.

“The HTTP timeout” is not a very useful phrase. There are limits for connecting, handshaking, receiving headers, reading bodies, remaining idle, and completing the whole operation. Naming the phase put the mechanism in the right place; before that, I had a very reliable timeout on the part that was not slow.

Parallel benchmarks and wandering maps

I tested a locked lookup table with Go 1.3’s parallel benchmark support:

func BenchmarkLookupParallel(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			_ = table.Lookup("troy")
		}
	})
}

RunParallel creates goroutines and distributes benchmark iterations among them. This exposed lock contention that a serial benchmark politely concealed. It is still synthetic: all workers hammering one key may be less realistic than production, so I use representative key distributions too.

The same upgrade broke a test that compared map traversal output to a fixed string. Go 1.3 randomizes iteration order for small maps, making an assumption that was always invalid fail more reliably.

for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)

Sorting is correct when output order is part of the result. For equality, I compare contents instead of rendering iteration order.

Random order is not cryptographic randomness, nor should a program depend on getting a different order every pass. It is an implementation defense against accidental ordering assumptions, not a shuffling API.

I approve of the randomization. A runtime that accidentally rewards a bad assumption lets the assumption become an API. My test was not flaky; it was finally honest.

sync.Pool is not a cache

I used Go 1.3’s new sync.Pool to reuse temporary buffers in an HTTP formatter. Then I almost used it to cache parsed templates. The first idea reduced garbage; the second would have randomly forgotten application data.

A pool holds temporary values that may be removed at any time. Garbage collection is explicitly allowed to clear it. That makes it suitable for amortizing allocation of scratch objects, not for storing anything whose presence affects correctness.

My buffer pool looks like this:

var buffers = sync.Pool{
	New: func() interface{} {
		return new(bytes.Buffer)
	},
}

func render(v value) []byte {
	b := buffers.Get().(*bytes.Buffer)
	b.Reset()

	writeValue(b, v)
	out := append([]byte(nil), b.Bytes()...)
	if b.Len() < 64<<10 {
		b.Reset()
		buffers.Put(b)
	}
	return out
}

The copy before Put is essential. b.Bytes() aliases the buffer’s storage. Returning that slice and putting the buffer back would allow another goroutine to overwrite the caller’s result. Pool safety does not transfer ownership safety to pooled objects.

The pool synchronizes Get and Put, but an object obtained from it belongs to the caller until returned. Two goroutines must not use the same buffer concurrently. Go 1.3’s bytes.Buffer does not expose its capacity, so I use the rendered length as a simple retention policy: a response that grows a buffer past 64 KiB does not return that buffer to the pool. It is only a proxy for capacity, but it keeps a freshly oversized allocation out of the pool.

In a benchmark of the formatter, pooling reduced allocation volume:

BenchmarkRender-4          500000    4100 ns/op    2304 B/op    12 allocs/op
BenchmarkRenderPool-4      500000    3000 ns/op     768 B/op     5 allocs/op

The output copy accounts for necessary ownership. If the function wrote directly to an io.Writer, even that copy could disappear, probably a better API than ever more pooling.

New should return the expected type consistently. A pool may omit New, in which case Get can return nil and every caller must handle that. I define it when all callers need the same scratch type; this keeps allocation policy together without pretending values are permanent.

Copying a sync.Pool after first use is invalid because its synchronization state and local storage are not value-like application data. I keep pools as package variables or pointer-owned fields initialized once.

I would not add sync.Pool without allocation profiles and a benchmark. It obscures object lifetime and may provide little benefit when allocations are already short and cheap. A cache promises retrieval; a pool merely offers to rummage in a box the collector is free to empty.

The Go 1.3 linker got less clever

One of our larger binaries spent enough time linking that I could switch windows, forget why, and begin an unrelated task. Go 1.3’s linker refactor made that interruption noticeably shorter.

The old linker represented symbols and their data using structures that encouraged many small allocations and expensive traversals. The 1.3 work reorganized that machinery around more compact symbol data and a cleaner division of the linking stages. The user-facing command did not change:

$ go build ./cmd/server

The wait did. On one clean build of our program I saw roughly:

Go 1.2.2   real 4.8s
Go 1.3     real 3.1s

This is not a controlled toolchain benchmark. Installed package archives, the filesystem, machine load, and exact source all affect it. Repeating alternating builds from the same installed state still showed enough difference that it was not merely optimism generated by a new version number.

Linking a Go program is substantial work. The linker reads object data for the program and imported packages, resolves symbol references, applies relocations, lays out executable sections, emits metadata used by reflection and the runtime, and writes the target executable. It also removes unreachable code and data. A statically linked Go binary may contain a great deal of runtime and type information, so representation overhead inside the linker matters.

The refactor is interesting because it improves a tool by making its internal model less elaborate. No language feature was needed. Existing builds became faster and used less memory because the implementation stopped turning every byte and symbol into quite so much bookkeeping.

I still avoid reading too much into final binary size. Importing a package can pull in initialization and reachable symbols, reflection can retain type metadata, and debug information contributes too. A smaller linker heap is not the same thing as a smaller output file.

Cross-compilation makes architecture-specific tool names visible in this toolchain. The compiler and linker remain separate programs selected for the target architecture, even though go build normally hides the sequence. When investigating a failure I use go build -x to print invoked commands rather than reconstructing them from folklore.

That output also shows whether the build uses installed archives from GOROOT/pkg and GOPATH/pkg or recompiles packages. Without it, I had occasionally blamed the linker for time actually spent rebuilding a dependency after a build-tag change.

Benchmark builds with and without the relevant installed archives separately. Archives installed for the standard library under GOROOT/pkg and for workspace packages under GOPATH/pkg can hide compilation work; release builders often expose linker time more clearly. Go 1.3 improved both my build and my attention span, though only one claim has numbers behind it.

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.