sync.Once Internals

A sync.Once guarantees that a given function runs exactly once, no matter how many goroutines call once.Do(f) concurrently, and — crucially — that every caller of Do is blocked until that one execution has completed. It is the canonical Go primitive for lazy, thread-safe initialization. Internally it is astonishingly small: one atomic boolean done plus a Mutex m. The atomic flag is the lock-free fast path that every post-initialization call takes; the mutex is the slow path that serializes the first race of callers so that exactly one runs f and the rest wait for it (sync/once.go).

Mental Model

The single insight to internalize: a naive CompareAndSwap on a boolean is wrong for Once, because winning the CAS only proves you are first — it says nothing about whether f has finished. Once’s contract is stronger: when Do returns, f has completed and its effects are visible. So Once cannot be just an atomic flag. It is a flag plus a mutex: the flag answers “has it already finished?” in one lock-free instruction (the case that matters for the millions of calls after initialization), and the mutex handles the brief, one-time window where several goroutines race to be the initializer — the loser threads block on the mutex until the winner’s f returns, then observe the flag set and leave.

flowchart TD
    Start["once.Do(f)"] --> Fast{"done.Load() == true?"}
    Fast -->|yes| Ret["return immediately<br/>(lock-free fast path)"]
    Fast -->|no| Slow["doSlow(f)"]
    Slow --> Lock["m.Lock()"]
    Lock --> Recheck{"done.Load() == true?<br/>(double-check)"}
    Recheck -->|yes: lost the race| Unlock1["m.Unlock(); return"]
    Recheck -->|no: we are first| Run["defer done.Store(true)<br/>run f()"]
    Run --> Unlock2["f returns -> done set -> m.Unlock()"]
    Unlock2 --> Ret2["return"]

Diagram: Do’s two-tier structure. The insight to extract: the cheap done.Load() at the top is the path taken by essentially every call in a program’s life; the mutex and the double-checked done.Load() inside doSlow are paid exactly once, by the small set of goroutines that happen to race the very first call.

Internal State Layout

The struct is four lines (sync/once.go):

type Once struct {
	_ noCopy
 
	done atomic.Bool
	m    Mutex
}
  • _ noCopy — the zero-size copy-detection marker. It has no-op Lock/Unlock methods purely so go vet’s copylocks analyzer flags any code that copies a Once by value. Copying a Once is a bug: the copy has a fresh done=false, so f would run a second time.
  • done atomic.Bool — the “has the action completed?” flag. The comment in the source is explicit that done is placed first in the struct deliberately: “It is first in the struct because it is used in the hot path. … Placing done first allows more compact instructions on some architectures (amd64/386), and fewer instructions (to calculate offset) on other architectures.” A field at offset 0 needs no displacement in the addressing mode — a genuine micro-optimization, justified because Do’s fast path is inlined at every call site, so the instruction-count saving multiplies. atomic.Bool is itself a one-word typed atomic (a uint32 under the hood).
  • m Mutex — a plain Mutex used only to serialize the first race. After initialization it is never locked again.

Mechanical Walk-through

Do — the fast path

func (o *Once) Do(f func()) {
	if !o.done.Load() {
		o.doSlow(f)
	}
}

Do does a single atomic load of done. If it is already true, Do returns immediately — no lock, no syscall, just one atomic read, and because this is so small the compiler inlines it directly into the caller. For the overwhelmingly common case (the initialization happened long ago), once.Do(f) compiles down to little more than “load a word, branch if non-zero.” The slow path is outlined into a separate doSlow function precisely so that the fast path stays small enough to inline — the same outlining trick used in RWMutex.RUnlock.

doSlow — the one-time serialization

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if !o.done.Load() {
		defer o.done.Store(true)
		f()
	}
}

A goroutine reaches doSlow only if it observed done == false. It acquires the mutex m. Now comes the double-check: it loads done again, under the lock. Why? Several goroutines may have all seen done == false at the top of Do and all entered doSlow. Only one acquires m first; it finds done still false, so it runs f. The others queue on m.Lock(); by the time each acquires the mutex, the winner has already finished, so their inner done.Load() returns true and they skip f and just unlock. This is the classic double-checked locking pattern — and unlike in C++ or Java before their memory models were fixed, it is correct in Go because atomic.Bool provides sequentially-consistent ordering (see Go Memory Model).

The ordering of the two defers is the subtle heart of the implementation. defer o.m.Unlock() is registered first, so it runs last. defer o.done.Store(true) is registered second (inside the if), so it runs first on the way out. The unwind order is therefore: f() returns → done.Store(true)m.Unlock(). This means done is set to true only after f has fully returned, and the mutex is released only after done is set. A goroutine waiting on m.Lock() cannot acquire the mutex until m.Unlock() runs, by which point done is already true — so its double-check is guaranteed to see the completed state. This is why the source comment warns against the “incorrect implementation” if o.done.CompareAndSwap(false, true) { f() }: that version sets the flag before running f, so a concurrent caller could see done == true, skip the slow path, and use the uninitialized result while f is still executing.

Using defer for the done.Store also handles a panicking f gracefully: the doc comment states “If f panics, Do considers it to have returned; future calls of Do return without calling f.” Because done.Store(true) is deferred, it executes even as the panic unwinds through doSlow — so a Once whose f panicked is spent; it will never retry. This is a deliberate, sometimes surprising, design choice.

Memory Model Guarantees

The Go Memory Model gives Once an explicit clause: “The completion of a single call of f() from once.Do(f) is synchronized before the return of any call of once.Do(f)” (go.dev/ref/mem). Read carefully: it is the completion of f — not the start, not the CAS — that synchronizes-before every Do return, including the fast-path returns that never touched the mutex. The implementation earns this for the fast path through the atomic.Bool: the winner’s done.Store(true) happens-after f completes (defer order), and any later done.Load() that observes true is, by the atomics rule “if the effect of an atomic operation A is observed by B then A synchronizes-before B,” synchronized-after that store — and transitively after f. So even a caller that only ever did the one-instruction fast path is guaranteed to see everything f wrote.

Code Examples

Canonical lazy initialization

var (
	once   sync.Once
	client *http.Client
)
 
func Client() *http.Client {
	once.Do(func() {
		client = &http.Client{Timeout: 10 * time.Second}  // runs exactly once
	})
	return client
}

Every Client() call after the first is a single atomic load plus a pointer return. Note client is assigned inside f; because f’s completion synchronizes-before every Do return, the assignment to client is safely visible to all callers without any further synchronization.

The Go 1.21 helpers: OnceFunc, OnceValue, OnceValues

Go 1.21 added three wrappers built on Once (sync/oncefunc.go):

// OnceValue returns a function that invokes g only once and
// returns g's result on every call.
var config = sync.OnceValue(func() Config {
	return loadConfigFromDisk()  // runs once, lazily, on first config() call
})
 
func handler() { c := config(); _ = c }

sync.OnceValue(g) returns a func() T that, on first call, runs g, caches its return value, and on every subsequent call returns the cache. OnceValues is the two-result version; OnceFunc wraps a func() with no result. These eliminate the boilerplate of declaring a package-level Once, a result variable, and a getter — and, importantly, they re-panic on every call if the wrapped function panicked, instead of silently doing nothing as a raw Once does. They are the recommended modern form for lazy values.

Failure Modes and Common Misunderstandings

  • Recursive Do deadlocks. If f itself calls once.Do(f) on the same Once, the inner call blocks on m.Lock() — which the outer call still holds — forever. The doc comment states it plainly: “if f causes Do to be called, it will deadlock.”
  • A panicking f permanently disables the Once. Because done.Store(true) is deferred, a panic still marks the Once done. If you need retry-on-failure semantics, Once is the wrong tool — use your own flag, or OnceValues returning an error plus an explicit check, or a retry loop guarded by a Mutex.
  • “Once per function value” is wrong. Once is once per Once instance, not per function. once.Do(fA); once.Do(fB) runs fA and never fB. Each distinct action needs its own Once.
  • Copying a Once. Passing a Once by value (or embedding it in a struct that is copied) yields a fresh done=false copy, so f can run again. go vet flags this via the noCopy marker — heed it.
  • Misusing the result. With a raw Once, the result variable must be assigned inside f and read after Do returns. Reading it before, or assigning it outside f, breaks the synchronization guarantee. The OnceValue wrappers exist precisely to remove this footgun.

Alternatives and When to Choose Them

  • sync.OnceValue / OnceValues / OnceFunc — prefer these over a hand-rolled Once for lazy values; they are correct by construction and propagate panics.
  • Package init() functions — for initialization that must happen eagerly at program start and has no inputs, an init function is simpler and needs no synchronization at all. Once is for lazy initialization, deferred until first use and possibly never.
  • A package-level variable assigned at declaration — for a value computable at compile time or from cheap pure code, just var x = compute(); the Go runtime guarantees package-variable initialization is single-threaded and ordered.
  • atomic.Pointer with double-checked construction — if you can tolerate f running more than once (idempotent, cheap construction) and only want the result to be published atomically, an atomic.Pointer CAS avoids even the one-time mutex. But this is rarely worth the complexity.

Production Notes

Once’s fast path is as cheap as concurrency primitives get — a single relaxed-cost atomic load that inlines into the caller — so guarding hot lazy-initialized resources (HTTP clients, compiled regexps, database pools, parsed config) with Once is essentially free after the first call. The one production trap worth flagging: because a panicking f spends the Once permanently, code that initializes something fallible (opening a file, dialing a network service) inside Once.Do and lets it panic will leave the program in a half-initialized state forever, with later callers happily using a nil resource. For fallible initialization, capture the error: var initErr error; once.Do(func(){ resource, initErr = open() }), then check initErr after Do returns — or use sync.OnceValues[Resource, error].

See Also