skip to content

What does a `-benchmem` benchmark reveal about the cost of each functional option passed to a Go constructor?

level: seniorimportance: nice to knowfreq 30%

answer

  1. the helper returns a closure, not a value
  2. the closure has to hold what it captured
  3. it is passed on, so it escapes
  4. an option capturing nothing is free
  5. built once at start-up, this is noise

basics

~20 s

Roughly one heap allocation per option that captures an argument, plus one for the variadic slice. Each With... helper builds a closure holding its captured value, and that closure escapes into the slice handed to the constructor.

solid answer

~40 s

Every parameterised helper such as `WithTimeout(2*time.Second)` returns a closure capturing its argument. Because that closure is stored in the `opts` slice and passed to another function, the compiler usually cannot prove its lifetime ends in the caller's frame, so it goes on the heap — one allocation per option. The variadic slice is another unless escape analysis keeps its backing array on the stack, and an option capturing nothing costs none. `go test -bench=. -benchmem` shows this as allocs/op scaling with the number of options; `go build -gcflags=-m` names the escaping literals. The judgment matters more than the number: for a client built once at start-up this is invisible, and only worth acting on if you construct per request — then you hoist the option slice out of the loop.

code

go · 15 lines
go
func BenchmarkNewWithOptions(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = New("10.0.0.1:9000",
			WithTimeout(2*time.Second),
			WithKeepAlive(30*time.Second))
	}
}

func BenchmarkNewDefaults(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = New("10.0.0.1:9000")
	}
}

go deeper

for a junior

Know that a With... helper returns a closure holding the value you passed it, and that creating that closure normally costs one small heap allocation rather than being free.

for a middle

Explain the mechanism: the closure captures the helper's argument, it is stored in the option slice and passed onward, so escape analysis moves it to the heap; an option capturing nothing needs no environment and costs nothing.

for a senior

Show that you measure before acting — allocs/op from -benchmem, attribution from go build -gcflags=-m — and that you fix it by hoisting the slice or by not constructing per request, not by redesigning the API first.

for a principal

Decide when this cost is worth a change of exported shape at all. Be able to say what evidence would justify it and why a saving measured in tens of nanoseconds at process start is not it.

## Where the allocations come from Write out what `WithTimeout(2*time.Second)` actually does. `WithTimeout` is a function that returns a function: ```go func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } } ``` The returned function literal refers to `d`, so it is a **closure**: at run time it is a pair of a code pointer and an environment holding the captured `d`. That environment has to live somewhere. Because the closure is returned from `WithTimeout`, stored in a slice, and handed to `New`, the compiler generally cannot prove its lifetime ends with the frame that created it, so the closure is heap-allocated. That is one allocation per parameterised option, per constructor call. The variadic parameter is a second source. `New(addr, a, b, c)` builds a `[]Option` of length three to pass as `opts`. If the compiler can see that `New` does not let the slice outlive the call, the backing array can stay on the stack; if it cannot, that is another allocation. Whether it does depends on the constructor's body and on inlining, which is exactly why you measure rather than assert. An option that captures nothing is the interesting exception: ```go func WithoutRetry() Option { return func(c *Client) { c.retries = 0 } } ``` The literal has no free variables, so there is no environment to allocate; the compiler can use a single static function value. No allocation. ## Measuring it rather than guessing ```go func BenchmarkNewWithOptions(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { _ = New("10.0.0.1:9000", WithTimeout(2*time.Second), WithKeepAlive(30*time.Second)) } } ``` Run it with `go test -bench=BenchmarkNewWithOptions -benchmem`. The `-benchmem` flag adds `B/op` and `allocs/op` columns to the usual `ns/op`; `b.ReportAllocs()` inside the benchmark turns the same reporting on for that benchmark whether or not the flag is passed. Compare a run with two options against a run with none: the difference in `allocs/op` is the per-option cost, and it will track the number of capturing options plus, sometimes, the slice. The complementary tool is the compiler's own explanation: `go build -gcflags=-m ./...` prints escape-analysis decisions, including lines identifying a function literal that escapes to the heap. Where the benchmark tells you *how much*, `-gcflags=-m` tells you *which literal* and *why the compiler could not keep it on the stack*. ## Putting the number in proportion A heap allocation of a two-word closure is tens of nanoseconds. For an RPC transport client constructed once when the process starts, three of them is not a cost — it is noise several orders of magnitude below the first packet you send. Presenting that as a reason to abandon the pattern is the classic premature-optimisation answer, and an interviewer is listening for whether you say so. It becomes real in one shape: **constructing per operation**. If a hot path builds a fresh value with options on every request — a per-request encoder, a per-message writer — then the options are being paid for at request rate rather than at process-start rate, and the allocation is on the profile. ## What you actually do about it In rough order of how much you should be willing to disturb: 1. **Hoist.** Build the `[]Option` once, outside the loop, and pass the same slice every time: `New(addr, opts...)`. The closures are then created once. This is free and usually enough. 2. **Construct once and reuse.** Most types that take options are safe for concurrent use and were never meant to be built per request in the first place; the allocation is a symptom of the wrong lifetime, not of the pattern. 3. **Offer an internal non-option path.** Keep the option API for external callers and have the hot path fill the struct directly inside the package, where the fields are reachable. 4. **Take a plain struct instead.** Only if the settings are data rather than behaviour and the measurement justifies redesigning an exported surface. ## The trap to avoid in the answer Do not claim the allocation is caused by the variadic parameter "boxing" values into interfaces. `Option` is a concrete function type, not `any`; nothing is boxed. And do not claim that passing a pointer to the struct forces a heap allocation — the escape analysis question is about the closure's environment and about whether the constructor lets its argument outlive the call, not about the mere presence of a `*Client`.

  • Why does an option that captures no arguments avoid the allocation?
    A function literal with no free variables needs no environment, so there is nothing to put on the heap; the compiler can represent it with a single static function value shared by every call. The moment the literal refers to a parameter of the enclosing helper, that value has to be stored somewhere with a lifetime longer than the helper's frame.
  • Which tool tells you why a particular closure escaped, as opposed to how many allocations happened?
    `go build -gcflags=-m` prints the compiler's escape-analysis decisions, naming the function literal and saying it escapes to the heap. A benchmark with -benchmem gives you the count and the bytes per operation but no attribution, so the two are complementary: the benchmark tells you there is a cost, the compiler flag tells you which expression pays it.
  • A profile shows option closures on the hot path. What is your first change?
    Hoist the option slice out of the loop and reuse it, or stop constructing the object per operation at all — most option-configured types are built once and shared. Redesigning the exported API to take a struct is the last resort, because it changes a surface other packages compile against for a saving you should first confirm survives the cheaper fixes.

saying these in an interview costs you the question

  • Claiming the variadic parameter boxes values into interfaces
  • Saying every option allocates, including ones that capture nothing
  • Treating three allocations at process start as a reason to drop the pattern
  • Assuming the compiler always keeps the option slice on the stack
  • Rewriting the exported API before measuring or hoisting the slice