Misuse of sync.Once
| Vulnerability potential | Low |
| DDoS potential | Low |
sync.Once is used incorrectly
Impact
Go’s sync.Once guarantees that the function passed to its Do method runs
exactly once, even under concurrent calls, and that all callers observe its
side effects before Do returns. Misusing it breaks initialization in subtle,
timing-dependent ways. Common misuses and their consequences:
- Copying a
sync.Oncevalue (passing it by value, or copying a struct that embeds one) produces an independent zero-valuedOnce, so the action runs again. The “single” initialization happens more than once. - Calling
once.Dorecursively from inside the very function it is running deadlocks:Doblocks waiting for the in-progress call to finish, which is the current goroutine. - Using a different
Onceinstance per call (e.g. a local variable, or a newOnceeach iteration) defeats the purpose entirely; the guard is meaningless. - Expecting
Doto retry on failure: if the function panics or fails,Oncestill records it as done, so the resource is never initialized and every later caller proceeds with a nil/zero value, often causing a nil dereference far from the real fault.
Vulnerability potential
This issue has limited security relevance.
- A recursive or self-referential
Dodeadlocks the calling goroutine; if that goroutine holds a request, repeated triggering can exhaust a worker pool and deny service. - If
Onceis meant to perform a one-time security setup (load keys, seed a RNG, build an allowlist) and a copy or wrong instance causes it to be skipped or repeated, the program may run with uninitialized or re-initialized security state. The direct memory-safety impact is otherwise negligible.
Technical details
sync.Once contains a done flag (accessed atomically) and an internal
Mutex. The first caller to win takes the mutex, double-checks done, runs the
function, then sets done. The done flag is set in a deferred statement, so it
is marked done even if the function panics — this is intentional and means
Once is not a retry primitive.
Non-copyability
Because Once holds a Mutex, it must not be copied after first use; go vet’s
copylocks pass reports this. A copied Once has its own zero state and will
re-run.
Correct usage patterns
Store the Once as a field of a long-lived struct (or a package-level variable)
and always call Do on the same instance through a pointer. For
initialization that can fail and should be retried, use a custom guard or the
sync.OnceValue/sync.OnceFunc helpers (Go 1.21+) only where one-shot
semantics are actually desired; for retry-on-error, do not use Once at all.
Catching the issue
go vet
go vet runs the copylocks analyzer, which flags any copy of a value
containing a sync.Once (or any lock), catching the most common misuse.
Race detector
Build and test with -race. While it does not directly understand Once
semantics, it surfaces the concurrent unsynchronized access that results when a
copied or wrong Once fails to establish the intended happens-before edge.
Linters and review
staticcheck reports several Once antipatterns. In review, require that each
Once is a stable field/global referenced by pointer, that Do is never called
from within its own function, and that the action is idempotent and does not rely
on Once for retry.
How to reproduce
Observe a self-deadlock: calling once.Do recursively blocks forever (the Go
runtime reports “all goroutines are asleep - deadlock”).
package main
import "sync"
var once sync.Once
func initX() {
// BUG: re-entering Do on the same Once deadlocks.
once.Do(initX)
}
func main() {
once.Do(initX)
}