Skip to content

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.OnceValue or sync.OnceValues rather than a variable next to a sync.Once. Nothing can read the value without going through the guard, and a panic repeats instead of vanishing.
  • Use sync.Once for a side effect with no result. Use sync.OnceFunc if a panic in it should keep being reported.
  • Decide what a failed initialization means. OnceValues keeps the error for good. If a later caller should try again, a Once is the wrong tool: use a mutex and record success only when it happens.
  • Don't call Do on the same Once from inside f. The documentation says that deadlocks, because Do waits for f and f would be waiting for Do.
  • Don't copy a Once after first use. Its documentation forbids that, as it does for a Mutex.

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 Mutex remembers a panic. Rust's std::sync::Once works the same way: a panicking closure poisons the Once, and every later call_once panics too. That matches Go's OnceFunc, not Go's Once.

Sources