skip to content

How do you make first-use initialization inside a zero-usable Go type safe when several goroutines call its methods?

level: middleimportance: nice to knowfreq 34%

answer

  1. the check must be inside the critical section
  2. two goroutines, two maps, one survivor
  3. the -race build shows both stacks
  4. a field whose zero value has not fired
  5. assigning to a field needs a pointer receiver

basics

~20 s

Do the nil check inside the mutex the type already holds, or use a sync.Once field. Both keep the zero value usable. An unlocked nil check is a data race and can throw away one goroutine's map.

solid answer

~50 s

The tempting version — check `if c.counts == nil` first, then lock — is a data race: two goroutines can both read nil, both call `make`, and whichever assignment lands second wins, silently discarding the other map along with anything written into it. The simple fix is to move the check inside the critical section, so the same mutex that protects the field also protects its creation. When the setup is expensive or shared by several methods, a `sync.Once` field is cleaner: `once.Do(c.init)` runs the function exactly once no matter how many goroutines arrive together, and `sync.Once`'s own zero value has not fired yet, so adding it does not break the type's usable zero value. Whichever you choose, the initializing method needs a pointer receiver, because a value receiver would mutate a copy and initialize nothing.

code

go · 8 lines
go
func (c *Counter) Add(name string, n int64) {
	if c.counts == nil { // data race: read outside the lock
		c.counts = make(map[string]int64)
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[name] += n
}

go deeper

for a junior

Know that if two goroutines can call the same method, anything that creates a shared field must be protected. Reaching for the mutex the type already has is the expected first answer.

for a middle

Explain why the unlocked check is wrong in two ways — lost updates and an undefined-behaviour data race — and be able to write both the lock-guarded check and the sync.Once version, including why Once's own zero value keeps the pattern working.

for a senior

Show that you weigh the cost: a branch on every write path, the maintenance burden of repeating it, and the point where setup that can fail must move into a constructor. Mentioning that a -race build reproduces this only if the path runs concurrently in the test is a strong signal.

for a principal

Set the expectation for the codebase. Lazily built internals keep types declarable but move failure to first use, so decide which of your published types promise it, and make sure anything with a lifetime or a failure mode is built by a constructor instead.

## The problem lazy initialization creates Keeping a type usable at its zero value often means building an internal member the first time a method needs it. Single-threaded that is trivial. Under concurrency it is a classic race, and the race is easy to write by accident because the code *looks* like it is protected: ```go func (c *Counter) Add(name string, n int64) { if c.counts == nil { // read outside any lock c.counts = make(map[string]int64) } c.mu.Lock() defer c.mu.Unlock() c.counts[name] += n } ``` Two goroutines can both evaluate the nil check before either assigns. Both call `make`. Both assign. One map survives; the other, plus every increment written into it, vanishes. Worse, an unsynchronised read of a field that another goroutine writes is a data race under Go's memory model — the behaviour is undefined, not merely "last writer wins", and a `-race` build reports it with both stacks. ## Option 1: check inside the lock If the type already carries a mutex to protect the member, use it for the creation too: ```go func (c *Counter) Add(name string, n int64) { c.mu.Lock() defer c.mu.Unlock() if c.counts == nil { c.counts = make(map[string]int64) } c.counts[name] += n } ``` One lock covers both the creation and the write, so there is exactly one map and no race. The cost is a branch on every call — negligible next to a mutex acquisition — and the discipline of repeating the check on every write path. If the type has five methods that touch the member, five checks is a maintenance smell; factor them into one unexported `c.ensure()` called under the lock, or move to the next option. ## Option 2: a sync.Once field ```go type Registry struct { once sync.Once mu sync.Mutex names map[string]int } func (r *Registry) init() { r.names = make(map[string]int) } func (r *Registry) Put(name string, v int) { r.once.Do(r.init) r.mu.Lock() defer r.mu.Unlock() r.names[name] = v } ``` `sync.Once` guarantees the function runs exactly once and that every caller of `Do` returns only after that run has completed — so the goroutine that arrives second is guaranteed to see the initialized member, which is the memory-ordering half of the problem, not just the mutual-exclusion half. `Do` is also cheap on the fast path after the first call. Two properties matter for this leaf specifically. First, **`sync.Once`'s own zero value is the not-yet-run state**, so adding it as a field costs nothing and does not itself require a constructor — the pattern stays self-hosting. Second, **`Once` considers the function done even if it panics**, so a setup step that can fail is a poor fit; a later call will proceed against a half-built value rather than retrying. ## The receiver detail people miss Any method that assigns to a field must have a **pointer receiver**. Written with a value receiver, `func (c Counter) Add(...)`, the assignment `c.counts = make(...)` mutates a copy that is discarded at return, so every call re-initializes and nothing is ever stored. This is the one place where lazy initialization interacts with method-set rules: `var c Counter; c.Add(...)` compiles because `c` is addressable and the compiler takes its address for you, but the same type used as a map element cannot have a pointer-receiver method called on it directly. ## When to stop and require a constructor Lazy initialization is not free forever. If first use has to start a goroutine, open a file, or dial something that can fail, you are pushing an error — and a lifetime — into a method that has no good way to report either. At that point the honest answer is a constructor that returns the built value and an error, plus a doc comment saying the zero value is not usable. Lazy setup is for allocation, not for work that can fail.

  • Why is sync.Once often better than repeating a nil check in every method?
    Because the check stops being a per-method obligation. `Do` guarantees one run and, just as importantly, that later callers observe the completed setup, so several methods can call it without coordinating. It also concentrates the setup in one named function instead of scattering `make` calls across write paths.
  • What goes wrong if the lazily initializing method uses a value receiver?
    Nothing is initialized. A value receiver gets a copy of the struct, so `c.counts = make(...)` writes into that copy and it is discarded when the method returns. Every call repeats the work and the original value stays nil. Any method that assigns to a field needs a pointer receiver.
  • When would you refuse to lazily initialize and demand a constructor instead?
    When first use must do something that can fail or that owns a lifetime — dialling a network address, opening a file, starting a background goroutine. A method called for another purpose has no natural place to return that error or to expose a Close, so a constructor returning the value and an error is the honest shape.

saying these in an interview costs you the question

  • Checks for nil before locking and calls it safe
  • Says an assignment to a field is atomic in Go
  • Uses a value receiver for the initializing method
  • Believes sync.Once retries when the function panics
  • Thinks the race detector proves code has no races
  • Adds a second mutex just to guard the first-use check