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.

get_12_x_1.go
// 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
}
increment_12_x_1.go
// 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
}
render_12_x_1.go
// 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.

WHERE THE SYNC TYPES SIT

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.

What you’ll learn
  • sync.Once and the OnceFunc/OnceValue/OnceValues wrappers, and why a cached failure is the defining property rather than an edge case
  • sync.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 one
  • sync.Pool's two-cycle lifetime, why Get must be followed by a reset and Put by a copy, and the boxing trap that makes a pooled slice allocate on every Put
  • sync.Cond's three rules, why Go’s Wait is not the pthread_cond_wait you 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
What we’re not covering
  • sync.Mutex and sync.RWMutex — Chapter 9. This chapter compares against them constantly but does not re-teach them
  • sync.WaitGroup — §2.3, including the wg.Go form (Go 1.25) used throughout this chapter
  • sync/atomic — Chapter 11. sync.Once and sync.Map are built on it, and §11.4.7's CAS state machine is the alternative to §12.1
  • golang.org/x/syncerrgroup, semaphore and singleflight sit outside the standard library. §12.2.8 points at singleflight where it beats hand-rolled deduplication
  • context.Context for cancellation and deadlines — Chapter 13, and the answer wherever §12.4 says sync.Cond cannot time out
Building toward

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.

Prerequisites

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.

Which Go Are We On?

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.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16. Every performance figure in this chapter came from running the benchmark as printed, each primitive freshly declared inside its own function so no lock or pool is shared between cases, -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:

get_config_121_x_1.go
// 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.

LOST INITIALIZATION

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:

get_config_121_2.go
// 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
}
Measured an uncontended 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.

get_config_121_3.go
// 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.

once_121.go
// 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:

get_config_121_4.go
// 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.

OnceFunc Re-Panics on Every Call

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:

once_121_2.go
// 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.

SYNC.ONCE, THREE CALLERS

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.

What Blocked Goroutines See When f Panics

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.

get_conn_121_x_1.go
// 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:

get_conn_121_x_2.go
// 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:

get_db_121.go
// 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:

get_config_121_5.go
// 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:

conn_cache_121.go
// 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.

The Question That Picks the Tool

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:

apiclient_121.go
// 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:

server_121.go
// 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:

cache_121.go
// 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.

When sync.Once Fits
Exactly once
Running twice is wrong, not merely wasteful
Lazy
The work should wait until something needs it
Concurrent
More than one goroutine can trigger it
No retry
A failure is fatal or permanent, never transient

The comparison people actually need is against init(), which is the other way to run something exactly once:

init() or sync.Once
Use init() when
Always needed
Failure should stop startup
Cost is trivial
Order is import order

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:

reload_cache_121_x_1.go
// 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:

once_121_3.go
// 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

A Once declared inside the function
Problem

Fresh each call, so f runs every time

Fix

Package-level or struct field

A value receiver on the method
Problem

Copies the Once, so the copy is always fresh

Fix

Pointer receiver

The error declared inside the function
Problem

Only the first caller sees it

Fix

Struct field or package level

Do calling itself
Problem

The inner call waits on the outer one

Fix

Break the recursion

Two Once values that wait on each other
Problem

AB-BA cycle, exactly as in §10.3

Fix

Order them, or merge them

Assigning sync.Once{} to reset
Problem

Data race, and no waiter is released

Fix

atomic.Bool or a mutex

The copy is the one go vet catches for you. A value receiver copies the whole struct, Once included:

service_121_x_1.go
// 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:

Terminal
$ go vet ./...
Terminal
./service.go:24:9: Init passes lock by value: main.Service contains sync.Once contains sync.noCopy

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:

initialize_121_x_1.go
// 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:

init_a_121_x_1.go
// 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.
sync.Once Performance
Operation
Do after the first call
OnceValue after the first
Mutex Lock/Unlock
First call

Two 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:

service_test_121_2.go
// 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:

once_initializes_test_121.go
// 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

What happens to the goroutines blocked inside 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?

get_conn_121_3.go
// Illustrative snippet — not a complete program
func GetConn() (*Conn, error) {
    var err error
    connOnce.Do(func() { conn, err = dial() })
    return conn, err
}
Which caller sees the real error, and what do the rest see?

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:

get_conn_121_4.go
// 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).

When should you use 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?

service_121_3.go
// Illustrative snippet — not a complete program
func (s Service) Init() {
    s.once.Do(func() { s.count++ })
}
What does the receiver change about 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:

Terminal
./service.go:24:9: Init passes lock by value: main.Service contains sync.Once contains sync.noCopy

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.Once guarantees 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; Do panics 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.

Section 12.1 — in one line

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:

What the Package Documentation Says

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.

Start With a Map and an RWMutex

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

increment_122_x_1.go
// 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:

cache_122.go
// 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.

Measured an 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:

map_122.go
// 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 HASH TRIE

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.

What the Memory Model Promises

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

map_122_2.go
// 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.

sync.Map Method Semantics
Method
Load
Store
Delete
LoadOrStore
LoadAndDelete
Swap
CompareAndSwap
CompareAndDelete
Range
Clear
CompareAndSwap Compares Interfaces, Not Contents

CompareAndSwap 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

user_122.go
// 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:

snippet_122_x_1.go
// 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)
LoadOrStore Evaluates Its Argument First

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.

WHAT RANGE DOES AND DOES NOT PROMISE

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:

map_len_122.go
// 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:

get_config_122.go
// 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:

handle_connection_122.go
// 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:

product_122.go
// 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 vs map + RWMutex
Operation
sync.Map read
map + RWMutex read
sync.Map write
map + RWMutex write

Read 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.

The Golden Rule

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:

cache_122_2.go
// 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:

cache_122_3.go
// 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:

metrics_122.go
// 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:

typed_map_122.go
// 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

Reaching for it by default
Problem

Slower and untyped below real contention

Fix

Start with an RWMutex

Asserting without checking ok
Problem

Panics on a missing key

Fix

Check ok, then assert

Mutating a value in place
Problem

The map guards itself, not your struct

Fix

Replace the value, or lock it

Load-then-Store to increment
Problem

Check-then-act race

Fix

LoadOrStore an atomic

Treating Range as a snapshot
Problem

It is a walk, not a photograph

Fix

Accept it, or use a mutex

Copying the map
Problem

Breaks it silently

Fix

Pass a pointer

CompareAndSwap with a literal
Problem

Type mismatch fails forever

Fix

Match the stored type

The one that is genuinely surprising is that sync.Map protects the map, not the things in it:

update_age_122_x_1.go
// 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:

Terminal
$ go vet ./...
Terminal
./cache.go:12:19: processMap passes lock by value: sync.Map contains sync.noCopy

12.2.11 Alternatives

Concurrent Map Options
Approach
map + RWMutex
sync.Map
Sharded map

A sharded map is the middle path, and it is worth knowing because it gives you the scaling without giving up types or len:

shard_122.go
// 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 Kmaphash.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).

load_or_test_122.go
// 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

When should you choose 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?

get_or_122.go
// Illustrative snippet — not a complete program
func GetOrCreate(key string) *Resource {
    actual, _ := resources.LoadOrStore(key, NewResource())
    return actual.(*Resource)
}
What happens on a cache hit, and what becomes of the 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:

get_or_122_2.go
// 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)
}
An article from 2022 says 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?

increment_122_2.go
// 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)
}
Where does the lost increment go, and why is -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:

increment_122_3.go
// Illustrative snippet — not a complete program
func Increment(key string) {
    c, _ := counts.LoadOrStore(key, new(atomic.Int64))
    c.(*atomic.Int64).Add(1)
}
Why does 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.Map is 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.Map protects 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.

Section 12.2 — in one line

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.

sync.Pool Is Not a Cache

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

handle_123.go
// 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)
}
Measured 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.

POOL.GET, IN ORDER

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.

WHAT A GC DOES TO A POOL

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

pool_123.go
// 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

render_123.go
// 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:

log_123_x_1.go
// 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:

snippet_123_x_1.go
// 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())
USE AFTER PUT

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:

buf_123_x_1.go
// 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
Measured the two versions, same buffer, same machine.
What the Boxing Costs
Pool element type
*[]byte
[]byte
*bytes.Buffer

A []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

When sync.Pool Fits
Hot path
Allocated and discarded constantly
Short-lived
Done with it before the handler returns
Interchangeable
Any instance is as good as any other
Resettable
One cheap call restores a clean state
No resources
No files, sockets or goroutines inside
Profiled
You measured GC cost, not guessed at it

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.

Measured make([]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:

encode_json_123.go
// 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:

put_buf_123.go
// 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:

request_123.go
// 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:

compress_123.go
// 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.

sync.Pool in the Standard Library
Package
fmt
net/http
encoding/json
encoding/gob
regexp

net/http's helper is two lines and worth reading next to §12.3.6:

put_bufio_123.go
// 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:

db_pool_123_x_1.go
// 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:

snippet_123_2.go
// 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

typed_pool_123.go
// 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

No reset after Get
Problem

Last caller’s data leaks into this one

Fix

Reset() immediately

Returning buf.Bytes()
Problem

Points into memory you gave back

Fix

bytes.Clone

Keeping a reference past Put
Problem

Someone else owns it now

Fix

Copy what you need

Pooling []byte not *[]byte
Problem

Allocates on every Put

Fix

Pool pointers

No capacity cap
Problem

One huge object is pooled forever

Fix

Drop oversized ones

Pooling a locked object
Problem

The next Get receives a held lock

Fix

Unlock before Put

Pre-filling in init
Problem

The next GC discards it

Fix

Trust New

Copying the pool
Problem

Breaks it silently

Fix

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:

process_123.go
// 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:

use_123_x_1.go
// 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:

Terminal
$ go vet ./...
Terminal
./pool.go:10:20: processPool passes lock by value: sync.Pool contains sync.noCopy

12.3.11 Performance Characteristics

Measured minimum of eight runs, 4 KB buffers.
sync.Pool Performance
Operation
Get/Put, *[]byte
Get/Put, *bytes.Buffer
Get/Put, []byte boxed
make([]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.

Derived against a 4 KB allocation the pool is roughly 50× cheaper per operation, and that is before counting the collector work it avoids. Against a 64-byte allocation it is about 2× cheaper on go1.26.1 — closer to 1.5× on Go 1.27, whose size-specialized allocator made allocations under 80 bytes cheaper — which is the number that should make you stop and ask whether the reset discipline is worth it.

A benchmark for this must report allocations, or it cannot see the boxing bug at all:

pool_123_2.go
// 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:

render_returns_test_123.go
// 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

What happens to pooled objects when the garbage collector runs, and how long does an object survive?

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?

get_data_123.go
// 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()
}
What memory does the caller end up holding?

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.

snippet_123_3.go
// Illustrative snippet — not a complete program
return bytes.Clone(buf.Bytes())

Why does this pool allocate on every Put, and what does it cost?

p_123.go
// Illustrative snippet — not a complete program
var p = sync.Pool{New: func() any { return make([]byte, 4096) }}
What has to be boxed on the way into 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.

Measured 30.66 ns and 24 B/op, 1 allocs/op, against 11.42 ns and zero allocations for the same buffer pooled as *[]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.

Your service uses 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.

Why is 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.Pool lends 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 the Put, and cap what goes back so one large object is not pooled forever
  • Pool pointer types: a []byte costs 24 B and one allocation on every Put; a *[]byte costs 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.DB is 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.

Section 12.3 — in one line

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:

worker_124_x_1.go
// 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:

worker_124_2.go
// 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.

cond_124.go
// 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:

cond_124_2.go
// 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.

WHY WAIT RELEASES THE LOCK

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.

Go’s Wait Has No Spurious Wakeups

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

l_124_x_1.go
// 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:

STOLEN WAKEUP

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.

SIGNAL VS BROADCAST

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:

queue_124.go
// 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”:

Everything else — a done signal, a one-shot broadcast, passing values, anything needing a timeout — is a channel.

A Cond Can Attach to a Read Lock

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.

barrier_124.go
// 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:

gate_124.go
// 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:

l_124_x_2.go
// 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:

ready_124.go
// 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.

Channels Are Not Deadlock-Proof Either

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 for
Problem

Stolen wakeups leave the predicate false

Fix

Always loop

Calling Wait without the lock
Problem

Fatal error, unrecoverable

Fix

Lock before waiting

Checking the predicate outside the lock
Problem

The change can land in the gap

Fix

Check under the lock

Signal where Broadcast is needed
Problem

Wakes a waiter that cannot proceed

Fix

Broadcast when unsure

Waiting on a different lock than c.L
Problem

Unlocks a mutex you do not hold

Fix

Use c.L

Zero-value Cond
Problem

c.L is nil

Fix

sync.NewCond

Copying the Cond
Problem

Panics at run time

Fix

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:

l_124_x_3.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Wait calls c.L.Unlock() on a mutex nobody holds
for !ready {
    c.Wait()
}
Terminal
fatal error: sync: unlock of unlocked mutex

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:

ready_124_2.go
// 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:

ready_124_x_3.go
// 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:

process_124_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: go vet reports it, and so does the runtime
func process(c sync.Cond) { c.Signal() }
Terminal
./cond.go:10:20: process passes lock by value: sync.Cond contains sync.noCopy

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.
sync.Cond Operation Costs
Signal with no waiters
2.70 ns
Broadcast with no waiters
2.70 ns
Wait/Signal round trip, two goroutines
367.00 ns
Buffered channel send plus receive
36.01 ns

Signalling 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

Which parts of 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.

Why must 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?

ready_124_4.go
// Illustrative snippet — not a complete program
mu.Lock()
cond.Signal()
ready = true
mu.Unlock()
Can a waiter return from 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:

ready_124_5.go
// 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?

consumer_124.go
// Illustrative snippet — not a complete program
func consumer() {
    for len(data) == 0 {
        cond.Wait()
    }
    item := data[0]
    data = data[1:]
    process(item)
}
Which line fails first, and can the program recover from it?

The lock is never acquired. Wait begins by calling c.L.Unlock() on a mutex this goroutine does not hold, and the program dies:

Terminal
fatal error: sync: unlock of unlocked mutex

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.

consumer_124_2.go
// 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
}
You have a thousand goroutines that must all wait for a configuration reload, and reloads happen many times over the process’s life. Channel or 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

  • Wait joins 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 Wait has no spurious wakeups — the loop is required for stolen wakeups and for Broadcast waking waiters with different predicates
  • Signal where Broadcast was needed hangs a goroutine forever; Broadcast where Signal would do wastes a little CPU. Prefer Broadcast when unsure
  • Hold the lock across both the state change and the signal; the order between them is a legibility choice, not a correctness one
  • Wait without the lock is a fatal error, not a recoverable panic
  • sync.Cond is the only type in this chapter that detects its own copy at run time; the other three rely on go 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.

Section 12.4 — in one line

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.

SYMPTOM TO CAUSE

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:

Terminal
$ curl -s localhost:6060/debug/pprof/goroutine?debug=2 | head -60

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:

Terminal
$ go test -bench=. -benchmem -count=8

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.

Section 12.5 — in one line

-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?

BY WHAT YOU ARE DOING

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.

Measured the numbers behind that claim, from §12.1.9, §12.2.7 and §12.3.11, on the reference machine.
The Whole Picture
Operation
sync.Once.Do after the first
sync.Pool Get plus Put
sync.Map read
Mutex Lock plus Unlock
RWMutex RLock plus RUnlock
map + RWMutex read
Buffered channel send plus receive
sync.Cond Wait/Signal round trip

Every 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.

Section 12.6 — in one line

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

How Chapter 12 Connects
Chapter 1
“Share memory by communicating” is about moving values; these four types are for state that sits still
Chapter 2
§2.4's four questions apply to a goroutine started inside Once.Do — it still needs an exit
Chapter 3
Closing a channel is the one-shot broadcast; Cond.Broadcast is the repeatable one
Chapter 5
A buffered channel is the fixed-size bounded queue; §12.4.5 is what to do when the bound moves
Chapter 7
Worker pools are where these four meet, and the exercise is one
Chapter 8
§8.4's happens-before is what every type here establishes; §8.2's race-condition-without-a-data-race is most of this chapter’s bugs
Chapter 9
“sync.Map or RWMutex?” is §9.3's reader/writer trade-off with new numbers
Chapter 10
Two Once values can form an AB-BA cycle; §10.4's dump-reading finds a stuck Cond
Chapter 11
Once is §11.4.7 packaged; Map and Pool are built on §11.1's primitives
Chapter 13
context.Context is the answer wherever §12.4 says Cond cannot time out

Final Checklist

Before moving to Chapter 13, ensure you can:

Exercise 12.1 — Tell the Truth About the Work You Took

Your move

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.

ch12/pool.go
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:

ch12/pool_test.go
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:

Terminal
$ cd labs/go-concurrency/code/ch12
$ go test -race ./...

It fails twice:

Terminal
--- FAIL: TestAcceptedJobsAlwaysRun (0.01s)
    pool_test.go:68: round 0: Submit returned nil but the job
        never ran.
        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.
        Submit's wait loop tests the queue length and nothing
        else. It has to know about shutdown too -- and then say
        so.
--- FAIL: TestShutdownReleasesBlockedSubmitters (1.05s)
    pool_test.go:119: Shutdown did not release a producer parked
        in notFull.Wait().
        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.
        Shutdown wakes the workers. It has to wake the producers
        too.
FAIL
FAIL corebackend.dev/go-concurrency/ch12 1.481s

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.

Done when: 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.
Two traps: the two failures need two different changes, and each one alone leaves the other failing. Teaching 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.
Where the files are: 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

Next

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.