sync.Once Internals
A
sync.Onceguarantees that a given function runs exactly once, no matter how many goroutines callonce.Do(f)concurrently, and — crucially — that every caller ofDois 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 booleandoneplus a Mutexm. 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 runsfand 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-opLock/Unlockmethods purely sogo vet’scopylocksanalyzer flags any code that copies aOnceby value. Copying aOnceis a bug: the copy has a freshdone=false, sofwould run a second time.done atomic.Bool— the “has the action completed?” flag. The comment in the source is explicit thatdoneis placed first in the struct deliberately: “It is first in the struct because it is used in the hot path. … Placingdonefirst 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 becauseDo’s fast path is inlined at every call site, so the instruction-count saving multiplies.atomic.Boolis itself a one-word typed atomic (auint32under 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
Dodeadlocks. Iffitself callsonce.Do(f)on the sameOnce, the inner call blocks onm.Lock()— which the outer call still holds — forever. The doc comment states it plainly: “iffcausesDoto be called, it will deadlock.” - A panicking
fpermanently disables theOnce. Becausedone.Store(true)is deferred, a panic still marks theOncedone. If you need retry-on-failure semantics,Onceis the wrong tool — use your own flag, orOnceValuesreturning anerrorplus an explicit check, or a retry loop guarded by a Mutex. - “Once per function value” is wrong.
Onceis once perOnceinstance, not per function.once.Do(fA); once.Do(fB)runsfAand neverfB. Each distinct action needs its ownOnce. - Copying a
Once. Passing aOnceby value (or embedding it in a struct that is copied) yields a freshdone=falsecopy, sofcan run again.go vetflags this via thenoCopymarker — heed it. - Misusing the result. With a raw
Once, the result variable must be assigned insidefand read afterDoreturns. Reading it before, or assigning it outsidef, breaks the synchronization guarantee. TheOnceValuewrappers exist precisely to remove this footgun.
Alternatives and When to Choose Them
sync.OnceValue/OnceValues/OnceFunc— prefer these over a hand-rolledOncefor 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, aninitfunction is simpler and needs no synchronization at all.Onceis 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
frunning more than once (idempotent, cheap construction) and only want the result to be published atomically, anatomic.PointerCAS 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
- sync.Mutex Internals — the
mfield is exactly one of these; serializes the first race - Atomic Operations in Go —
atomic.Booland the double-checked-locking correctness argument - Go Memory Model — the explicit “completion of
fsynchronizes before everyDoreturn” clause - sync.RWMutex Internals — shares the fast-path-inline / slow-path-outline structure
- The sync Package Design —
Onceas one piece of the widersynctoolkit - panic and recover — why a panicking
fpermanently spends theOnce - Go Internals MOC — section 9, Concurrency Internals