sync.Once is a small synchronization primitive with one clear job: run an action exactly once, even when many goroutines attempt it concurrently.
Using sync.Once
A Once value is commonly used for one-time initialization:
var once sync.Once func initialize() { once.Do(func() { fmt.Println("this code runs only once") })}
Multiple goroutines can call initialize at the same time. Only one of them executes the function passed to Do. Every caller waits until that function has finished before returning from Do.
The guarantee belongs to the Once value rather than to the function. Calling the same Once again with a different function will not execute that function.
The implementation
The implementation is surprisingly small:
type Once struct { done uint32 m Mutex} func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 0 { o.doSlow(f) }} func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() }}
It contains only two pieces of state:
donerecords whether the function has completed.mcoordinates goroutines while the first call is still running.
Why there are two paths
Splitting the work into a fast path and a slow path keeps repeated calls inexpensive.
Fast path
After initialization completes, done is 1. Every later call performs one atomic load, sees that the work is complete and returns immediately without locking the mutex.
This is the common path during the lifetime of a program, so keeping it small allows the compiler to inline it at each call site.
Slow path
When a goroutine sees done == 0, it enters doSlow and tries to acquire the mutex.
Several goroutines may reach the slow path concurrently. The mutex allows only one to continue at a time. The first goroutine checks done again, runs f and marks the work as complete. Waiting goroutines acquire the mutex later, observe that done is already 1 and return without running f.
The second check inside the mutex is essential. The first atomic check only decides whether a goroutine should enter the slow path. It does not give that goroutine permission to execute the function.
Why a compare-and-swap is not enough
It may seem possible to replace the mutex with a single compare-and-swap:
if atomic.CompareAndSwapUint32(&o.done, 0, 1) { f()}
This does not provide the contract promised by sync.Once. The winning goroutine sets done before f finishes. Another goroutine can then see done == 1 and return while initialization is still running.
sync.Once guarantees more than a single execution. It also guarantees that when any call to Do returns, the one execution of f has finished. The mutex makes competing callers wait and the deferred store changes done only after f returns.
Memory visibility
Suppose goroutine A initializes shared state inside f. How can goroutine B safely use that state after its own call to Do returns?
The Go memory model gives sync.Once a synchronization guarantee: completion of f happens before the return of every Do call for that Once. As a result, writes performed by f are visible to callers after Do returns.
This guarantee is the reason callers should access the initialized value only after calling Do:
var ( once sync.Once config *Config) func getConfig() *Config { once.Do(func() { config = loadConfig() }) return config}
Edge cases
A panic still completes the Once
The store to done is deferred before f runs. Deferred functions execute while a panic unwinds the stack, so done is still set to 1 if f panics.
Later calls to Do will not try the function again. If initialization can fail and must be retried, sync.Once is not the right model by itself.
Calling the same Once recursively deadlocks
var once sync.Once once.Do(func() { once.Do(func() { fmt.Println("this will never run") })})
The outer call holds the internal mutex until its function returns. The inner call waits for that same mutex, but the outer function cannot return while it is waiting for the inner call. The mutex is not reentrant, so the goroutine becomes stuck.
Do not copy a Once after use
sync.Once contains synchronization state and must not be copied after its first use. Pass it by pointer when it needs to move between functions or place it directly inside the owning type.
// Bad: setup receives an independent copy.func setup(o sync.Once) { o.Do(initialize)} // Good: every caller shares the same Once.func setup(o *sync.Once) { o.Do(initialize)}
What I want to remember
sync.Once combines a cheap atomic fast path with a mutex-protected slow path. The atomic load makes calls after initialization inexpensive. The mutex ensures that competing callers wait for initialization to finish and the second done check ensures that only one goroutine runs the function.
The most important detail is not simply that f runs once. It is that every returning caller can safely observe the state initialized by f.