sync.Once in Go
Run setup code exactly once even when many goroutines ask for it, with sync.Once, sync.OnceValue and sync.OnceValues.
Some work should happen only once: loading a config file, opening a shared connection, building a lookup table. If several goroutines need that result at the same time, you must make sure the work runs once and everyone waits for it. sync.Once does exactly this.
Basic sync.Once
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config map[string]string
)
func loadConfig() {
fmt.Println("loading config...")
config = map[string]string{"region": "ap-south-1"}
}
func getConfig() map[string]string {
once.Do(loadConfig)
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = getConfig()
}()
}
wg.Wait()
fmt.Println("region:", getConfig()["region"])
}
Output:
loading config...
region: ap-south-1
Five goroutines and one extra call all go through once.Do, but loading config... is printed only once. If a goroutine calls Do while the function is still running, it waits until it finishes, so nobody sees a half-built config.
Why Not Just Check a Bool
A plain if !loaded { load(); loaded = true } is a data race. Two goroutines can both see loaded == false and both run load. Adding a mutex fixes that, but sync.Once is the ready-made, correct version of that pattern.
sync.OnceValue and sync.OnceValues
Since Go 1.21, sync.OnceValue wraps a function that returns a value, and sync.OnceValues wraps one that returns a value and an error. They remove the separate global variable:
package main
import (
"errors"
"fmt"
"sync"
)
var loadKey = sync.OnceValues(func() (string, error) {
fmt.Println("reading key once")
return "", errors.New("key file missing")
})
func main() {
for i := 0; i < 3; i++ {
_, err := loadKey()
fmt.Println("attempt", i, "error:", err)
}
}
Output:
reading key once
attempt 0 error: key file missing
attempt 1 error: key file missing
attempt 2 error: key file missing
Errors are remembered too:
sync.Once and its variants never run the function again, even if it failed. The first error is returned forever. If you need to retry after a failure, use a mutex and your own "loaded" flag instead.
Calling Do from inside Do:
If the function passed to once.Do calls once.Do on the same Once, it waits for itself and the program deadlocks.
Lazy setup done right:
Expensive shared setup wrapped in sync.OnceValue or sync.OnceValues runs only when first needed, exactly once, and is safe to call from any goroutine.
Summary
sync.Once runs a function exactly once and makes every other caller wait until it is done, which is the safe way to do lazy setup across goroutines. sync.OnceValue and sync.OnceValues return the result directly, including an error. The result, including a failure, is cached forever, so use a mutex-based approach if you need retries.