Chapter 12: The sync Package
Chapter 9 gave you the mutex, Chapter 10 gave you the bill, and Chapter 11 took the lock away for the handful of problems that fit in one word of memory. Every one of those chapters handed you a primitive and left you to assemble it. This chapter hands you four that are already assembled.
That sounds like a simplification, and mostly it is. But an assembled primitive makes a promise, and the promise is always narrower than the name suggests. Consider three uses of these types that every tool in the book will pass.
// Illustrative snippet — not a complete program
// ✗ BROKEN: "exactly once" includes exactly one failure
var (
conn *Conn
connOnce sync.Once
connErr error
)
func Get() (*Conn, error) {
connOnce.Do(func() { conn, connErr = dial() })
return conn, connErr // one bad DNS answer, cached until restart
}
// Illustrative snippet — not a complete program
// ✗ BROKEN: two atomic operations, one non-atomic update
func Increment(m *sync.Map, key string) {
v, _ := m.Load(key)
n, _ := v.(int)
m.Store(key, n+1) // another goroutine stored between these
}
// Illustrative snippet — not a complete program
// ✗ BROKEN: the buffer is back in the pool before the caller reads it
func Render() []byte {
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
defer pool.Put(buf)
buf.WriteString("hello")
return buf.Bytes() // points into memory someone else now owns
}
Each of these compiles. go vet is happy with all three. go test -race is silent on all three, because not one of them contains a data race — every access really does go through the primitive’s own API. They are correct programs computing the wrong answer, and the first one does it permanently.
That is the shape of this chapter. Each of these four types removes a category of mistake and leaves a narrower one in its place, and the narrower one is always about the promise you assumed rather than the promise you got. sync.Once promises one execution, not one success. sync.Map makes each operation atomic, not each pair. sync.Pool lends you an object, and takes it back the instant you say so. sync.Cond manages the sleeping and leaves the entire condition to you.
The four sync types side by side. sync.Once runs one function one time; sync.Map is a map for two access patterns; sync.Pool lends an allocation back; sync.Cond sleeps until a predicate holds. The first three never block once warm, and Cond always blocks. Each replaces a hand-built combination: a mutex and a bool, a mutex and a map, an allocation, a mutex and a poll loop. They are ordered from the narrowest promise you can rely on to the widest obligation you have to meet.
sync.Onceand theOnceFunc/OnceValue/OnceValueswrappers, and why a cached failure is the defining property rather than an edge casesync.Map's two documented access patterns, what its Go 1.24 rewrite changed, and why its advantage is a scaling property rather than a per-operation onesync.Pool's two-cycle lifetime, whyGetmust be followed by a reset andPutby a copy, and the boxing trap that makes a pooled slice allocate on everyPutsync.Cond's three rules, why Go’sWaitis not thepthread_cond_waityou may have read about, and the two situations where a channel genuinely cannot replace it- How to choose between these four, a mutex, an atomic and a channel from the access pattern rather than from instinct
sync.Mutexandsync.RWMutex— Chapter 9. This chapter compares against them constantly but does not re-teach themsync.WaitGroup— §2.3, including thewg.Goform (Go 1.25) used throughout this chaptersync/atomic— Chapter 11.sync.Onceandsync.Mapare built on it, and §11.4.7's CAS state machine is the alternative to §12.1golang.org/x/sync—errgroup,semaphoreandsingleflightsit outside the standard library. §12.2.8 points atsingleflightwhere it beats hand-rolled deduplicationcontext.Contextfor cancellation and deadlines — Chapter 13, and the answer wherever §12.4 sayssync.Condcannot time out
Every primitive so far has protected state that sits still. Chapter 13 turns to the other axis — context.Context, and how a cancellation or a deadline travels down through every goroutine a request creates. The four types here are the last of the ones you assemble yourself.
Mutex mechanics from Chapter 9, especially §9.3's reader/writer trade-off, because “sync.Map or RWMutex?” is most of §12.2. The happens-before rules from §8.4 — all four types in this chapter establish one, and §8.4's verdict on double-checked locking is the reason §12.1 exists. Chapter 11 §11.4.7 for the lock-free lazy initialization sync.Once replaces.
Every listing, figure and benchmark in this chapter was run on Go 1.25 or later. Six changes land close enough to this material to matter when you compare it with older writing. sync.Map gained Swap, CompareAndSwap and CompareAndDelete in Go 1.20, and Clear in Go 1.23 — not 1.21, which several references still claim. Go 1.21 added OnceFunc, OnceValue and OnceValues, and §12.1.5 argues they should be your default rather than Do. Go 1.24 replaced sync.Map's implementation outright, which is the single most consequential fact in this chapter: every article describing a “read map” and a “dirty map” now describes something that no longer exists, and every benchmark published before it is measuring a different type. Go 1.24 also rewrote the mutex fast path, so any comparison that puts an uncontended mutex at 20–25 ns is roughly double the truth. And Go 1.25 added WaitGroup.Go, which replaces the Add(1) / defer Done() pair everywhere in this book — a rewrite Go 1.27’s go fix now performs mechanically, through its waitgroupgo modernizer. And Go 1.27 made small allocations (under 80 bytes) up to 30% cheaper through size-specialized allocation routines, so the make([]byte, 64) and boxing figures in §12.3 — measured on go1.26.1 — are an upper bound on a 1.27 toolchain; the pool still wins, by a smaller margin.
-benchtime 1s -count 8, minimum of eight passes reported. Serial figures are a plain loop; parallel figures use RunParallel across all 16 threads. The machine was not idle, so absolute numbers are an upper bound; the ratios held across every pass. Two figures are quoted from earlier chapters rather than re-derived here, so that one operation carries one number across the book: the uncontended mutex at 10.70 ns is Chapter 11's measurement, which Chapter 9 confirmed independently at 10.8 ns. Repeated runs of that benchmark during this chapter’s work landed between 9.2 and 10.1 ns depending on the session, which is the ordinary drift of a machine that is doing other things — treat every absolute figure here as good to about a nanosecond, and the ratios as the durable part.
12.1 sync.Once: Exactly-Once Initialization
Some things must happen once: opening a database handle, compiling a regexp, reading a config file, starting a background reaper. Zero times breaks the program. Twice wastes a connection, or corrupts a counter, or starts two reapers that fight.
sync.Once guarantees exactly one execution across any number of goroutines, and — this is the part that catches people — it guarantees exactly one attempt.
12.1.1 The Problem
Here is lazy initialization written the obvious way:
// Illustrative snippet — not a complete program
// ✗ BROKEN: two goroutines can both see nil
var config *Config
func GetConfig() *Config {
if config == nil { // both goroutines pass this
config = loadFromFile()
}
return config
}
Two goroutines can both read config as nil, both call loadFromFile, and both assign. One result is silently discarded, and the read of config races with the write — this is a data race in the §8.1 sense, and the race detector will say so.
A timeline for two goroutines lazily initializing a shared config pointer without synchronization. Both read config as nil at T0 and T1, both call loadFromFile, and both assign at T4 and T5. Goroutine A’s result is overwritten and never used, two files are opened instead of one, and any goroutine reading config between the two writes races with them.
The mutex fix is correct and costs you something on every call forever:
// Illustrative snippet — not a complete program
// ✓ CORRECT, but every reader pays for the one writer
var (
config *Config
mu sync.Mutex
)
func GetConfig() *Config {
mu.Lock()
defer mu.Unlock()
if config == nil {
config = loadFromFile()
}
return config
}
Lock/Unlock pair is 10.70 ns on the reference machine. That is small, but it is paid on the millionth call as fully as on the first, and under contention it serializes readers that only ever read.
You already know the lock-free version. §11.4.7 built it with an atomic.Bool guard and a mutex behind it, and §11.2.3 answered the objection that Chapter 8 called double-checked locking broken: the C++ version is broken, the Go version with an atomic guard is not, because §8.4's rule 7 gives the atomic store a happens-before edge to the load that observes it. sync.Once is that construction, packaged, with the one refinement that is easy to get wrong by hand — the flag is set after f returns, so a second caller blocks until initialization is genuinely finished rather than sailing past a flag that was set too early.
// Illustrative snippet — not a complete program
// ✓ BEST: the same construction, already written and tested
var (
config *Config
configOnce sync.Once
)
func GetConfig() *Config {
configOnce.Do(func() { config = loadFromFile() })
return config
}
12.1.2 The API, and the One to Reach For First
There are four entry points. Most writing introduces Do and mentions the other three as conveniences; that ordering is backwards for new code.
// Illustrative snippet — not a complete program
func (o *Once) Do(f func())
func OnceFunc(f func()) func() // Go 1.21
func OnceValue[T any](f func() T) func() T // Go 1.21
func OnceValues[T, U any](f func() (T, U)) func() (T, U)
OnceValue and OnceValues collapse the three-declaration dance — the value, the error, the Once — into one:
// Illustrative snippet — not a complete program
// The Do form: three package-level names, and the value is
// reachable (and mutable) before it is initialized.
var (
config *Config
configErr error
configOnce sync.Once
)
func GetConfig() (*Config, error) {
configOnce.Do(func() { config, configErr = load() })
return config, configErr
}
// The OnceValues form: one name, nothing reachable early.
var GetConfig = sync.OnceValues(func() (*Config, error) {
return load()
})
The second version is not merely shorter. In the first, config is an ordinary package variable: any code in the package can read it before GetConfig has ever run and get nil, or assign to it and defeat the whole mechanism. In the second there is no variable to misuse. §11.2.3 made the same recommendation from the atomics side and named the three cases where it does not fit, and they are still the three cases: when you need to ask whether initialization has happened without triggering it, when you need to retry after a failure, and when you need to reset.
The three wrappers do not treat a panicking function the way Do does. If f panics, Do propagates that panic to the caller that ran it and returns normally for everyone afterwards. The wrappers capture the panic value and re-panic with it on every subsequent call, forever. That is usually what you want — a failed initialization should not look like a success to the second caller — but it means a recover around the first call does not make the problem go away, and it is a real behavioral difference from Do that the documentation states in one line and most writing omits.
12.1.3 How sync.Once Works
The real implementation is short enough to read in full:
// Illustrative snippet — not a complete program
// $GOROOT/src/sync/once.go, lightly trimmed
type Once struct {
_ noCopy
done atomic.Bool
m Mutex
}
func (o *Once) Do(f func()) {
if !o.done.Load() {
o.doSlow(f) // outlined so the fast path can inline
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if !o.done.Load() {
defer o.done.Store(true)
f()
}
}
Three details are worth pausing on. The slow path is a separate function so that Do itself is small enough for the compiler to inline at every call site — which is why the steady-state cost is a single atomic load and a branch. done is stored with a defer, so it is set even if f panics; that is the mechanism behind the “no retry” rule. And the store happens after f returns, not before, which is what lets a blocked second caller trust that initialization is complete when Do returns to it.
A timeline for three goroutines calling Do on the same sync.Once. Goroutine A finds the flag unset, takes the lock and runs the function. B and C arrive while it is running and block. When the function returns, the done flag is set and the lock released, waking B and C, which return without running anything. Every later call is a single inlined atomic load with the branch not taken.
The happens-before guarantee follows from that store. The Go memory model states it directly: the completion of f happens before the return of any call to Do. Everything f wrote is visible to every goroutine that Do returns to, with no further synchronization — the same edge §8.4 rule 7 gives an atomic store, which is exactly what done.Store(true) is.
If f panics, done is still set by the deferred store, the mutex is still released by the deferred unlock, and the goroutines blocked behind it wake and return normally. They do not observe the panic; only the goroutine that ran f does. So a panic inside Do produces one crashing goroutine and an unknown number of callers that believe initialization succeeded and are now looking at a half-built value. If initialization can panic and the program is meant to survive it, Do is the wrong tool — see §12.1.4.
12.1.4 Exactly Once Includes Exactly One Failure
This is the property that defines sync.Once, and treating it as a footnote is how the first listing in this chapter got written.
Do marks itself complete when f returns, whether f succeeded, returned an error, or panicked. There is no retry, and there is no way to ask for one.
// Illustrative snippet — not a complete program
// ✗ BROKEN: a transient failure becomes a permanent one
var (
conn *Conn
connErr error
connOnce sync.Once
)
func GetConn() (*Conn, error) {
connOnce.Do(func() { conn, connErr = dial() })
return conn, connErr
}
One DNS timeout during startup and this function returns that same error until the process restarts. Nothing logs a second attempt, because there is no second attempt.
There is also a quieter version of the bug, which hides the failure instead of caching it:
// Illustrative snippet — not a complete program
// ✗ BROKEN: err is a fresh variable on every call
func GetConn() (*Conn, error) {
var err error // local!
connOnce.Do(func() { conn, err = dial() })
return conn, err
}
The first caller sees the real error. Every caller after that gets a freshly zeroed err, which is nil, alongside a conn that is also nil. The error does not survive the call that produced it, so the failure is reported once and then silently becomes a nil-pointer dereference somewhere else.
The fix depends on what a failure means for your program, and there are exactly three answers.
If initialization must succeed or the program has no reason to run, fail loudly and immediately:
// Illustrative snippet — not a complete program
var GetDB = sync.OnceValue(func() *sql.DB {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
panic(fmt.Sprintf("opening database: %v", err))
}
return db
})
If the failure is genuinely permanent — a malformed config file, an unparseable pattern — cache it deliberately and let callers see it:
// Illustrative snippet — not a complete program
var GetConfig = sync.OnceValues(func() (*Config, error) {
return parseConfigFile("/etc/app/config.yaml")
})
If the failure is transient, sync.Once is the wrong type. A network dial is transient. Use a mutex and keep the retry:
// Illustrative snippet — not a complete program
type ConnCache struct {
mu sync.Mutex
conn *Conn
}
func (c *ConnCache) Get() (*Conn, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return c.conn, nil // already built; nothing to do
}
conn, err := dial()
if err != nil {
return nil, err // caller may try again later
}
c.conn = conn
return c.conn, nil
}
This costs 10.70 ns per call instead of 1.03 ns, which matters only if you are calling it millions of times per second. Correctness under a flaky network is worth ten nanoseconds.
Ask what should happen the second time, after the first attempt failed. If the answer is “the program should already have exited”, use a wrapper and panic. If it is “the caller should see the same error forever, because retrying cannot help”, use OnceValues and cache the error on purpose. If it is “the caller should be able to try again”, you do not want sync.Once at all — you want a mutex, and §11.4.7's CAS state machine if the mutex ever shows up in a profile.
12.1.5 Common Patterns
Per-instance lazy initialization. Each value gets its own Once, so a hundred clients initialize a hundred times, once each:
// Illustrative snippet — not a complete program
type APIClient struct {
baseURL string
clientOnce sync.Once
client *http.Client
}
func (c *APIClient) httpClient() *http.Client {
c.clientOnce.Do(func() {
c.client = &http.Client{Timeout: 30 * time.Second}
})
return c.client
}
One-time cleanup. Close is the mirror image of initialization, and the same guarantee applies — closing twice is usually an error, and callers should not have to coordinate:
// Illustrative snippet — not a complete program
type Server struct {
closeOnce sync.Once
closeErr error
listener net.Listener
}
func (s *Server) Close() error {
s.closeOnce.Do(func() {
s.closeErr = s.listener.Close()
})
return s.closeErr // same answer for every caller
}
Note that closeErr is a struct field, not a local: the error has to outlive the call that produced it, for the reason §12.1.4 gave.
Starting a background goroutine exactly once. This one carries an obligation Chapter 2 will recognize:
// Illustrative snippet — not a complete program
type Cache struct {
reaperOnce sync.Once
stop chan struct{}
}
func (c *Cache) startReaper() {
c.reaperOnce.Do(func() {
c.stop = make(chan struct{})
go c.reap(c.stop) // §2.4: how does this one exit?
})
}
§2.4's four questions still apply. sync.Once guarantees the goroutine is started once; it says nothing about how it stops, and a reaper started lazily and never stopped is a leak that outlives every request that caused it.
12.1.6 When to Use sync.Once
Reach for it when all four of these hold.
The comparison people actually need is against init(), which is the other way to run something exactly once:
init() whensync.Once whensync.Once when
sync.Once when
sync.Once when
sync.Once when
A CLI with twenty subcommands is the clean example: an init() that opens the database makes mytool --help open a database. sync.Once makes the two subcommands that need it pay for it.
12.1.7 When NOT to Use sync.Once
It cannot retry. §12.1.4 is the whole of this point.
It cannot be reset. Assigning a fresh Once over a live one is a data race, not a reset:
// Illustrative snippet — not a complete program
// ✗ BROKEN: a data race, and it does not do what it looks like
func ReloadCache() {
cacheOnce = sync.Once{} // racing with every concurrent Do
}
For state that changes more than once, you want an atomic.Bool or a mutex — §11.2.1 for the flag, §11.4.6 for a state machine that moves between more than two values.
It does not care which function you pass. Once tracks whether any call has run, not which:
// Illustrative snippet — not a complete program
var once sync.Once
once.Do(func() { fmt.Println("first") }) // prints
once.Do(func() { fmt.Println("second") }) // does nothing
It is one flag, not one flag per key. A single Once cannot initialize a map of resources lazily. That is sync.Map plus LoadOrStore, and §12.2.8 builds it — including the version that gives each key its own Once.
12.1.8 Common Mistakes
Once declared inside the functionFresh each call, so f runs every time
Package-level or struct field
Copies the Once, so the copy is always fresh
Pointer receiver
Only the first caller sees it
Struct field or package level
Do calling itselfThe inner call waits on the outer one
Break the recursion
Once values that wait on each otherAB-BA cycle, exactly as in §10.3
Order them, or merge them
sync.Once{} to resetData race, and no waiter is released
atomic.Bool or a mutex
The copy is the one go vet catches for you. A value receiver copies the whole struct, Once included:
// Illustrative snippet — not a complete program
// ✗ BROKEN: s.once is a copy, so every call re-initializes
func (s Service) Init() { s.once.Do(s.load) }
// ✓ CORRECT
func (s *Service) Init() { s.once.Do(s.load) }
go vet reports this, and the message names the enclosing type as well as the field:
Recursive Do is a deadlock, not a no-op. The inner call finds done still false, tries to take the mutex the outer call is holding, and stops:
// Illustrative snippet — not a complete program
// ✗ BROKEN: deadlocks on the first call, deterministically
func Initialize() {
once.Do(func() {
setup()
Initialize() // waits for the Do that is calling it
})
}
Two Once values can deadlock the same way two mutexes can. This is §10.3's AB-BA pattern wearing different clothes, and it needs §10.5.3's fix — a consistent order:
// Illustrative snippet — not a complete program
// ✗ BROKEN: initA takes outer then inner; initB takes the
// reverse. Run concurrently, neither finishes.
func initA() { outer.Do(func() { inner.Do(setupInner) }) }
func initB() { inner.Do(func() { outer.Do(setupOuter) }) }
The runtime deadlock detector will not save you here for the reason §10.4 gave: it fires only when every goroutine is asleep, and a real program has a ticker.
12.1.9 Performance Characteristics
Measured minimum of eight runs, each primitive freshly declared and already tripped, so every timed call takes the fast path. The mutex row is Chapter 11's figure, quoted rather than re-derived.Do after the first callOnceValue after the firstLock/Unlockf, plus one lockTwo things in that table matter more than the ratio. The first is that Do's steady state is an atomic load of a bool and a not-taken branch, inlined into the caller — which is why it gets faster with more goroutines rather than slower. A shared read of a cache line that nobody writes scales; this is §11.2.7's point about atomic loads, and Once is the same shape.
The second is that a mutex-guarded check costs 10.70 ns and does not scale, because every reader writes the lock word.
Derived that is about 10× at one goroutine and much more than that at sixteen — but ten nanoseconds per call is a real difference only above roughly a million calls per second. Below that, pick the type that expresses what you mean.sync.Once is 12 bytes on a 64-bit platform: a zero-width noCopy, a 4-byte atomic.Bool (which is a uint32 internally, not a single byte), and an 8-byte Mutex.
12.1.10 Testing Code That Uses sync.Once
Prefer a fresh instance per test. A Once on a struct is testable; a Once at package level is not, because the second test in the file gets the first test’s initialization:
// Illustrative snippet — not a complete program
func TestService(t *testing.T) {
svc := NewService() // its own Once, its own state
svc.Init()
}
Test that concurrent callers see a finished value, which is the guarantee that is easy to break by hand:
// Illustrative snippet — not a complete program
func TestOnceInitializesExactlyOnce(t *testing.T) {
var (
runs atomic.Int32
once sync.Once
value int
wg sync.WaitGroup
)
for range 100 {
wg.Go(func() {
once.Do(func() {
runs.Add(1)
value = 42
})
// Reading value without a lock is deliberate and
// safe: Do's return establishes happens-before
// with the write inside f (§8.4 rule 7), so this
// is not a data race and -race agrees.
if value != 42 {
t.Errorf("value = %d after Do; want 42", value)
}
})
}
wg.Wait()
if n := runs.Load(); n != 1 {
t.Errorf("f ran %d times; want 1", n)
}
}
A reset helper for package-level state is possible and is a data race the moment any goroutine is still running. If you find yourself writing one, the finding is that the package state should have been a struct field.
Summary: sync.Once
sync.Once runs one function one time and blocks everyone else until it finishes. The fast path is an inlined atomic load, so the steady-state cost is about a nanosecond and it improves with core count. Its return establishes happens-before with everything f wrote.
The guarantee is about executions, not outcomes. A failure is cached as firmly as a success, a panic marks it done and lets every blocked caller return as if it had worked, and there is no reset. Choose OnceValue/OnceValues by default, keep Do for the cases §11.2.3 named, and if a failure might be transient, use a mutex and keep the retry.
Self-Check Questions: sync.Once
Do when the function passed to it panics?
done is set by a deferred store and the mutex is released by a deferred unlock, so the blocked goroutines wake and return normally. They do not see the panic — only the goroutine that ran f does.
That is the dangerous part: every blocked caller now believes initialization succeeded and is holding whatever f managed to build before it died. If a panic is possible and the program should survive it, do the recovery inside f and record the failure explicitly, or use OnceValue, which re-panics on every subsequent call so that no caller can mistake a failure for a success.
This function is called on every request and the connection is never established more than once. What is wrong with it?
// Illustrative snippet — not a complete program
func GetConn() (*Conn, error) {
var err error
connOnce.Do(func() { conn, err = dial() })
return conn, err
}
err is a local variable, so it is freshly zeroed on every call. The first caller sees the real error from dial. Every caller after that gets err == nil alongside a conn that is also nil — the failure is erased and reappears later as a nil-pointer dereference somewhere unrelated.
The error has to live as long as the Once does: a package-level variable, a struct field, or — better — no variable at all:
// Illustrative snippet — not a complete program
var GetConn = sync.OnceValues(func() (*Conn, error) {
return dial()
})
Note that this fixes the reporting bug and keeps the caching one. A dial failure is transient, so the real answer here is a mutex with a retry (§12.1.4).
init() instead of sync.Once?
When the work is always needed, cheap enough that nobody notices, and a failure should stop the program from starting. init() runs before main, in import order, and cannot report an error to anyone.
sync.Once earns its place when the work might never be needed (a subcommand nobody ran), when it is expensive enough to be worth deferring, or when a failure should reach a caller rather than kill the process.
Why does this counter sometimes report the wrong number of initializations?
// Illustrative snippet — not a complete program
func (s Service) Init() {
s.once.Do(func() { s.count++ })
}
s.once?
The method has a value receiver, so s — and the sync.Once inside it — is copied on every call. Each call gets a pristine Once whose done is false, so f runs every time, and each increment lands on a copy that is discarded when the method returns.
go vet catches it:
The fix is a pointer receiver. This is worth internalizing because it generalizes: none of the four types in this chapter may be copied after first use, and for three of them go vet is the only thing that will tell you.
Key Takeaways
sync.Onceguarantees one execution and blocks concurrent callers until it completes; its return establishes happens-before with everything the function wrote- “Exactly once” includes exactly one failure — an error or a panic marks it done forever, and there is no reset
- Prefer
OnceValue/OnceValues: one name instead of three, and no variable that can be read before it is initialized - The wrappers re-panic on every call;
Dopanics once and then returns normally to everyone else - Transient failures mean you want a mutex with a retry, not a
Once - The steady-state cost is about 1 ns and it improves with core count, because a shared read of an unwritten cache line scales
Next: sync.Once decides whether something has been built. §12.2 turns to a type that decides where a hundred thousand things are stored, and whose reputation rests on an implementation Go replaced in 1.24.
sync.Once runs one function one time and then caches whatever happened — including the failure, which is the property that turns a bad DNS answer into a permanent one.
12.2 sync.Map: Concurrent Maps for Two Access Patterns
Go’s built-in map is not safe for concurrent use, and the runtime says so rather than corrupting quietly. The standard fix — a map behind a sync.RWMutex — is correct, type-safe, and the right default. sync.Map exists for two access patterns where that default becomes the bottleneck, and its own documentation is unusually blunt about how narrow they are:
The sync.Map doc comment is unusually direct about how narrow this type is, and it is worth reading before the rest of this section rather than after:
The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content.
The two patterns it names are keys written once and read many times — a cache that only grows — and goroutines reading and writing disjoint sets of keys. Everything in this section follows from those two sentences.
This is not a hedge; it is the recommendation in the package documentation, and §12.2.7 measures what you give up when you ignore it. A map behind an RWMutex gives you compile-time types, len, iteration you can trust, and compound operations you can write in three lines. sync.Map gives you none of those. Reach for it when a profile shows lock contention on a map whose access pattern matches one of the two shapes above — and not before.
12.2.1 The Problem
// Illustrative snippet — not a complete program
// ✗ BROKEN: concurrent map write, detected by the runtime
var cache = make(map[string]int)
func increment(key string) {
cache[key]++ // fatal error: concurrent map writes
}
The runtime detects concurrent map access and crashes the process. That is deliberate: a torn map is far worse than a stopped one, and this failure is a fatal error, not a panic — you cannot recover from it (§10.4 makes the same point about the deadlock detector).
A mutex fixes it, and an RWMutex lets readers overlap:
// Illustrative snippet — not a complete program
// ✓ CORRECT: the default, and usually the right answer
type Cache struct {
mu sync.RWMutex
items map[string]Item
}
func (c *Cache) Get(key string) (Item, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.items[key]
return item, ok
}
func (c *Cache) Set(key string, item Item) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = item
}
§9.3 covered why this is not free. RLock does not block other readers, but it does write to the lock word, so every reader on every core takes exclusive ownership of the same cache line in turn. Readers do not block each other logically; they contend physically.
RLock/RUnlock pair costs 10.70 ns with one goroutine and 29.23 ns with sixteen. The serial figure is deliberately the same number as the plain mutex pair above: run the two benchmarks alternately on a quiet machine and they land within a hundredth of a nanosecond of each other, so read it as a read lock costs what a mutex costs, not as two independent measurements that happened to agree. That is the shape of the problem — not that the lock is slow, but that it gets slower as you add cores, which is the opposite of what adding cores is for.
12.2.2 How sync.Map Works
Almost everything written about sync.Map describes a “read map” and a “dirty map”, a promotion counter, and an amended flag. Go 1.24 deleted all of it. sync.Map is now a thin wrapper over a concurrent hash-trie:
// Illustrative snippet — not a complete program
// $GOROOT/src/sync/map.go
type Map struct {
_ noCopy
m isync.HashTrieMap[any, any]
}
If you are comparing this section against an article, a talk or a benchmark, check its date first. Anything from before Go 1.24 is describing a different data structure, and its performance numbers do not transfer.
The structure. The trie is 16-way: each interior node holds sixteen atomic pointers to children, and each level consumes four bits of the key’s hash. A leaf is an entry holding the key, the value, and an overflow chain for genuine hash collisions.
The structure sync.Map has used since Go 1.24. An atomic root pointer leads to interior nodes, each holding sixteen atomic child pointers, with four bits of the key’s hash consumed per level. Leaves are entries holding a key and value. A load walks down using only atomic pointer loads and takes no lock; a store locks only the single interior node whose child slot it changes, so writers on different branches never contend.
Loads take no locks. Load hashes the key, walks down through atomic pointer loads, and reads the leaf. There is no lock, no promotion, no bookkeeping, and no state in which a load is more expensive than it was a moment ago. The pre-1.24 implementation had a slow path where a miss in the read map took the mutex; this one does not have that path at all.
Stores lock exactly one node. A write locks the single interior node whose child slot it is about to change, mutates it, and unlocks. Two goroutines storing keys that hash to different branches take different locks and never interact. This is the mechanism behind the second sweet spot: “disjoint key sets” is no longer a statement about promotion behavior, it is a statement about which mutex you land on.
It is also why the write story changed. Under the old design, writes were the case where sync.Map lost badly; under the trie, writes to a single shared lock are what RWMutex is bad at. §12.2.7 has the numbers, and they do not point the way the older literature says.
The documentation defines the guarantee in the memory model’s own vocabulary: a write operation synchronizes before any read operation that observes its effect. Load, LoadOrStore, LoadAndDelete, Swap, CompareAndSwap and CompareAndDelete are read operations; Store, Swap, Delete and LoadAndDelete are writes, and LoadOrStore counts as a write only when it reports loaded as false. This is §8.4's happens-before relation applied to a container: whatever you wrote before storing a value is visible to whoever loads it. The guarantee covers the map’s own operations, and nothing else — §12.2.10 is about the part it does not cover.
12.2.3 API Reference
// Illustrative snippet — not a complete program
func (m *Map) Load(key any) (value any, ok bool)
func (m *Map) Store(key, value any)
func (m *Map) Delete(key any)
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool)
func (m *Map) LoadAndDelete(key any) (value any, loaded bool)
func (m *Map) Range(f func(key, value any) bool)
func (m *Map) Swap(key, value any) (previous any, loaded bool)
func (m *Map) CompareAndSwap(key, old, new any) (swapped bool)
func (m *Map) CompareAndDelete(key, old any) (deleted bool)
func (m *Map) Clear() // Go 1.23
Swap, CompareAndSwap and CompareAndDelete arrived in Go 1.20; Clear in Go 1.23. The zero Map is ready to use.
Loadok reports presenceStoreDeleteLoadOrStoreLoadAndDeleteSwapCompareAndSwapCompareAndDeleteRangeClearCompareAndSwap and CompareAndDelete use Go’s == on the boxed values, which compares dynamic type and value. An int never equals an int64 even when both hold 42, so a mismatched literal type fails silently and forever. For pointers it compares addresses, so two structurally identical values at different addresses are never equal. And if the stored type is not comparable at all — a slice, a map, a func — the comparison panics at runtime. Store one concrete type per key, and prefer pointers, whose identity is exactly what you want to compare.
12.2.4 Basic Operations
// Illustrative snippet — not a complete program
var m sync.Map
m.Store("user:123", &User{ID: 123, Name: "Alice"})
if val, ok := m.Load("user:123"); ok {
user := val.(*User) // assert only after checking ok
fmt.Println(user.Name)
}
LoadOrStore is the one that earns its place. Load-then-store is a check-then-act race, exactly as in §8.2:
// Illustrative snippet — not a complete program
// ✗ BROKEN: two goroutines can both take the !ok branch
if _, ok := m.Load(key); !ok {
m.Store(key, newValue)
}
// ✓ CORRECT: one operation, exactly one winner
actual, loaded := m.LoadOrStore(key, newValue)
Go evaluates arguments before the call, so m.LoadOrStore(addr, dial(addr)) dials on every single call, including the ones that find the key already present and throw the new connection away. When the value is expensive to build — a dial, a file read, a compile — load first, build only on a miss, then LoadOrStore the result and close yours if you lost the race. §12.2.8 shows the shape, and the same caution applies to Swap and CompareAndSwap.
12.2.5 Range Behavior and Limitations
Range visits entries; it does not photograph them.
Two columns separating what sync.Map’s Range guarantees from what it does not. It promises each key is seen at most once, that concurrent modification will not crash it, and that each value is read coherently. It does not promise a point-in-time snapshot, that keys added or deleted during the walk are seen or skipped, any particular order, or any particular total. The set of keys observed need never have existed all at once.
The consequence people trip over is counting. There is no Len, and building one out of Range gives you a number that was never true:
// Illustrative snippet — not a complete program
// This is O(n) and the answer may be stale before it returns
func mapLen(m *sync.Map) int {
n := 0
m.Range(func(_, _ any) bool { n++; return true })
return n
}
The omission is deliberate. A concurrent map cannot report a size that means anything without serializing every writer, which would defeat the point. If you need a count, keep an atomic.Int64 beside the map and adjust it where you Store and Delete — §11.2.2 — or accept that map plus RWMutex gives you len for free and take that instead.
12.2.6 When to Use sync.Map
Pattern one: keys written once, read many times. A registry, a compiled-pattern cache, a per-service configuration lookup — anything that fills up early and is then read hard:
// Illustrative snippet — not a complete program
var configCache sync.Map
func GetConfig(service string) (*Config, error) {
if val, ok := configCache.Load(service); ok {
return val.(*Config), nil // lock-free
}
cfg, err := loadConfigFromDisk(service)
if err != nil {
return nil, err
}
actual, _ := configCache.LoadOrStore(service, cfg)
return actual.(*Config), nil
}
Every steady-state call is a hash and a walk down atomic pointers. Nothing is written, so nothing contends.
Pattern two: disjoint keys. Per-connection state, per-shard counters, per-request scratch — cases where goroutine N touches only the keys goroutine N owns:
// Illustrative snippet — not a complete program
var connState sync.Map // connID -> *ConnState
func HandleConnection(connID string) {
connState.Store(connID, &ConnState{Status: "connected"})
defer connState.Delete(connID)
// ... this goroutine reads and writes only connID ...
}
Different keys hash to different branches of the trie, so their writes take different node locks. A single RWMutex would serialize all of them against each other for no reason — they are not sharing anything.
And the precondition for both: enough concurrency that contention is real. §12.2.7 shows that at one goroutine, map plus RWMutex wins outright.
12.2.7 When NOT to Use sync.Map
You lose compile-time types. Keys and values are any. A wrong assertion is a runtime panic in whichever goroutine happens to hit it:
// Illustrative snippet — not a complete program
cache.Store("user:123", &User{})
value, _ := cache.Load("user:123")
product := value.(*Product) // panics, at run time, in production
You lose len, iteration you can trust, and compound operations. Incrementing a counter is three lines and a mutex with a plain map; with sync.Map it is a LoadOrStore of an atomic.Int64 (§12.2.8) or a CAS retry loop.
Boxing costs allocations. Every Store puts the key and the value into interfaces, and non-pointer values escape to the heap to do it. Measured: Store on a sync.Map[int]int costs 61 B/op and 2 allocs/op; the equivalent write to a map[int]int under a mutex allocates nothing. For a write-heavy map of small values, that garbage is a real cost that no lock-free read path pays back.
And below a few cores, it is simply slower. This is the part the older literature gets backwards in both directions, so it is worth the table.
Measured minimum of eight runs, 1000 keys, values already present.sync.Map readmap + RWMutex readsync.Map writemap + RWMutex writeRead that table by column, not by row. Serially, the plain map wins both operations — it is a single hash lookup against a trie walk plus interface boxing. At sixteen goroutines both numbers have inverted, and the reason is in the trend, not the ratio: sync.Map gets faster as you add cores and RWMutex gets slower. The plain map’s read goes from 15.77 to 31.10 ns because every reader writes the same lock word; sync.Map's goes from 20.08 to 2.62 ns because sixteen goroutines reading shared, unwritten memory genuinely proceed at once.
This is the same shape §11.2.7 found for atomic loads, and §11.2.7's warning applies here too: a RunParallel figure is aggregate throughput, and reading the 2.62 ns as “one load costs 2.62 ns” is the mistake that callout exists to prevent. What the column says is that the work spread across sixteen threads; what it does not say is that any individual operation got three times cheaper than the serial case.
sync.Map's advantage is a scaling property, not a per-operation one. If your program does not have enough concurrent map traffic for a lock to contend, sync.Map is strictly worse: slower per operation, allocating on every write, and untyped. Default to map plus RWMutex for the reasons that have nothing to do with speed — types, len, iteration, compound operations — and switch only when a profile shows the lock itself is the problem.
12.2.8 Common Patterns
Get-or-create without paying for the create. The naive LoadOrStore builds the value every time; this builds it only on a miss, and cleans up if it loses the race:
// Illustrative snippet — not a complete program
func (c *Cache) Get(key string) *Conn {
if v, ok := c.m.Load(key); ok {
return v.(*Conn) // hot path: no allocation, no lock
}
conn := dial(key)
actual, loaded := c.m.LoadOrStore(key, conn)
if loaded {
conn.Close() // someone else won; ours must not leak
}
return actual.(*Conn)
}
That if loaded { conn.Close() } is not optional. Without it every lost race leaks a connection, and lost races are exactly what happens under the load that made you reach for sync.Map.
Exactly-once creation per key. When building the value is expensive enough that you want it built once rather than merely stored once, give each key its own sync.Once — this is the composition §12.1.7 pointed forward to:
// Illustrative snippet — not a complete program
type entry struct {
once sync.Once
val *Result
err error
}
func (c *Cache) Get(key string) (*Result, error) {
e, _ := c.m.LoadOrStore(key, &entry{})
en := e.(*entry)
en.once.Do(func() { en.val, en.err = compute(key) })
return en.val, en.err
}
Allocating an empty entry per call is cheap; compute runs exactly once per key, and everyone else blocks on that key’s Once rather than duplicating the work. Note that this caches the error permanently, for the reason §12.1.4 gave — if compute can fail transiently, this is the wrong shape, and golang.org/x/sync/singleflight is the well-tested version that handles it.
Per-key counters. Compound operations are what sync.Map cannot do, so put something that can do them in the value:
// Illustrative snippet — not a complete program
type Metrics struct {
counters sync.Map // name -> *atomic.Int64
}
func (m *Metrics) Inc(name string) {
c, _ := m.counters.LoadOrStore(name, new(atomic.Int64))
c.(*atomic.Int64).Add(1)
}
The map handles “which counter”; the atomic handles “increment it”. Neither could do the other’s job, and this composition is why §11.2.2's counters and this section belong in the same program.
12.2.9 Type Safety with Generics
A generic wrapper restores the call sites, which is most of the benefit:
// Illustrative snippet — not a complete program
type TypedMap[K comparable, V any] struct {
m sync.Map
}
func (t *TypedMap[K, V]) Load(key K) (V, bool) {
v, ok := t.m.Load(key)
if !ok {
var zero V
return zero, false
}
val, ok := v.(V) // still an assertion, still at run time
return val, ok
}
func (t *TypedMap[K, V]) Store(key K, value V) {
t.m.Store(key, value)
}
Be honest about what this buys. Callers get V instead of any, which removes the assertion from every call site and is worth doing. It does not make the code type-safe: the boxing still happens, the assertion still happens, and if anything ever stores a different concrete type through the embedded sync.Map the wrapper will observe it at run time like everyone else. Use the comma-ok form inside the wrapper, as above, so a violation returns false instead of panicking.
12.2.10 Common Mistakes
Slower and untyped below real contention
Start with an RWMutex
okPanics on a missing key
Check ok, then assert
The map guards itself, not your struct
Replace the value, or lock it
Check-then-act race
LoadOrStore an atomic
Range as a snapshotIt is a walk, not a photograph
Accept it, or use a mutex
Breaks it silently
Pass a pointer
CompareAndSwap with a literalType mismatch fails forever
Match the stored type
The one that is genuinely surprising is that sync.Map protects the map, not the things in it:
// Illustrative snippet — not a complete program
// ✗ BROKEN: a data race on User.Age, which -race will find
func UpdateAge(id string, age int) {
val, _ := users.Load(id)
val.(*User).Age = age // two goroutines, one struct
}
// ✓ CORRECT: copy-on-write; the stored value is immutable
func UpdateAge(id string, age int) {
val, ok := users.Load(id)
if !ok {
return
}
old := val.(*User)
updated := *old // copy
updated.Age = age
users.Store(id, &updated)
}
sync.Map gives you safe concurrent access to a map[K]*V. It gives you nothing at all about the fields of *V. This is the same distinction §11.3.6 drew for atomic.Pointer: publishing a pointer safely does not make the thing it points at immutable, and the discipline that makes it work is the same — treat anything you store as frozen from the moment you store it.
Copying is caught by go vet, and the message names noCopy, not a mutex — there has not been a sync.Mutex inside sync.Map since Go 1.24:
12.2.11 Alternatives
lenmap + RWMutexlen
sync.Maplen
len
A sharded map is the middle path, and it is worth knowing because it gives you the scaling without giving up types or len:
// Illustrative snippet — not a complete program
const shards = 32
var seed = maphash.MakeSeed()
type shard[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
type Sharded[K comparable, V any] struct {
s [shards]shard[K, V]
}
func (s *Sharded[K, V]) Load(key K) (V, bool) {
sh := &s.s[maphash.Comparable(seed, key)%shards]
sh.mu.RLock()
defer sh.mu.RUnlock()
v, ok := sh.m[key]
return v, ok
}
Thirty-two locks instead of one means thirty-two times less contention, len is a sum over the shards, and the types survive. The cost is that you write and maintain it, and that you need a hash function for K — maphash.Comparable (Go 1.24) supplies one for any comparable type, and Go 1.27’s maphash.ComparableHasher packages the hash and the equality together as a maphash.Hasher, which is the contract a sharded map wants.
12.2.12 Testing sync.Map Code
Run it under -race, always, and remember what the race detector can and cannot see here: it will catch a goroutine mutating a stored *User (§12.2.10), and it will not catch a lost update from a load-then-store, because that is a race condition and not a data race (§8.2).
// Illustrative snippet — not a complete program
func TestLoadOrStoreCreatesExactlyOne(t *testing.T) {
var m sync.Map
var created atomic.Int32
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
if _, loaded := m.LoadOrStore("k", "v"); !loaded {
created.Add(1)
}
})
}
wg.Wait()
if n := created.Load(); n != 1 {
t.Errorf("stored %d times; want exactly 1", n)
}
}
Summary: sync.Map
sync.Map is a hash-trie, not the read/dirty pair that most writing still describes. Loads walk it with atomic pointer loads and take no lock; stores lock the single interior node they modify, so writers on different branches never meet.
That makes its advantage a scaling property. Serially it loses to map plus RWMutex on both reads and writes, and it allocates on every store; at sixteen goroutines it wins both, because it improves with core count while a shared lock degrades. Use it for the two documented patterns — stable keys read hard, or disjoint keys per goroutine — and default to a mutex-guarded map for the reasons that were never about speed: types, len, iteration, and compound operations.
Self-Check Questions: sync.Map
sync.Map over map plus sync.RWMutex?
When all three hold: the access pattern is one of the two documented ones (keys written once and read many times, or disjoint key sets per goroutine); a profile shows the lock is actually contended; and you can live without compile-time types, len, consistent iteration and compound operations.
If concurrency is low, sync.Map is slower per operation — 20.08 ns against 15.77 ns for a read at one goroutine — and it allocates on every write. The win only appears once enough goroutines are hitting the map that a single lock becomes the bottleneck.
What is wrong with this function?
// Illustrative snippet — not a complete program
func GetOrCreate(key string) *Resource {
actual, _ := resources.LoadOrStore(key, NewResource())
return actual.(*Resource)
}
NewResource() runs on every call, because Go evaluates arguments before the call. On a hit, the freshly built resource is immediately discarded — and if it holds a file handle, a connection or a goroutine, it is leaked rather than discarded.
Load first, build only on a miss, and clean up if you lose the race:
// Illustrative snippet — not a complete program
func GetOrCreate(key string) *Resource {
if v, ok := resources.Load(key); ok {
return v.(*Resource)
}
r := NewResource()
actual, loaded := resources.LoadOrStore(key, r)
if loaded {
r.Close() // we lost; do not leak ours
}
return actual.(*Resource)
}
sync.Map is much slower than a mutex-protected map for write-heavy workloads, because writes go to the dirty map and force expensive promotions. Is that still true?
No, and the mechanism it describes no longer exists. Go 1.24 replaced sync.Map with a hash-trie: there is no dirty map, no read map, and no promotion. A write locks the one interior node whose child slot it changes, so writes to keys on different branches proceed in parallel.
Measured on the reference machine at sixteen goroutines, sync.Map writes cost 33.45 ns against 85.51 ns for a single-RWMutex map — the opposite of the older claim. Serially the plain map still wins (26.07 ns against 80.78 ns), and sync.Map still allocates 61 B and 2 allocations per store, which is a genuine cost the article would not have mentioned either.
The lesson is the one Chapter 11 drew about mutex benchmarks: check the date before you believe the number.
This code has no data race and -race is silent. What is the bug?
// Illustrative snippet — not a complete program
func Increment(key string) {
v, ok := counts.Load(key)
if !ok {
counts.Store(key, 1)
return
}
counts.Store(key, v.(int)+1)
}
-race quiet?
Lost updates. Load and Store are each atomic; the pair is not. Two goroutines can both load 5, both compute 6, and both store 6 — one increment vanishes.
This is §8.2's distinction exactly: a race condition, not a data race, so no tool in the book will find it. It is also §11.3.2's “atomics do not compose” in a different container.
The fix is to make the value itself capable of a compound operation:
// Illustrative snippet — not a complete program
func Increment(key string) {
c, _ := counts.LoadOrStore(key, new(atomic.Int64))
c.(*atomic.Int64).Add(1)
}
sync.Map have no Len method?
Because it could not return a number that means anything. A count is only true if nothing changes while you take it, and serializing every writer to make that true would remove the reason to use sync.Map at all.
Range will count for you in O(n), and the answer may already be wrong by the time it returns. If you need a size, track it in an atomic.Int64 beside the map, or take the hint: needing len is one of the signals that map plus RWMutex is the better fit.
Key Takeaways
- Since Go 1.24
sync.Mapis a hash-trie; the read/dirty design that most articles describe has been deleted, and their benchmarks measure a different type - Loads take no locks at all; stores lock only the interior node they modify, so disjoint keys mean disjoint locks
- The advantage is scaling, not per-operation cost — serially a mutex-guarded map wins both reads and writes
- Every store boxes key and value: 61 B/op and 2 allocs/op against zero for a plain map
sync.Mapprotects the map, never the values inside it — treat anything you store as immutable- No
len, no consistent iteration, no compound operations, no compile-time types; a generic wrapper fixes the call sites, not the assertions
Next: sync.Map reuses the map. §12.3 turns to a type that reuses the memory underneath one — and whose central rule is that anything you hand back to a caller has to be a copy.
sync.Map is a hash-trie whose advantage is that it scales, not that any single operation is cheap — serially a mutex-guarded map beats it at both reading and writing.
12.3 sync.Pool: Temporary Object Reuse
Every allocation eventually becomes garbage, and collecting garbage costs CPU that your program wanted for other things. In a server that builds a 32 KB buffer per request at ten thousand requests per second, that is 320 MB of garbage every second, all of it dead the moment the response is written.
sync.Pool lends you an object and takes it back. Get returns one that was returned earlier, or builds a fresh one; Put offers it back for the next caller. It is the narrowest of the four types in this chapter, and the one most often reached for too early.
The garbage collector empties pools. An object you Put may be gone by the next Get, and there is no setting, no size, and no policy that changes this. That makes sync.Pool unusable for anything you need to find again: not a value cache, not a connection pool, not a session store, not a rate limiter’s state. It is for objects that are interchangeable — where “a buffer” is as good as “the buffer” — and it holds them only until the next collection or two. If losing the contents would be a bug, you want a different type.
12.3.1 The Problem
// Illustrative snippet — not a complete program
// A fresh 32 KB buffer for every request, garbage by the
// time the handler returns.
func Handle(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 0, 32*1024)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
buf = append(buf, transform(body)...)
w.Write(buf)
}
make([]byte, 4096) costs 588 ns on the reference machine, and that is only the allocation — the collector still has to find and free it later. A pooled Get/Put round trip for the same buffer costs 11.42 ns.
The obvious alternatives are worse. One shared buffer behind a mutex serializes every request, which trades garbage for a queue. A fixed channel of buffers works, but you have to guess the size: too small and you allocate anyway, too large and you hold memory the program is not using, and either way the number is wrong when traffic changes.
sync.Pool sizes itself, keeps a per-processor cache so the common path takes no lock, and gives memory back when traffic drops.
12.3.2 How sync.Pool Works
Per-P caches. Go’s scheduler runs goroutines on logical processors — Ps, one per GOMAXPROCS. A pool keeps a separate cache for each. Get pins the goroutine to its current P for the duration of the call, so it can check that P’s private slot with no synchronization at all, then unpins. That is the entire fast path: one pointer, no atomic, no lock.
The five places sync.Pool.Get looks for an object, in order: the current processor’s private slot, which needs no synchronization; that processor’s shared queue, via a lock-free pop; another processor’s queue, via a contended steal; the victim cache holding survivors of the last collection; and finally the New function, which is a real allocation. The first two steps touch only this processor’s own memory, which is why the pool gets faster as cores are added.
The garbage collector empties it. This is the part to internalize. Since Go 1.13 a pool keeps two generations: the live cache, and a victim cache holding what the live cache held before the last collection.
The two-generation lifetime of a sync.Pool across a garbage collection. Before a collection the pool holds a live cache and a victim cache. The collection drops the victim cache entirely, so those objects become garbage, and moves the live cache into the victim slot. An object therefore survives at most two collections. Objects that are continuously taken and returned never sit still long enough to age out.
How long two cycles take is a property of your allocation rate, not a number. Under the load that justifies a pool, collections can be seconds or milliseconds apart. It is tempting to translate “two cycles” into wall-clock time; resist it, because the only case with a fixed answer is an idle program, where the runtime forces a collection every two minutes and the pool drains for exactly the reason you do not care about.
The design follows from the purpose: a pool exists to reduce collector work, so a pool that kept objects alive across collections would be adding to the problem it was built to solve.
12.3.3 The API
// Illustrative snippet — not a complete program
type Pool struct {
New func() any // optional
}
func (p *Pool) Get() any
func (p *Pool) Put(x any)
Two methods and one field. If the pool is empty and New is nil, Get returns nil, so almost every pool should set New and skip the nil check. Put(nil) is a no-op.
12.3.4 The Canonical Pattern
// Illustrative snippet — not a complete program
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func Render(data []byte) []byte {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // 1. reset before use
defer bufPool.Put(buf) // 2. always give it back
buf.Write(transform(data))
// 3. copy anything that outlives the Put
return bytes.Clone(buf.Bytes())
}
Three rules, and every sync.Pool bug in the wild is one of them.
Reset after Get, not before Put. Both orders can be made to work; only one survives a colleague. Resetting after Get means you are protected even if some other code path forgot, and it costs nothing on a fresh object from New:
// Illustrative snippet — not a complete program
// ✗ BROKEN: the previous caller's bytes are still in there
func Log(msg string) string {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.WriteString(msg)
return buf.String() // "hello" then "helloworld"
}
Copy anything you return. buf.Bytes() is a window into memory the pool is about to lend to somebody else:
// Illustrative snippet — not a complete program
// ✗ BROKEN: returns a view of a buffer that is back in the pool
return buf.Bytes()
// ✓ CORRECT: the caller gets memory nobody else can touch
return bytes.Clone(buf.Bytes())
A timeline showing why a pooled buffer’s contents must be copied before returning them. Goroutine A gets a buffer, writes to it, takes a slice of its bytes, and returns the buffer to the pool. Goroutine B then gets the same buffer, resets it, and writes its own data. A’s slice and B’s buffer name the same backing array, so A’s caller reads corrupted data. Nothing here is a data race the detector can see.
Pool pointers, not slices. This one is invisible until you measure it, and it is the single most common sync.Pool performance bug:
// Illustrative snippet — not a complete program
// ✗ COSTS AN ALLOCATION ON EVERY Put
var p = sync.Pool{New: func() any { return make([]byte, 4096) }}
buf := p.Get().([]byte)
p.Put(buf) // boxing a 24-byte slice header into `any`
// ✓ FREE
var p = sync.Pool{New: func() any {
b := make([]byte, 4096)
return &b
}}
bp := p.Get().(*[]byte)
p.Put(bp) // a pointer fits in the interface word
*[]byte[]byte*bytes.BufferA []byte is a 24-byte header — pointer, length, capacity — which does not fit in an interface’s single data word, so Go allocates a copy of it on every Put. The pool that was supposed to eliminate allocations performs one per cycle. A pointer fits, so it does not. This is why *bytes.Buffer and *[]byte are the shapes you see in the standard library, and it generalizes: pool pointer types.
Storing *[]byte has a second benefit. Because the pooled value is the slice header, *bp = append(*bp, ...) writes any regrown slice back into the pool, so the capacity you paid for is still there next time.
12.3.5 When to Use sync.Pool
Good candidates are almost always buffers or the machinery wrapped around them: encoding and decoding scratch space, compression state, protocol framing, parser workspaces. The standard library pools exactly these — fmt pools its print state, net/http pools its bufio.Reader and bufio.Writer, encoding/json pools encoder state, compress/gzip pools compressors.
On size: the usual advice is that objects under a kilobyte are not worth pooling. That is a decent rule with a bad justification, and the numbers say so.
Measuredmake([]byte, 64) costs 22.42 ns; make([]byte, 256) costs 48.34 ns; make([]byte, 4096) costs 588 ns. A pooled round trip is 11.42 ns regardless of size. By raw speed the pool wins even at 64 bytes. On Go 1.27, whose size-specialized allocator targets objects under 80 bytes, expect the 64-byte and the boxed-header figures to fall by up to 30%; the ratio argument in this section does not depend on them.
The reason to skip small objects anyway is that the two sides scale differently. The benefit of pooling grows with the bytes you keep out of the collector’s way; the risk — a missed reset leaking one request’s data into another’s, a returned slice that someone else overwrites — is exactly the same for a 64-byte object as for a 64 KB one. At 64 bytes you are accepting the whole risk for a fraction of the benefit, and Go’s allocator is fast enough that nobody will notice the difference. That is a judgment about ratios, not a threshold in bytes, and it is why “profile first” is the rule rather than “1 KB”.
12.3.6 Common Patterns
A buffer pool, which is the pattern the others are variations of:
// Illustrative snippet — not a complete program
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func EncodeJSON(v any) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
if err := json.NewEncoder(buf).Encode(v); err != nil {
return nil, err
}
return bytes.Clone(buf.Bytes()), nil
}
Capping what goes back. A pooled buffer grows to fit the largest thing you ever put in it and then keeps that memory forever. One 100 MB request would otherwise leave a 100 MB buffer in the pool for the rest of the process:
// Illustrative snippet — not a complete program
const maxPooled = 64 << 10
func putBuf(buf *bytes.Buffer) {
if buf.Cap() > maxPooled {
return // let the big one go to the collector
}
buf.Reset()
bufPool.Put(buf)
}
This applies to *bytes.Buffer and *[]byte alike, and the standard library does it too. It is easy to forget precisely because nothing goes wrong until the day something large arrives.
Resetting a struct. Give the type a Reset method so the discipline lives with the type rather than at every call site:
// Illustrative snippet — not a complete program
type Request struct {
Method string
URL string
Headers map[string]string
Body []byte
}
func (r *Request) Reset() {
r.Method = ""
r.URL = ""
r.Body = r.Body[:0] // keep the capacity
clear(r.Headers) // keep the map
}
func AcquireRequest() *Request {
r := reqPool.Get().(*Request)
r.Reset() // the caller cannot forget
return r
}
Wrapping something that holds a reference. A pooled gzip.Writer keeps a pointer to whatever you last reset it onto, which keeps that writer — and everything it references — alive for as long as the pooled object lives:
// Illustrative snippet — not a complete program
var gzipPool = sync.Pool{
New: func() any { return gzip.NewWriter(nil) },
}
func Compress(w io.Writer, data []byte) (err error) {
gw := gzipPool.Get().(*gzip.Writer)
gw.Reset(w)
defer func() {
gw.Reset(nil) // drop the reference to w
gzipPool.Put(gw)
}()
if _, err = gw.Write(data); err != nil {
return err
}
return gw.Close() // Close flushes; its error matters
}
The gw.Reset(nil) is not decoration. Without it the pool holds a live pointer to a response writer, a connection, or whatever else w was, long after the request that owned it finished. net/http does exactly this in putBufioReader, and it is worth reading its two-line body next to this one.
12.3.7 sync.Pool in the Standard Library
The clearest guide to what belongs in a pool is what the standard library already puts in one. Every case is the same shape: a scratch object, built per call, discarded per call, on a path that runs constantly.
fmtPrintf, discarded immediatelynet/httpbufio.Reader and bufio.Writerencoding/jsonMarshalencoding/gobregexpnet/http's helper is two lines and worth reading next to §12.3.6:
// Illustrative snippet — not a complete program
// $GOROOT/src/net/http/server.go
func putBufioReader(br *bufio.Reader) {
br.Reset(nil)
bufioReaderPool.Put(br)
}
The Reset(nil) is the reference-dropping move. Without it, every pooled reader would keep the last connection it wrapped alive for as long as the pool held the reader — a slow leak of exactly the objects a server most wants collected.
12.3.8 When NOT to Use sync.Pool
Anything that must still be there later. Caches, sessions, rate-limiter state, deduplication tables. The collector empties the pool; that is the whole design.
Anything holding an external resource. A pooled object that owns a file descriptor or a socket has no way to close it — the pool never tells you an object is being dropped, it simply stops referencing it, and the descriptor leaks until the process ends.
Connection pools, which are not this. The example people reach for is a database, and it is worth being precise about why it is wrong:
// Illustrative snippet — not a complete program
// ✗ BROKEN, and confused: *sql.DB is ALREADY a pool
var dbPool = sync.Pool{
New: func() any {
db, _ := sql.Open("postgres", dsn)
return db
},
}
sql.Open does not open a connection; it returns a handle that manages a pool of them internally, and it is meant to be created once and shared for the life of the program. Wrapping it in a sync.Pool builds a pool of pools, opens a new one every time the collector empties the outer pool, and leaks every connection each discarded handle was holding. What you want is one *sql.DB, configured:
// Illustrative snippet — not a complete program
db, err := sql.Open("postgres", dsn)
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
Objects with cleanup attached. A finalizer runs when an object becomes unreachable, and a pooled object is deliberately kept reachable — so the cleanup may run at a moment that makes no sense, or never. This applies to runtime.SetFinalizer and equally to runtime.AddCleanup, which replaced it in Go 1.24 and which you should prefer for new code. Neither belongs on a pooled type.
Anything you have not profiled. A pool adds a reset you can forget, a copy you can forget, and a lifetime that is not obvious from reading the code. That is a real maintenance cost, and it should buy something you measured.
12.3.9 Type Safety with Generics
// Illustrative snippet — not a complete program
type TypedPool[T any] struct {
p sync.Pool
}
func NewTypedPool[T any](newFn func() T) *TypedPool[T] {
return &TypedPool[T]{
p: sync.Pool{New: func() any { return newFn() }},
}
}
func (tp *TypedPool[T]) Get() T { return tp.p.Get().(T) }
func (tp *TypedPool[T]) Put(v T) { tp.p.Put(v) }
Instantiate it with a pointer type — TypedPool[*bytes.Buffer] — both for the boxing reason in §12.3.4 and because Put on a value type would copy it. The wrapper also removes the Put-the-wrong-type bug, which is otherwise a runtime panic in whichever goroutine happens to Get next.
12.3.10 Common Mistakes
GetLast caller’s data leaks into this one
Reset() immediately
buf.Bytes()Points into memory you gave back
bytes.Clone
PutSomeone else owns it now
Copy what you need
[]byte not *[]byteAllocates on every Put
Pool pointers
One huge object is pooled forever
Drop oversized ones
The next Get receives a held lock
Unlock before Put
initThe next GC discards it
Trust New
Breaks it silently
Pass a pointer
The slice-growth mistake is subtler than it looks, and it is worth getting right because the obvious version of the example does not actually exhibit the bug:
// Illustrative snippet — not a complete program
// This does NOT retain the grown slice: s is a local header,
// so the pool still holds the original 1 KB one. It is a
// missed optimization, not a leak.
func process(data []byte) {
sp := slicePool.Get().(*[]byte)
defer slicePool.Put(sp)
s := append((*sp)[:0], data...)
_ = s
}
// THIS is the version that retains it -- and the one you
// should write, with the cap, because writing the grown
// slice back is the point of pooling a *[]byte.
func process(data []byte) {
sp := slicePool.Get().(*[]byte)
defer func() {
if cap(*sp) <= maxPooled {
slicePool.Put(sp)
}
}()
*sp = append((*sp)[:0], data...) // written back
}
Putting back a locked object is not a deadlock, which makes it worse than one:
// Illustrative snippet — not a complete program
// ✗ BROKEN: Put happens while the lock is still held
func use() {
s := statePool.Get().(*State)
s.mu.Lock()
defer s.mu.Unlock() // runs AFTER Put
statePool.Put(s)
}
The deferred Unlock runs after Put, so between the two the object sits in the pool with its mutex held. A goroutine that Gets it blocks — and is then released when the first goroutine’s Unlock runs. Nothing hangs. What happens instead is that two goroutines are inside the same object’s critical section believing they hold it exclusively, and the second one’s Unlock releases a lock the first one thinks it owns. It is a corruption bug wearing a deadlock’s clothes, and no tool will report it.
Copying is caught by go vet:
12.3.11 Performance Characteristics
Measured minimum of eight runs, 4 KB buffers.Get/Put, *[]byteGet/Put, *bytes.BufferGet/Put, []byte boxedmake([]byte, 4096)make([]byte, 256)make([]byte, 64)The per-P design shows up in the second column: 11.42 ns to 1.52 ns as goroutines go from one to sixteen. Each P is reading and writing its own cache, so there is nothing to contend over — the same scaling shape as sync.Map's reads and sync.Once's fast path, for the same underlying reason.
A benchmark for this must report allocations, or it cannot see the boxing bug at all:
// Illustrative snippet — not a complete program
func BenchmarkPool(b *testing.B) {
p := sync.Pool{New: func() any {
s := make([]byte, 4096)
return &s
}}
b.ReportAllocs() // without this, 1 alloc/op is invisible
for b.Loop() {
bp := p.Get().(*[]byte)
s := *bp
s[0] = 1
p.Put(bp)
}
}
12.3.12 Testing Code That Uses sync.Pool
The bugs worth testing for are the reset and the escape, and neither is a data race, so -race will not find them. Write the test that catches a missing copy:
// Illustrative snippet — not a complete program
func TestRenderReturnsIndependentBytes(t *testing.T) {
a := Render([]byte("first"))
b := Render([]byte("second"))
a[0] = 'X' // if these share a backing array...
if b[0] == 'X' {
t.Fatal("Render returned a view into the pool")
}
}
Do not write a test that asserts Get returns the same object you Put. It usually will, and it is entitled not to — the collector may have run, or another P may have stolen it. A test that depends on pool contents is a flaky test by construction.
Summary: sync.Pool
sync.Pool reduces allocation by lending out objects and taking them back, with a per-P cache that makes the common path cost about 11 ns and get cheaper as cores are added. The collector empties it, so an object survives at most two collections and nothing in it can be relied upon.
Three rules cover almost every bug: reset immediately after Get, copy anything that outlives the Put, and pool pointer types so the interface conversion does not allocate the thing you were trying not to allocate. Cap what goes back so one large object does not live forever, and reach for it only after a profile shows the collector is the problem.
Self-Check Questions: sync.Pool
A collection drops the victim cache entirely and moves the live cache into it. So an object survives at most two collections: one to move it to victim, another to drop it.
How long that takes in wall-clock time is not a property of the pool — it depends on your allocation rate. Under heavy load, collections may be milliseconds apart. The only case with a fixed answer is an idle program, where the runtime forces a collection every two minutes, and that is the case where pool contents do not matter.
The practical rule is simply that pool contents are never guaranteed. An object that is continuously Get and Put is never idle long enough to age out; anything else may be gone.
This function passes go vet and is clean under -race. What is wrong with it?
// Illustrative snippet — not a complete program
func GetData() []byte {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
buf.WriteString("response")
return buf.Bytes()
}
It returns a slice pointing into the buffer’s backing array, and the deferred Put hands that buffer to the next caller before the return value is ever read. The next goroutine to Get it will Reset and overwrite the memory the caller is holding.
The race detector cannot see this: from its point of view this goroutine finished with the buffer and another goroutine legitimately acquired it. There is no concurrent access to the same variable — only two goroutines with different names for the same memory.
// Illustrative snippet — not a complete program
return bytes.Clone(buf.Bytes())
Why does this pool allocate on every Put, and what does it cost?
// Illustrative snippet — not a complete program
var p = sync.Pool{New: func() any { return make([]byte, 4096) }}
Put?
Put takes an any. A []byte is a three-word header — pointer, length, capacity — which does not fit in an interface’s single data word, so Go allocates a copy of the header to box it. Every Put, forever.
*[]byte. The pool was added to remove allocations and is performing one per cycle.
Pool pointer types. It is also why a benchmark for a pool must call b.ReportAllocs() — without it, the bug is invisible.
sync.Pool for 4 KB buffers. A colleague suggests also pooling a 40-byte struct that is allocated just as often. Is that a good idea?
Probably not, and the reason is a ratio rather than a threshold.
Measured a 64-byte allocation costs 22.42 ns against 11.42 ns for a pooled round trip, so the pool is genuinely about twice as fast per operation on go1.26.1 (closer to 1.5× on Go 1.27, whose allocator made sub-80-byte allocations cheaper). The speed argument is not zero.But the benefit of pooling scales with the bytes you keep away from the collector, and 40 bytes is very little; the risk does not scale at all. A missed reset leaks one request’s data into another’s just as easily at 40 bytes as at 4 KB, and a returned pointer is just as dangerous. You would be taking on the full correctness cost for a small fraction of the benefit, in exchange for nanoseconds nobody will observe.
Profile it. If 40-byte structs are genuinely a measurable share of your allocation volume, pool them; if they are not, the simpler code is worth more.
sync.Pool the wrong tool for database connections?
Two reasons, and the second is the interesting one.
First, the collector empties the pool, and a connection dropped that way is never closed — the file descriptor and the server-side session leak. Pools have no eviction callback, so there is nowhere to put the Close.
Second, and more specifically: *sql.DB is already a connection pool. sql.Open does not open a connection; it returns a handle that manages connections internally, with its own limits, health checks and idle timeouts. Putting it inside a sync.Pool builds a pool of pools and leaks every connection each discarded handle held.
Create one *sql.DB for the life of the program and configure it with SetMaxOpenConns and SetMaxIdleConns.
Key Takeaways
sync.Poollends objects and takes them back; the collector empties it, so an object survives at most two collections and pool contents are never guaranteed- Reset immediately after
Get, copy anything that outlives thePut, and cap what goes back so one large object is not pooled forever - Pool pointer types: a
[]bytecosts 24 B and one allocation on everyPut; a*[]bytecosts nothing - The per-P cache means the fast path takes no lock and gets cheaper with more cores — 11.42 ns at one goroutine, 1.52 ns at sixteen
- Not a cache, not a connection pool, and not for anything holding a file, socket or cleanup function;
*sql.DBis already a pool - The reason to skip small objects is that the benefit scales with size and the risk does not — not that pooling is slower
Next: the three types so far never block. §12.4 turns to the one that exists entirely to block, and to the rule that a Go program must obey even though the folklore around it comes from a different language.
sync.Pool lends you memory the collector can take back at any moment, so reset what you take and copy what you return — and pool a pointer, or the interface conversion allocates the thing you were trying not to allocate.
12.4 sync.Cond: Waiting on a Predicate
The other three types in this chapter never block. sync.Cond exists only to block: it puts a goroutine to sleep until some other goroutine says the world has changed, and then lets it look again.
Go programs need that far less often than programs in other languages do, because a channel already carries “wait until something happens” in its send and receive. So sync.Cond is genuinely rare, and most uses of it in the wild would read better as a channel. This section spends its first half on when not to use it, and its second half on the narrow set of problems where a channel cannot be made to fit.
12.4.1 The Problem
Without a way to sleep on a condition, waiting means polling:
// Illustrative snippet — not a complete program
// ✗ BROKEN: burns CPU and adds latency at the same time
func worker(q *Queue, mu *sync.Mutex) {
for {
mu.Lock()
for q.Len() == 0 {
mu.Unlock()
time.Sleep(10 * time.Millisecond)
mu.Lock()
}
item := q.Pop()
mu.Unlock()
process(item)
}
}
The sleep interval is a choice between two bad outcomes. Short, and the worker spends its life taking a lock to discover nothing has changed. Long, and an item that arrives one microsecond after the sleep begins waits ten milliseconds for no reason. There is no value that is right, because the interval is a guess about something the program already knows exactly.
For most producer–consumer work, the answer is not a condition variable — it is a channel, which blocks precisely and costs nothing while waiting:
// Illustrative snippet — not a complete program
// ✓ IDIOMATIC: no polling, no lock, no interval to tune
func worker(items <-chan Item) {
for item := range items {
process(item)
}
}
Start there. sync.Cond earns a place only when the thing you are waiting for is a predicate over state that already lives behind a mutex, and cannot be reduced to “a value arrived”.
12.4.2 How sync.Cond Works
A Cond is a lock you already own, plus a queue of sleeping goroutines.
// Illustrative snippet — not a complete program
type Cond struct {
L Locker // held while you read or change the condition
}
func NewCond(l Locker) *Cond
func (c *Cond) Wait() // release L, sleep, re-acquire L
func (c *Cond) Signal() // wake one waiter, if any
func (c *Cond) Broadcast() // wake every waiter, if any
There is no useful zero value: Cond.L would be nil and Wait would dereference it. Always use NewCond.
The implementation of Wait is four lines, and the order of the first two is the entire design:
// Illustrative snippet — not a complete program
// $GOROOT/src/sync/cond.go
func (c *Cond) Wait() {
c.checker.check()
t := runtime_notifyListAdd(&c.notify) // 1. take a ticket
c.L.Unlock() // 2. release the lock
runtime_notifyListWait(&c.notify, t) // 3. sleep
c.L.Lock() // 4. re-acquire
}
Steps 1 and 2 are what matters, and only they are indivisible in the sense that counts. The goroutine joins the notify list before it releases the lock. So a signaller that acquires the lock the instant it is released cannot slip past — the waiter’s ticket is already issued, and the wake will be delivered to it.
Step 4 is an ordinary Lock. It is not atomic with the wake, it can block for as long as any lock can, and the world may change completely between the wake at step 3 and the return at step 4. That gap is not a flaw; it is the reason for the rule in §12.4.3.
A two-column trace of a waiter and a signaller sharing a mutex. The waiter locks, finds its predicate false, and calls Wait, which joins the notify list and then releases the lock. The signaller can now acquire the lock, change the state, signal and unlock. The waiter wakes, re-acquires the lock, and re-checks the predicate. If Wait kept the lock while sleeping, the signaller could never acquire it, and the waiter would sleep forever holding the thing it was waiting for.
Most writing about condition variables comes from POSIX threads, where pthread_cond_wait may return without any signal at all, and where the loop around the wait is justified on exactly that ground. Go’s documentation states the opposite in a sentence written to be noticed: “Unlike in other systems, Wait cannot return unless awoken by Broadcast or Signal.” Go uses a ticketed notify list, and a return from Wait always corresponds to a real wake. The loop in §12.4.3 is still mandatory — but for reasons that have nothing to do with spuriousness, and knowing which is which is the difference between following a rule and understanding one.
12.4.3 The Rule: Wait Inside a Loop
// Illustrative snippet — not a complete program
// ✓ CORRECT
c.L.Lock()
for !condition() {
c.Wait()
}
// condition() is true here
c.L.Unlock()
// ✗ BROKEN
c.L.Lock()
if !condition() {
c.Wait()
}
// condition() may be false here
c.L.Unlock()
There are two reasons, and neither is spurious wakeups.
Stolen wakeups. Between your wake and your re-acquisition of the lock, another goroutine can take the lock and consume whatever you were woken for. You wake up correctly, and by the time you can look, it is gone:
A timeline showing why a Wait must sit inside a loop. Two consumers are asleep on an empty queue. A producer appends one item and signals. Both consumers wake and compete for the lock. Consumer A wins, takes the item, and unlocks. Consumer B then acquires the lock and finds the queue empty again, so its predicate is false and it must wait once more. Both were woken legitimately; only one could win.
The documentation for Signal names the mechanism directly: it does not affect scheduling priority, so a goroutine that is merely trying to Lock may get in ahead of the goroutine that was actually waiting. Being woken is not a promise that anything is still true.
Different waiters, different predicates. Broadcast wakes everyone. If three goroutines are waiting for three different conditions on the same state, all three wake and at most one is right. Each has to check for itself.
12.4.4 Signal or Broadcast
Signal wakes one waiter; Broadcast wakes all of them.
A comparison of sync.Cond’s two wake methods. Signal wakes one waiter and is safe when every waiter is testing the same predicate and one item lets one waiter proceed. Broadcast wakes all of them and is required when waiters test different predicates or when a state change lets many proceed. The two errors are not symmetric: using Signal where Broadcast was needed loses wakeups and hangs goroutines forever, while using Broadcast unnecessarily only wastes CPU.
The asymmetry is the whole guidance. Using Signal where Broadcast was needed wakes the wrong goroutine — one that re-checks its own predicate, finds it false, and goes back to sleep, while the goroutine that could have proceeded is never woken and hangs forever. Using Broadcast where Signal would do wakes some goroutines that immediately sleep again, costing a little CPU.
One is a hang; the other is a small waste. When in doubt, Broadcast.
12.4.5 When a Channel Cannot Replace It
Most sync.Cond code should be a channel. It is worth being concrete about the cases where it genuinely should not, because “use channels” without that boundary is advice nobody can act on.
A bounded queue is the example everyone reaches for, and for a fixed capacity it is the wrong example — a buffered channel does the same job in one line, blocking on send when full and on receive when empty. But a buffered channel’s capacity is fixed at make time and cannot be changed, so the moment the bound has to move at runtime, the channel stops being an option:
// Illustrative snippet — not a complete program
// A queue whose capacity can be changed while it is in use.
// A buffered channel cannot do this: cap is fixed at make.
type Queue[T any] struct {
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
items []T
capacity int
}
func NewQueue[T any](capacity int) *Queue[T] {
q := &Queue[T]{capacity: capacity}
q.notEmpty = sync.NewCond(&q.mu)
q.notFull = sync.NewCond(&q.mu)
return q
}
func (q *Queue[T]) Put(item T) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) >= q.capacity {
q.notFull.Wait()
}
q.items = append(q.items, item)
q.notEmpty.Signal()
}
func (q *Queue[T]) Get() T {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.notEmpty.Wait()
}
item := q.items[0]
var zero T
q.items[0] = zero // let the element be collected
q.items = q.items[1:]
q.notFull.Signal()
return item
}
// Resize is the reason this is not a channel.
func (q *Queue[T]) Resize(capacity int) {
q.mu.Lock()
defer q.mu.Unlock()
q.capacity = capacity
q.notFull.Broadcast() // room may exist for several
}
Note Resize uses Broadcast while Get uses Signal. Removing one item makes room for exactly one producer; raising the capacity by fifty makes room for fifty, and Signal would wake one and strand the rest.
Three properties of that type are the general answer to “when is a channel not enough”:
- Two predicates over one piece of state.
notEmptyandnotFullshare a mutex and the same slice. Two channels would be two independent things to keep consistent. - A predicate that is not “a value arrived”. “At least n items”, “capacity has changed”, “the queue is empty and we are shutting down” — a channel signals arrivals, not arbitrary facts about state.
- State that already lives under a mutex. If you are already holding a lock to inspect something, a condition variable attaches to that lock. Bolting a channel onto mutex-protected state means maintaining two synchronization mechanisms that have to agree.
Everything else — a done signal, a one-shot broadcast, passing values, anything needing a timeout — is a channel.
sync.RWMutex has an RLocker method returning a Locker backed by RLock/RUnlock, so sync.NewCond(rw.RLocker()) gives you waiters that hold only the read lock. It is occasionally exactly right — many readers waiting for a version bump — and it comes with a sharp edge: Signal and Broadcast are usually called by a writer holding the write lock, and the waiters re-acquire the read lock on the way out. Mixing the two halves of the same RWMutex under one Cond is easy to get wrong, so reach for it only when the read-side sharing is the point.
12.4.6 Two More Patterns
A reusable barrier. Every participant waits until all of them have arrived, then all proceed — and the barrier resets so it can be used again next round. The generation counter is what makes it reusable: without it, a fast goroutine could lap the others and be released by the next round’s broadcast.
// Illustrative snippet — not a complete program
type Barrier struct {
mu sync.Mutex
cond *sync.Cond
need int
waiting int
gen uint64 // which round we are in
}
func NewBarrier(n int) *Barrier {
b := &Barrier{need: n}
b.cond = sync.NewCond(&b.mu)
return b
}
func (b *Barrier) Wait() {
b.mu.Lock()
defer b.mu.Unlock()
gen := b.gen
b.waiting++
if b.waiting == b.need {
b.waiting = 0
b.gen++ // open the next round
b.cond.Broadcast() // everyone can proceed
return
}
for gen == b.gen { // wait for MY round to end
b.cond.Wait()
}
}
Broadcast is mandatory here, not a preference: releasing the barrier lets every waiter proceed at once, and Signal would release one and strand the rest permanently. The for gen == b.gen loop is the predicate — “my round has ended” — and it is exactly the kind of predicate a channel cannot express, because it is a fact about state rather than an arrival.
Waiting for a drain. A shutdown that has to wait for in-flight work, without a WaitGroup, because the work is counted rather than spawned:
// Illustrative snippet — not a complete program
type Gate struct {
mu sync.Mutex
cond *sync.Cond
active int
closed bool
}
func NewGate() *Gate {
g := &Gate{}
g.cond = sync.NewCond(&g.mu)
return g
}
func (g *Gate) Enter() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return errors.New("gate: closed")
}
g.active++
return nil
}
func (g *Gate) Leave() {
g.mu.Lock()
defer g.mu.Unlock()
g.active--
if g.active == 0 {
g.cond.Broadcast() // any number may be draining
}
}
func (g *Gate) Drain() {
g.mu.Lock()
defer g.mu.Unlock()
g.closed = true
for g.active > 0 {
g.cond.Wait()
}
}
Note Leave broadcasts rather than signals. Only one goroutine usually calls Drain, but “usually” is doing load-bearing work in that sentence — two callers and a Signal would leave one of them asleep forever. The chapter’s exercise is this bug, in the direction that is harder to see.
12.4.7 When NOT to Use sync.Cond
Anything that needs a timeout or cancellation. Wait has no deadline, takes no context, and cannot appear in a select. This is the single most common reason a sync.Cond design has to be torn out later, and there is no way to add it afterwards:
// Illustrative snippet — not a complete program
// ✗ IMPOSSIBLE: there is nowhere to put a timeout
c.L.Lock()
for !ready {
c.Wait()
}
c.L.Unlock()
// ✓ CORRECT: channels compose with select
select {
case <-readyCh:
case <-time.After(5 * time.Second):
case <-ctx.Done():
}
If there is any chance a deadline will be required — and in a server there almost always is — start with a channel. Chapter 13 makes this concrete with context.Context.
A one-shot broadcast. Closing a channel wakes every receiver, needs no lock, and works with select:
// Illustrative snippet — not a complete program
ready := make(chan struct{})
// waiters: <-ready
close(ready) // wakes all of them, once
sync.Cond earns its place only when the broadcast has to happen repeatedly, since a channel cannot be re-closed.
One-time initialization. That is §12.1.
Simple producer–consumer at a fixed size. That is a buffered channel.
It is tempting to summarize this section as “channels are safe and condition variables are dangerous”. Chapter 10 does not support that. §10.2 catalogues four separate ways channels deadlock — all goroutines blocked on an empty channel, a receiver waiting on a channel nobody closes, circular dependencies between two channels, and a send under a held mutex. Choosing a channel over a sync.Cond buys you select, timeouts and familiarity. It does not buy you freedom from deadlock, and treating it as though it does is how §10.2's examples get written.
12.4.8 Common Mistakes
if instead of forStolen wakeups leave the predicate false
Always loop
Wait without the lockFatal error, unrecoverable
Lock before waiting
The change can land in the gap
Check under the lock
Signal where Broadcast is neededWakes a waiter that cannot proceed
Broadcast when unsure
c.LUnlocks a mutex you do not hold
Use c.L
Condc.L is nil
sync.NewCond
CondPanics at run time
Pass a pointer
Calling Wait without the lock is a fatal error, not a panic. This distinction matters, because a recover will not save you:
// Illustrative snippet — not a complete program
// ✗ BROKEN: Wait calls c.L.Unlock() on a mutex nobody holds
for !ready {
c.Wait()
}
fatal error, like the concurrent-map crash in §12.2.1 and the deadlock detector in §10.4, is unrecoverable by design. The process stops.
The lost wakeup is about the lock, not the ordering. This is worth stating carefully, because the usual telling of it is wrong. Signalling before you change the state is fine as long as you hold the lock across both:
// Illustrative snippet — not a complete program
// ✓ CORRECT, if surprising: the lock is held across both,
// so no waiter can return from Wait until Unlock, by which
// time ready is already true.
mu.Lock()
c.Signal()
ready = true
mu.Unlock()
// ✓ CORRECT and clearer: write it in the order it reads
mu.Lock()
ready = true
c.Signal()
mu.Unlock()
Both of these work, and the second is the one to write — not because the first is broken but because a reader should not have to reconstruct the argument above to trust it. Put the state change first; it costs nothing and it says what you mean.
What actually loses a wakeup is signalling outside the lock, before the state changes:
// Illustrative snippet — not a complete program
// ✗ BROKEN: the signal lands before there is anything to see
c.Signal() // no lock held; the waiter is asleep and wakes
mu.Lock() // ... re-checks ready (still false) ...
ready = true // ... and is already back asleep by now
mu.Unlock() // nothing will signal again
The waiter wakes, re-acquires the lock ahead of the signaller, finds ready false, and waits again. Then the signaller sets ready and never signals again. The rule that prevents this is not “state before signal” — it is hold the lock across both.
Copying a Cond is the one the runtime catches itself:
// Illustrative snippet — not a complete program
// ✗ BROKEN: go vet reports it, and so does the runtime
func process(c sync.Cond) { c.Signal() }
sync.Cond carries a copyChecker that records its own address on first use and compares it on every subsequent call. Use a copy and the program panics with sync.Cond is copied. That makes it the only one of this chapter’s four types that detects its own misuse at run time — Once, Map and Pool all rely on go vet alone, and go vet only sees the cases it can prove statically. Chapter 11 drew the same line for atomics: a compile-time guarantee and a vet-time convention are different things, and it is worth knowing which one you are relying on.
12.4.9 Performance Characteristics
Measured minimum of eight runs.Signal with no waitersBroadcast with no waitersWait/Signal round trip, two goroutinesSignalling into an empty wait list is nearly free, so a Signal on every Put costs almost nothing when nobody is waiting. The round-trip figure is what a real park-and-wake costs: two goroutines handing control back and forth, which is scheduler work rather than synchronization work, and roughly ten times a buffered channel’s send-and-receive on the same machine.
Do not read that last comparison as “channels are ten times faster than condition variables”. They are measuring different things — the channel benchmark never parks a goroutine, because the buffer is never full or empty. The honest version is that both are cheap enough that the choice between them should be made on expressiveness, and that anything which actually blocks costs hundreds of nanoseconds regardless of which primitive did the blocking.
Broadcast to n waiters wakes n goroutines that then queue for one lock. If only one can proceed, the other n−1 wake, take the lock, re-check, and sleep again. That is the thundering herd, and it is a reason to prefer Signal when it is provably correct — never a reason to use Signal where correctness needs Broadcast.
A sync.Cond is 56 bytes, and one Cond serves any number of waiters, which is the memory argument for it over a channel per waiter.
12.4.10 sync.Cond in the Standard Library
sync.Cond is rare in the standard library, and the places it does appear are instructive because they all share a shape.
net/http's connReader uses one to coordinate a background read against the handler that may need to abort it: the reader goroutine and the connection state live behind one mutex, and Wait blocks until a background read finishes. net/http's HTTP/2 client uses another on ClientConn, where a stream waits for room in the flow-control window — a predicate (“the window has enough bytes”) that is not “a value arrived”, over state that a mutex already protects.
Both are the case §12.4.5 described: mutex-protected state, a predicate that is not an arrival, no timeout expressible in the wait itself. And both are deep inside a library rather than in application code, which is the honest summary of where sync.Cond belongs.
Summary: sync.Cond
sync.Cond puts goroutines to sleep on a predicate and wakes them when someone says the state changed. Wait joins the notify list before releasing the lock, which is what makes a wakeup impossible to lose; it re-acquires the lock on the way out, which is why the predicate must be re-checked.
Go’s Wait does not return spuriously — the loop is required because wakeups can be stolen before you get the lock back, and because Broadcast wakes waiters with different predicates. Signal where Broadcast was needed hangs a goroutine; the reverse only wastes CPU.
Reach for it when two predicates share one mutex-protected state and the wait cannot be expressed as an arrival. Everything else is a channel, and anything that might ever need a timeout is a channel for certain.
Self-Check Questions: sync.Cond
Wait are indivisible, and what does that buy?
Joining the notify list and releasing the lock. Wait takes its ticket first and unlocks second, so a signaller who acquires the lock the instant it is released still finds the waiter registered, and the wake is delivered.
If those were reversed, a signal landing in the gap would find an empty wait list, go nowhere, and the goroutine would then sleep with nothing left to wake it — a lost wakeup.
What is not atomic is the fourth step, re-acquiring the lock after waking. That is an ordinary Lock that can block for as long as any lock can, and the state can change completely in the meantime. That gap is exactly why the predicate has to be re-checked in a loop.
Wait be called inside a for loop rather than an if? Name the reasons that actually apply to Go.
Two reasons, and spurious wakeups is not one of them — Go’s documentation says plainly that “Wait cannot return unless awoken by Broadcast or Signal.” That claim comes from POSIX threads and does not transfer.
Stolen wakeups. Between waking and re-acquiring the lock, another goroutine can take the lock and consume what you were woken for. Signal's documentation notes that it does not affect scheduling priority, so a goroutine merely attempting to Lock can get in ahead of the one that was waiting.
Different waiters, different predicates. Broadcast wakes everyone. If they are waiting on different conditions over the same state, at most one is right, and each must check for itself.
Both mean the same thing: being woken is not evidence that anything is true. Only re-checking is.
Is this a bug?
// Illustrative snippet — not a complete program
mu.Lock()
cond.Signal()
ready = true
mu.Unlock()
Wait while ready is still false?
No. This works correctly, which surprises people who have been taught “always change state before signalling”.
The lock is held across both operations. A woken waiter cannot return from Wait until it re-acquires that mutex, which cannot happen until mu.Unlock() — by which point ready is already true. The waiter re-checks its predicate, finds it true, and proceeds.
Write it the other way round anyway. Not because this version is broken, but because a reader has to reconstruct that argument to see that it is safe, and the version that puts the state change first requires no argument at all.
The genuinely broken version signals outside the lock:
// Illustrative snippet — not a complete program
cond.Signal() // waiter wakes, re-checks, sleeps again
mu.Lock()
ready = true // too late; nothing will signal again
mu.Unlock()
The rule is hold the lock across the change and the signal, not merely order them.
What is wrong here, and what exactly happens when it runs?
// Illustrative snippet — not a complete program
func consumer() {
for len(data) == 0 {
cond.Wait()
}
item := data[0]
data = data[1:]
process(item)
}
The lock is never acquired. Wait begins by calling c.L.Unlock() on a mutex this goroutine does not hold, and the program dies:
That is a fatal error, not a panic — recover does not catch it and the process stops, the same class of failure as the concurrent-map crash in §12.2.1.
There is a second bug underneath it: reading and writing data without the lock is a plain data race that -race would report.
// Illustrative snippet — not a complete program
func consumer() {
cond.L.Lock()
for len(data) == 0 {
cond.Wait()
}
item := data[0]
data = data[1:]
cond.L.Unlock()
process(item) // outside the lock: it may be slow
}
sync.Cond?
sync.Cond with Broadcast, and this is one of the few clear cases.
Closing a channel is the idiomatic one-shot broadcast, but a channel cannot be re-closed, so a repeated broadcast means replacing the channel on every reload and making a thousand goroutines find the new one — which is its own synchronization problem. Broadcast can be called any number of times, needs no per-waiter allocation, and the configuration state is already behind a mutex.
The thing to check before committing is whether any waiter will ever need to give up — a shutdown, a deadline, a context. If so, Wait cannot express it and you are back to channels regardless.
Key Takeaways
Waitjoins the notify list before unlocking, which is why a wakeup cannot be lost; it re-acquires the lock afterwards, which is why the predicate must be re-checked- Go’s
Waithas no spurious wakeups — the loop is required for stolen wakeups and forBroadcastwaking waiters with different predicates SignalwhereBroadcastwas needed hangs a goroutine forever;BroadcastwhereSignalwould do wastes a little CPU. PreferBroadcastwhen unsure- Hold the lock across both the state change and the signal; the order between them is a legibility choice, not a correctness one
Waitwithout the lock is afatal error, not a recoverable panicsync.Condis the only type in this chapter that detects its own copy at run time; the other three rely ongo vet- Use it when two predicates share one mutex-protected state and the wait is not an arrival — and never when a timeout might be needed
Next: four primitives, four sets of rules. §12.5 is about what to do when one of them misbehaves in production, and §12.6 puts all four back in one picture.
sync.Cond owns the sleeping and you own the predicate, which is why the wait is always a loop — not because Go has spurious wakeups, which it does not, but because a wakeup can be stolen before you get the lock back.
12.5 Debugging sync Primitives in Production
Each of these four types fails in a characteristic way, and the failure signature is usually enough to name the type before you read any code.
A lookup table pairing production symptoms with the sync primitive that causes them. Goroutines parked in notifyListWait mean a condition variable nobody signalled. One goroutine in Once.doSlow with many behind it means initialization that blocks. Stale data that never refreshes means a Once that cached a failure. Cross-request data in responses means a pooled buffer returned without a copy. An unchanged allocation profile means a pooled slice boxing on every Put.
Getting a goroutine dump. On a live service, use the pprof endpoint:
SIGQUIT also prints a dump, and terminates the process — §10.4 covers this at length and the warning has not changed. Use it on something you are willing to kill.
Reading the dump. sync.runtime_notifyListWait in a stack means a sync.Cond waiter. sync.(*Once).doSlow means one goroutine is inside the initialization and everyone else is queued behind it — look at that goroutine’s stack, not at the queue, for the same reason §10.4 says to find the goroutine holding the lock rather than the ones waiting for it.
Since Go 1.27 the runtime can also tell you which of those waiters can never wake. /debug/pprof/goroutineleak (also pprof.Lookup("goroutineleak")) lists goroutines blocked on a channel, mutex or Cond that is unreachable from every runnable goroutine — a Cond waiter whose signaller has already exited shows up there without you reading the other five hundred stacks. It cannot see a primitive that is still reachable through a global or a live goroutine’s locals, so a package-level Cond in a leak stays a job for the full dump. §19.3.6 has the mechanics.
Verifying a pool is helping. The only honest test is an allocation profile before and after:
If allocs/op did not fall, the pool is not doing anything, and the first thing to check is §12.3.4's boxing trap — a pool of []byte rather than *[]byte shows up as exactly one stubborn allocation per operation.
What -race will and will not find here. It catches a goroutine mutating a value stored in a sync.Map (§12.2.10), and it catches two goroutines touching a pooled object at the same time — a Put that happened while the caller was still writing, say. It does not catch a lost update from a load-then-store, a sync.Once that cached an error, a Signal that should have been a Broadcast, or the case in §12.3.4 where one goroutine reads a buffer after returning it and another has legitimately acquired it since — none of those are data races, and the last one looks to the detector like two goroutines correctly taking turns. §8.3 makes the distinction; this chapter is full of bugs on the wrong side of it.
Race-detector builds run 2–20× slower and use far more memory, so they belong in CI and staging rather than production — §8.3 has the details, including the exit codes.
-race finds the two failures here that genuinely are data races — a mutated sync.Map value and a pooled object still in use — and none of the rest, so the goroutine dump and the allocation profile are the tools that actually find the others.
12.6 Choosing a Synchronization Primitive
Nine tools, one question: what are you actually trying to do?
A decision table mapping tasks to primitives. Moving a value between goroutines is a channel; waiting for goroutines to finish is a WaitGroup; running something once is sync.Once; protecting a group of fields is a mutex, or an RWMutex when reads dominate; one word of memory is sync/atomic; a contended map with stable or disjoint keys is sync.Map and any other map is a mutex-guarded one; reusing an allocation is sync.Pool; sleeping on a predicate over locked state is sync.Cond; and cancellation is context.
Three rules cover most of the remaining doubt.
Channels move values; the sync package protects state. If a goroutine needs to have something another goroutine produced, that is a channel. If several goroutines need to agree about something that sits still, that is a lock or one of this chapter’s types. Chapter 1's proverb is about the first case and was never a claim that mutexes are unidiomatic.
Prefer the tool that expresses the constraint. A sync.Once says “this happens once” in a way a bool and a mutex do not. That is worth more than the nanoseconds, and the nanoseconds are usually the smaller part of the difference anyway.
Scaling and speed are different questions. Three of this chapter’s four types get faster with more cores — Once's fast path, Map's reads, Pool's per-P cache — because they read memory nobody is writing. A mutex gets slower, because every reader writes the lock word. At one goroutine, the mutex wins nearly every comparison in this chapter. Which column of the table you are in is a fact about your program, not about the primitive.
sync.Once.Do after the firstsync.Pool Get plus Putsync.Map readmap + RWMutex readsync.Cond Wait/Signal round tripEvery figure in the second column that went down is a type that scales; every one that went up is a lock. That is the whole of the performance argument in this chapter, and it is the same argument §11.2.7 made about atomic loads.
Three of these four types get faster as you add cores because their fast paths read memory nobody writes; a lock gets slower because every reader writes the lock word.
Chapter Summary
Four types, four narrow promises, and the same failure in each case: assuming a wider promise than the one you were given.
sync.Once runs a function once and blocks everyone else until it finishes. Its fast path is an inlined atomic load — about a nanosecond, and cheaper still with more cores. Its guarantee is about executions, not outcomes, so a failure is cached exactly as firmly as a success. Prefer OnceValue and OnceValues; keep Do for the cases §11.2.3 named.
sync.Map is a hash-trie, and has been since Go 1.24 — the read/dirty design that most writing still describes was deleted. Loads take no lock; stores lock only the one interior node they touch. Its advantage is a scaling property, not a per-operation one: serially a mutex-guarded map beats it at both reading and writing, and every store boxes two values onto the heap.
sync.Pool lends objects out and the collector takes them back, so nothing in it can be relied upon. Reset after Get, copy anything that outlives the Put, cap what goes back, and pool pointer types — a pooled []byte allocates on every Put, which is the bug that makes a pool look useless in a profile.
sync.Cond sleeps on a predicate you own. Go’s Wait never returns spuriously; the loop is mandatory because wakeups get stolen and because Broadcast wakes waiters with different predicates. Signal where Broadcast was needed hangs a goroutine; the reverse wastes a little CPU.
Underneath all four is the pattern this book has been building toward. Three of them get faster as you add cores, because their fast paths read memory nobody writes; a mutex gets slower, because every reader writes the lock word. That is §11.2.7's finding about atomic loads, showing up again one layer higher — and it is why “which is faster” is never answerable without saying how many goroutines are asking.
Chapter Connections
Once.Do — it still needs an exitCond.Broadcast is the repeatable oneOnce values can form an AB-BA cycle; §10.4's dump-reading finds a stuck CondOnce is §11.4.7 packaged; Map and Pool are built on §11.1's primitivescontext.Context is the answer wherever §12.4 says Cond cannot time outFinal Checklist
Before moving to Chapter 13, ensure you can:
- Say what
sync.Onceguarantees and what it does not, and explain why a cached error is the defining property rather than an edge case - Choose between
Do,OnceFunc,OnceValueandOnceValues, and say how the wrappers treat a panic differently - Describe how
sync.Mapis actually implemented since Go 1.24, and say what that changed about the write-heavy advice - Read a two-column benchmark without mistaking a scaling property for a per-operation cost
- State the three rules for
sync.Pooland explain why a pooled[]byteallocates on everyPut - Say what the garbage collector does to a pool, and why “about two minutes” is not the answer
- Write a
sync.Condwait loop and give the two reasons it must be a loop — neither of which is spurious wakeups - Decide between
SignalandBroadcast, and say which error is a hang and which is a waste - Name the two situations where a channel genuinely cannot replace a condition variable
- Fix the exercise: make a bounded pool tell the truth about the work it accepted
Exercise 12.1 — Tell the Truth About the Work You Took
Tell the Truth About the Work You Took
This worker pool is correct in every way the previous chapters taught you to check. It compiles. go vet is happy. go test -race reports nothing at all, because every field really is touched under p.mu — there is no data race here to find.
It still loses work, and it lies about it.
Submit returns nil to promise the job is queued. A producer parked in notFull.Wait() when Shutdown begins eventually wakes, sees only that the queue has room, appends its job, and returns that promise — to a pool whose workers have already seen an empty queue and exited. Shutdown then returns, reporting a clean drain of a queue that gained an entry after it looked.
This is §12.4.3's rule turned around. That section says a woken goroutine must re-check its predicate. This one re-checks a predicate that was never complete: it asks whether there is room, when there were always two reasons to stop waiting.
package ch12
import (
"errors"
"sync"
)
// ErrShutdown is returned by Submit once Shutdown has begun.
var ErrShutdown = errors.New("pool: shutting down")
// Pool is a bounded worker pool. Submit blocks while the queue is
// full; Shutdown stops accepting work and waits for the workers to
// drain what is already queued.
//
// TODO(reader): this type compiles, `go vet` is clean, and
// `go test -race` finds nothing -- every access to every field
// really is under p.mu. There is no data race here to find.
//
// It still loses work, and it lies about it.
//
// A producer parked in Submit's notFull.Wait() is woken by a
// worker dequeuing a job. If Shutdown lands while it is parked,
// that producer eventually wakes, sees only that the queue has
// room, appends its job, and returns nil -- telling the caller the
// work is queued. By then the workers may already have seen an
// empty queue and exited, so nothing will ever run it. Shutdown
// returns reporting a clean drain of a queue that gained an entry
// after it looked.
//
// Two tests gate the fix, and the second one is the interesting
// half:
//
// 1. TestAcceptedJobsAlwaysRun submits under shutdown and checks
// that every job Submit accepted actually ran. It fails today,
// deterministically.
// 2. TestShutdownReleasesBlockedSubmitters blocks the incomplete
// fix. Teaching Submit's wait loop about shutdown is necessary
// and not sufficient: setting the flag wakes nobody, so a
// producer already parked keeps sleeping until some worker
// happens to signal notFull -- and during shutdown there may
// be no such worker left. The waiter has to be woken as well
// as taught what to check.
//
// You may not remove the bound, and Submit must keep returning nil
// for work it really did queue.
type Pool struct {
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
jobs []func()
maxQueued int
shuttingDown bool
workers sync.WaitGroup
shutdownOnce sync.Once
}
// New starts n workers with room for maxQueued pending jobs.
func New(workers, maxQueued int) *Pool {
p := &Pool{maxQueued: maxQueued}
p.notEmpty = sync.NewCond(&p.mu)
p.notFull = sync.NewCond(&p.mu)
for range workers {
p.workers.Go(p.run)
}
return p
}
// Submit queues a job, blocking while the queue is full.
func (p *Pool) Submit(job func()) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.shuttingDown {
return ErrShutdown
}
for len(p.jobs) >= p.maxQueued { // <- your move
p.notFull.Wait()
}
p.jobs = append(p.jobs, job)
p.notEmpty.Signal()
return nil
}
// Shutdown stops accepting work and waits for the queue to drain.
// It is safe to call more than once.
func (p *Pool) Shutdown() {
p.shutdownOnce.Do(func() {
p.mu.Lock()
p.shuttingDown = true
p.notEmpty.Broadcast() // <- your move
p.mu.Unlock()
p.workers.Wait()
})
}
func (p *Pool) run() {
for {
p.mu.Lock()
for len(p.jobs) == 0 && !p.shuttingDown {
p.notEmpty.Wait()
}
if len(p.jobs) == 0 && p.shuttingDown {
p.mu.Unlock()
return
}
job := p.jobs[0]
p.jobs = p.jobs[1:]
p.notFull.Signal()
p.mu.Unlock()
job()
}
}
And the tests that gate it. The first is the promise; the second is the one that blocks a half-fix:
package ch12
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// Correctness first: whatever you change, the pool must still run
// every job it accepted, and Shutdown must still return.
func TestSubmitAndDrain(t *testing.T) {
p := New(4, 8)
var done atomic.Int32
for range 100 {
if err := p.Submit(func() { done.Add(1) }); err != nil {
t.Fatalf("Submit returned %v; want nil", err)
}
}
p.Shutdown()
if n := done.Load(); n != 100 {
t.Fatalf("ran %d jobs; want all 100", n)
}
if err := p.Submit(func() {}); !errors.Is(err, ErrShutdown) {
t.Fatalf("Submit after Shutdown = %v; want ErrShutdown", err)
}
p.Shutdown() // must stay safe to call twice
}
// The point of the exercise.
//
// Submit returns nil to promise the job is queued. That promise
// has to survive Shutdown: either the job runs, or Submit says
// ErrShutdown. Returning nil for a job nobody will ever run is the
// one outcome that is not allowed.
func TestAcceptedJobsAlwaysRun(t *testing.T) {
const rounds = 50
for i := range rounds {
p := New(1, 1)
release := make(chan struct{})
var ran atomic.Bool
mustSubmit(t, p, func() { <-release }) // worker takes it
waitForQueue(t, p, 0)
mustSubmit(t, p, func() {}) // queue now full
waitForQueue(t, p, 1)
var wg sync.WaitGroup
var err error
wg.Go(func() {
err = p.Submit(func() { ran.Store(true) })
})
time.Sleep(2 * time.Millisecond) // park it on notFull
shutdownDone := make(chan struct{})
go func() {
close(release)
p.Shutdown()
close(shutdownDone)
}()
wg.Wait()
<-shutdownDone
// Shutdown has returned, so the queue is drained and every
// worker has exited. Nothing more can run, ever.
if err == nil && !ran.Load() {
t.Fatalf("round %d: Submit returned nil but the job "+
"never ran.\n\n"+
"The producer was parked in notFull.Wait() when "+
"Shutdown began. It woke when a worker dequeued, "+
"saw only that the queue had room, and appended "+
"to a pool whose workers had already decided to "+
"exit. The caller was told the work was queued.\n\n"+
"Submit's wait loop tests the queue length and "+
"nothing else. It has to know about shutdown "+
"too -- and then say so.", i)
}
if err != nil && !errors.Is(err, ErrShutdown) {
t.Fatalf("round %d: Submit = %v; want nil or "+
"ErrShutdown", i, err)
}
}
}
// Blocks the incomplete fix.
//
// Teaching the wait loop about p.shuttingDown is half the job.
// Setting the flag wakes nobody, so a producer that is already
// parked sleeps on until some worker happens to signal notFull --
// and during shutdown there may be no worker left to do it.
// Shutdown has to wake the producers itself.
//
// Here a worker is still busy with a long job when Shutdown lands,
// so no dequeue will happen and no notFull signal is coming.
func TestShutdownReleasesBlockedSubmitters(t *testing.T) {
p := New(1, 1)
release := make(chan struct{})
mustSubmit(t, p, func() { <-release }) // worker takes this
waitForQueue(t, p, 0)
mustSubmit(t, p, func() {}) // queue now full
waitForQueue(t, p, 1)
blocked := make(chan error, 1)
go func() { blocked <- p.Submit(func() {}) }()
time.Sleep(50 * time.Millisecond) // park it on notFull
done := make(chan struct{})
go func() { p.Shutdown(); close(done) }()
select {
case err := <-blocked:
if !errors.Is(err, ErrShutdown) {
t.Fatalf("parked Submit returned %v; want "+
"ErrShutdown", err)
}
case <-time.After(time.Second):
t.Fatal("Shutdown did not release a producer parked in " +
"notFull.Wait().\n\n" +
"A worker is still running a long job, so no dequeue " +
"will happen and nothing will signal notFull. " +
"Setting p.shuttingDown does not wake a sleeping " +
"goroutine -- only Signal or Broadcast does.\n\n" +
"Shutdown wakes the workers. It has to wake the " +
"producers too.")
}
close(release)
<-done
}
func mustSubmit(t *testing.T, p *Pool, job func()) {
t.Helper()
if err := p.Submit(job); err != nil {
t.Fatalf("Submit returned %v; want nil", err)
}
}
func waitForQueue(t *testing.T, p *Pool, n int) {
t.Helper()
deadline := time.Now().Add(time.Second)
for p.queued() != n {
if time.Now().After(deadline) {
t.Fatalf("queue length stuck at %d; want %d",
p.queued(), n)
}
time.Sleep(time.Millisecond)
}
}
// queued reports the current queue length. Test-only helper.
func (p *Pool) queued() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.jobs)
}
Run it:
It fails twice:
Only the round number moves. TestAcceptedJobsAlwaysRun retries until the drop reproduces, which took one to five attempts across thirty runs on the reference machine — so expect round 0 through round 4, and treat a different index as the same failure, not a different one.
go test -race ./... in code/ch12/ reports ok for all three tests, and keeps reporting it under -count=10. TestSubmitAndDrain is there to stop the fix that breaks ordinary use — returning ErrShutdown whenever the queue is full would satisfy both other tests and make the pool useless.Submit's wait loop about p.shuttingDown fixes the dropped job and not the parked producer, because setting a flag wakes nobody. Broadcasting notFull in Shutdown fixes the parked producer and not the dropped job, because a producer woken that way still concludes the queue merely drained. You need both, and the second one has to answer §12.4.4's question — Signal or Broadcast? — correctly.labs/go-concurrency/code/ch12/. A worked answer sits in solution/pool.go.txt, including why run is still right to use Signal where Shutdown must use Broadcast, and what would make this a buffered channel instead.Further Reading
- The
syncpackage documentation — short, and the source for three things this chapter leans on: thatsync.Mapis “specialized” and most code should not use it, thatOnceFuncre-panics with the same value on every call, and thatSignaldoes not affect scheduling priority, which is the mechanism behind every stolen wakeup in §12.4.3. go doc sync.Cond.Wait— one paragraph, and the sentence that undoes a great deal of folklore: “Unlike in other systems, Wait cannot return unless awoken by Broadcast or Signal.” Worth reading on your own toolchain rather than online, where the POSIX version dominates the search results.- Go 1.24 release notes —
sync.Map— the rewrite. Every article, talk and benchmark describing a read map and a dirty map predates this, and is now measuring a type that no longer exists. Check the date before you believe the number; §12.2.7 shows how far wrong the old write-heavy advice now is. $GOROOT/src/internal/sync/hashtriemap.go— the implementationsync.Mapdelegates to, and readable in one sitting. The two facts that explain the benchmarks are both visible in the first hundred lines:Loadtakes no lock at all, and each interior node carries its own mutex.- The Go Memory Model — the
syncsection states the guarantee forOnceand the one forMapin “synchronizes before” terms. §8.4 is the prerequisite; this is the same relation applied to a container. net/http/server.go,connReader— one of the very fewsync.Conduses in the standard library, and a good example of the shape §12.4.5 describes: mutex-protected state, a predicate that is not an arrival, and no timeout expressible in the wait itself.- Bryan Mills, Rethinking Classical Concurrency Patterns (GopherCon 2018) — the argument for replacing condition variables and worker pools with channels, made carefully enough to show where it does not apply. Read it against §12.4.5.
golang.org/x/sync/singleflight— the well-tested version of §12.2.8's per-key deduplication, including the error and panic handling that the hand-rolled version quietly gets wrong.
You can now say what each of these four types guarantees and, more usefully, what it does not: that “exactly once” includes exactly one failure, that sync.Map’s advantage is a scaling property rather than a per-operation one, that anything you hand back from a pooled buffer has to be a copy, and that a woken goroutine has learned nothing until it re-checks. Every primitive so far has protected state that sits still. Chapter 13 turns to the other axis: context.Context, and how a cancellation, a deadline or a request-scoped value travels down through every goroutine a request creates.