sync.Once runs exactly once¶
Level: 201 · anyone initializing something lazily from more than one goroutine
One line: However many goroutines call once.Do(f) at the same moment, f runs once and every call returns only after it has. If f panics, Once forgets the panic, while sync.OnceFunc repeats it on every call.
Verified output of once_runs_exactly_once_go.go — regenerated by tools/run_examples.py, never hand-typed.
sync.Once
calls to Do: 10
times loadConfig ran: 1
callers that saw :8080: 10
sync.OnceValue
calls: 10
times the function ran: 1
callers that got :8080: 10
sync.OnceValues
calls: 10
times the function ran: 1
error every caller got: strconv.Atoi: parsing "80x": invalid syntax
callers that got it: 10
Reading the output¶
Each section starts ten goroutines, holds them at a channel, and releases them together by closing it, so the ten calls really do overlap.
sync.Once. All ten called once.Do(loadConfig), and loadConfig ran once. All ten then read config and saw :8080, including the nine whose Do did not run it. That read is safe because of a promise in the Do documentation ↗: no call to Do returns until the one call to f has returned. A goroutine that arrives while loadConfig is still running waits inside Do.
sync.OnceValue. Go 1.21 ↗ added three functions for the common use of Once, lazily initializing a value. OnceValue(f) returns a function that calls f the first time and returns its result on every call. The value and its guard are now one thing, so the value can't be read without going through the guard.
sync.OnceValues. The same for a function with two results, usually a value and an error. The error is a result like any other: strconv.Atoi("80x") ran once, and all ten callers got its error back. OnceValues never calls the function again, so a failed initialization stays failed.
once_runs_exactly_once_go.go
once_runs_exactly_once_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// sync.Once, and the three functions built on it: ten goroutines ask for the
// configuration at the same moment, and it is loaded exactly once.
//
// go build once_runs_exactly_once_go.go && ./once_runs_exactly_once_go
package main
import (
"fmt"
"strconv"
"sync"
"sync/atomic"
)
const callers = 10
// together starts callers goroutines, releases them at the same moment, and
// waits for all of them. Each gets its own index.
func together(work func(i int)) {
var wg sync.WaitGroup
start := make(chan struct{})
for i := range callers {
wg.Go(func() {
<-start
work(i)
})
}
close(start)
wg.Wait()
}
func main() {
// sync.Once: Do runs its function on the first call only. Every other call
// waits until that one has returned, so reading config after Do is safe.
var (
once sync.Once
loads atomic.Int64
config map[string]string
)
loadConfig := func() {
loads.Add(1)
config = map[string]string{"listen": ":8080"}
}
seen := make([]string, callers)
together(func(i int) {
once.Do(loadConfig)
seen[i] = config["listen"]
})
fmt.Println("sync.Once")
fmt.Println(" calls to Do: ", callers)
fmt.Println(" times loadConfig ran: ", loads.Load())
fmt.Println(" callers that saw :8080:", count(seen, ":8080"))
// sync.OnceValue: the same, for a function that returns a value.
var reads atomic.Int64
listenAddr := sync.OnceValue(func() string {
reads.Add(1)
return ":8080"
})
together(func(i int) { seen[i] = listenAddr() })
fmt.Println("sync.OnceValue")
fmt.Println(" calls: ", callers)
fmt.Println(" times the function ran:", reads.Load())
fmt.Println(" callers that got :8080:", count(seen, ":8080"))
// sync.OnceValues: two results, usually a value and an error. An error is
// remembered like any other result: the function is not tried again.
var parses atomic.Int64
port := sync.OnceValues(func() (int, error) {
parses.Add(1)
return strconv.Atoi("80x")
})
together(func(i int) {
_, err := port()
seen[i] = fmt.Sprint(err)
})
fmt.Println("sync.OnceValues")
fmt.Println(" calls: ", callers)
fmt.Println(" times the function ran:", parses.Load())
fmt.Println(" error every caller got:", seen[0])
fmt.Println(" callers that got it: ", count(seen, seen[0]))
}
func count(values []string, want string) int {
n := 0
for _, v := range values {
if v == want {
n++
}
}
return n
}
When the function panics¶
loadConfig now always panics. The program calls it three times through a sync.Once and three times through sync.OnceFunc:
Verified output of once_runs_exactly_once_panic_go.go — regenerated by tools/run_examples.py, never hand-typed.
once.Do(loadConfig)
call 1: panic = config file missing
call 2: panic = <nil>
call 3: panic = <nil>
loadConfig ran: 1
load := sync.OnceFunc(loadConfig)
call 1: panic = config file missing
call 2: panic = config file missing
call 3: panic = config file missing
loadConfig ran: 1
once.Do let the panic through the first time and then treated loadConfig as having returned. The second and third calls returned normally without calling it. The Do documentation says exactly this: if f panics, Do considers it to have returned. What that means in practice: code after once.Do goes on as if the configuration were loaded, and it isn't.
sync.OnceFunc also ran loadConfig once, but every call panicked with the same value, config file missing. The documentation ↗ promises this for OnceFunc, OnceValue and OnceValues alike: if f panics, the returned function panics with the same value on every call. A broken initialization keeps saying so, instead of looking like success.
The program recovers each panic only so that it can print it.
once_runs_exactly_once_panic_go.go
once_runs_exactly_once_panic_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// When the function panics: sync.Once forgets the panic, sync.OnceFunc repeats
// it. Each is called three times, and each runs the function once.
//
// The program recovers each panic only so that it can print it.
//
// go build once_runs_exactly_once_panic_go.go && ./once_runs_exactly_once_panic_go
package main
import (
"fmt"
"sync"
)
// recovered calls f and returns the value it panicked with, or nil.
func recovered(f func()) (value any) {
defer func() { value = recover() }()
f()
return nil
}
func main() {
runs := 0
loadConfig := func() {
runs++
panic("config file missing")
}
var once sync.Once
fmt.Println("once.Do(loadConfig)")
for call := 1; call <= 3; call++ {
fmt.Printf(" call %d: panic = %v\n", call, recovered(func() { once.Do(loadConfig) }))
}
fmt.Println(" loadConfig ran:", runs)
runs = 0
load := sync.OnceFunc(loadConfig)
fmt.Println("load := sync.OnceFunc(loadConfig)")
for call := 1; call <= 3; call++ {
fmt.Printf(" call %d: panic = %v\n", call, recovered(load))
}
fmt.Println(" loadConfig ran:", runs)
}
What to do¶
- For a value, use
sync.OnceValueorsync.OnceValuesrather than a variable next to async.Once. Nothing can read the value without going through the guard, and a panic repeats instead of vanishing. - Use
sync.Oncefor a side effect with no result. Usesync.OnceFuncif a panic in it should keep being reported. - Decide what a failed initialization means.
OnceValueskeeps the error for good. If a later caller should try again, aOnceis the wrong tool: use a mutex and record success only when it happens. - Don't call
Doon the sameOncefrom insidef. The documentation says that deadlocks, becauseDowaits forfandfwould be waiting forDo. - Don't copy a
Onceafter first use. Its documentation forbids that, as it does for aMutex.
In other languages¶
- The Concurrency library's chapter 04 Waiting for each other (planned) will cover run-once initialization in six languages.
- The Rust library's Lock poisoning ↗ explains why a Rust
Mutexremembers a panic. Rust'sstd::sync::Once↗ works the same way: a panicking closure poisons theOnce, and every latercall_oncepanics too. That matches Go'sOnceFunc, not Go'sOnce.
Sources¶
sync.Once↗, withOnce.Do↗: runsfonce, the panic rule, and the deadlock warning.sync.OnceFunc↗,OnceValue↗ andOnceValues↗, all added in Go 1.21.- Go 1.21 release notes ↗
- Katherine Cox-Buday, Concurrency in Go, chapter 3 "Go's Concurrency Building Blocks", the section on
Once. Jon Bodner, Learning Go, 2nd ed., chapter 12 "Concurrency in Go", the section "Run Code Exactly Once".