Chapter 11: Atomic Operations

Chapter 9 gave you the mutex: take the lock, touch the state, release it. Chapter 10 gave you the bill — deadlock, the failure the race detector cannot see. Every technique in both chapters assumed a lock. This chapter removes it.

Not everywhere. For one value at a time, and only for the handful of operations a CPU can perform indivisibly.

Consider these three:

metrics_11_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the write is atomic, the read is not
type Metrics struct {
    requests int64
}

func (m *Metrics) Record()      { atomic.AddInt64(&m.requests, 1) }
func (m *Metrics) Count() int64 { return m.requests }  // DATA RACE
account_11_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: each field is atomic; the pair is not
type Account struct {
    balance atomic.Int64
    limit   atomic.Int64   // invariant: balance <= limit
}

func (a *Account) IsValid() bool {
    // Between these two loads, either value can change.
    return a.balance.Load() <= a.limit.Load()
}
pool_11_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the check and the act are two operations
func (p *Pool) Acquire() bool {
    if p.available.Load() > 0 {
        p.available.Add(-1)   // two goroutines can both reach here
        return true
    }
    return false
}

Not one of these has a lock to order wrongly, so not one of them can deadlock. All three are still broken. The race detector finds the first and is silent on the other two, because nothing in them races — they simply compute the wrong answer.

That is the shape of this chapter. Atomics remove a whole class of bug and introduce a narrower one in its place: operations that are individually indivisible and collectively meaningless. Knowing which is which is most of the skill, and §11.3 is entirely about the second kind.

WHERE ATOMICS SIT

Four synchronization primitives ordered from lightest to heaviest: atomic, sync.Mutex, sync.RWMutex and channel. An atomic covers one value and one operation, never blocks, and cannot deadlock. A mutex covers any state and many fields. An RWMutex adds concurrent readers. A channel transfers ownership. Each buys expressiveness with cost, and none is simply better than the others.

By the end of this chapter, you’ll understand:
  • The sync/atomic package: the typed atomics (Go 1.19+) and the seven operations they support
  • What Load, Store, Add, Swap, CompareAndSwap, And and Or guarantee, and what each one actually costs
  • Why atomics establish happens-before, and how that connects to §8.4
  • When atomics are the right tool, and the more common case where they are not
  • The patterns that hold up in production: shutdown, metrics, configuration hot-reload, copy-on-write, generation counters
What we’re NOT covering in Chapter 11:
  • Lock-free data structures — lock-free queues and stacks need techniques beyond a single value
  • CPU-specific memory ordering — x86-TSO, ARM’s model, acquire and release as separate operations. Go does not expose them; §11.1.8 covers what it does
  • sync.Once, sync.Map, sync.Pool, sync.Cond — Chapter 12
  • context.Context for cancellation — Chapter 13, and usually the right answer where §11.2.1 reaches for a flag
  • The unsafe package — custom atomics over unsafe.Pointer

Prerequisites: data races and the memory model from Chapter 8, especially §8.4's happens-before rules — atomics are rule 7. Mutex mechanics from Chapter 9, because nearly every decision in this chapter is “atomic or mutex?”

Which Go Are We On?

Every listing, figure and benchmark in this chapter was run on Go 1.25 or later. Five changes land close enough to this material to matter when you compare it with older writing:

  • Typed atomics (Go 1.19) — atomic.Int64, atomic.Bool, atomic.Pointer[T]. The function API they replaced is still there, and §11.1.9 explains why not to reach for it.
  • And and Or (Go 1.23) — two more read-modify-write operations. Anything written before them says atomics have five; they have seven.
  • The mutex fast path was rewritten (Go 1.24). Every atomics-versus-mutex benchmark published earlier overstates the uncontended mutex by roughly a factor of two, and so overstates the case for atomics. That includes most of what a search will turn up.
  • wg.Go (Go 1.25) replaces the wg.Add(1) / defer wg.Done() pair.
  • go fix learned to rewrite the legacy function API into typed atomics (Go 1.27) — the atomictypes modernizer; §11.5 shows the mapping it applies.

Everything else here — the memory model, what CAS guarantees, why atomics do not compose — is version-independent.

Measured go1.26.1, darwin/amd64, Intel i7-10700K. Every performance figure in this chapter came from running the benchmark as printed, each primitive freshly declared in its own function, -benchtime 1s -count 8, minimum of eight passes reported. The machine was not idle, so the absolute numbers are an upper bound; ratios held across every pass.

11.1 The sync/atomic Package

Go’s sync/atomic package provides low-level atomic memory primitives. These operations are implemented using special CPU instructions that guarantee indivisibility—no other goroutine can observe a partial update.

ATOMIC VS NON-ATOMIC

Two columns. On the left, counter++ expands into a read, an add and a write, with another goroutine able to act between any two of them, producing lost updates and torn reads. On the right, counter.Add(1) is one indivisible read-modify-write in which no interleaving is possible and every increment is counted exactly once.

From Chapter 8, recall that data races require three conditions:

  1. ✓ Two goroutines access the same memory location
  2. ✓ At least one access is a write
  3. ✗ No synchronization ← Atomics provide this

Atomic operations satisfy the synchronization requirement, eliminating data races for single-value operations.


11.1.1 Two APIs: Legacy Functions and Typed Atomics

Go provides two atomic APIs due to historical evolution:

Legacy API (Go 1.0+): Package-level functions operating on pointers.

counter_111.go
// Illustrative snippet — not a complete program
var counter int64

atomic.AddInt64(&counter, 1)           // Increment
value := atomic.LoadInt64(&counter)    // Read
atomic.StoreInt64(&counter, 0)         // Write

Typed API (Go 1.19+): Method-based types with cleaner ergonomics.

counter_111_2.go
// Illustrative snippet — not a complete program
var counter atomic.Int64

counter.Add(1)          // Increment
value := counter.Load() // Read
counter.Store(0)        // Write
API COMPARISON

A two-column comparison of the legacy package-level functions against the Go 1.19 typed methods. Load, Store, Add, Swap and CompareAndSwap each appear twice: on the left taking a pointer to a plain variable, on the right as a method on the atomic type with no ampersand.

Always Use Typed Atomics for New Code

The typed API (Go 1.19+) is preferred:

  • Type-safe: compiler catches mismatched types
  • Self-documenting: atomic.Int64 declares intent
  • No & needed: cleaner syntax
  • Zero value ready: no initialization required
  • Alignment guaranteed: the Go compiler ensures proper 8-byte alignment for 64-bit atomics, preventing crashes on 32-bit ARM/386 that plague the legacy API

The legacy API remains for backward compatibility and unsafe.Pointer scenarios — and since Go 1.27 go fix can convert existing code to the typed form (§11.5).


11.1.2 Typed Atomic Types

Go 1.19+ provides typed atomic wrappers for common types:

Type
atomic.Bool
atomic.Int32
atomic.Int64
atomic.Uint32
atomic.Uint64
atomic.Uintptr
atomic.Pointer[T]
atomic.Value

Zero value is ready to use—no initialization required:

metrics_111.go
// Illustrative snippet — not a complete program
type Metrics struct {
    requests atomic.Int64   // Ready to use, starts at 0
    errors   atomic.Int64   // No initialization needed
    healthy  atomic.Bool    // Starts as false
}

func (m *Metrics) RecordRequest() {
    m.requests.Add(1)  // Works immediately
}
Do Not Initialize Unnecessarily

The zero value of every atomic type is the zero value of the type it wraps, and it is ready to use. Storing zero into a fresh atomic.Int64 is not harmless boilerplate — it is a reader wondering what you knew that they don’t.

counter_111_x_3.go
// Illustrative snippet — not a complete program
// ✗ UNNECESSARY
counter := new(atomic.Int64)
counter.Store(0)

// ✓ CORRECT
var counter atomic.Int64

11.1.3 Core Operations

Between them, the atomic types support seven operations. Five have been there since Go 1.0; And and Or arrived in Go 1.23, which is why most writing about sync/atomic says five.

Operation
Load
Store
Swap
CompareAndSwap
Add
And
Or

Note the inconsistency in that third column before it bites you: Add returns the value after the operation, while Swap, And and Or return the value before it. There is no principle to remember here — it is just how the API is, and mixing them up produces an off-by-one you will not see in testing.

Load: Atomic Read

Load reads the current value atomically. No other goroutine can observe a partial write—you always get a complete, consistent value.

counter_111_4.go
// Illustrative snippet — not a complete program
var counter atomic.Int64
var config atomic.Pointer[Config]

value := counter.Load()   // Returns int64
cfg := config.Load()      // Returns *Config

Why Load is necessary: Even reading a shared variable requires synchronization to avoid data races.

racy_111_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN — the legacy API lets you mix the two
var counter int64
atomic.AddInt64(&counter, 1)  // atomic write
racy := counter               // plain read → DATA RACE
counter_111_5.go
// Illustrative snippet — not a complete program
// ✓ CORRECT — both sides go through the package
var counter int64
atomic.AddInt64(&counter, 1)
value := atomic.LoadInt64(&counter)
Typed Atomics Make the Non-Atomic Read Impossible

An atomic.Int64 has no arithmetic and no conversion to int64. There is no expression that reads the value without going through Load, so the mistake the legacy API invites cannot be written at all.

counter_111_6.go
// Illustrative snippet — not a complete program
var counter atomic.Int64
counter.Add(1)

// There is no counter + 1, no int64(counter), no counter++.
// Load is the only door.
value := counter.Load()

This is a genuine compile-time guarantee, unlike the copying rule in §11.1.10, which only go vet can enforce.

WHY ATOMIC ACCESS IS NECESSARY

Two timelines. In the first, goroutine A calls atomic.AddInt64 while goroutine B reads the same variable directly; the direct read is a data race because only one side is synchronized. In the second, B reads through atomic.LoadInt64 instead, and a happens-before edge connects A’s write to B’s read.


Store: Atomic Write

Store writes a value atomically. Other goroutines using Load will see either the old value or the new value, never a partial update.

Store Is Not the Mirror of Load

Load and Store look like the same operation pointing in opposite directions. They are not, and the difference is a factor of twelve.

On amd64, Load compiles to a plain MOVQ — an ordinary read that the hardware already makes atomic for an aligned word. Store compiles to XCHGQ, an exchange with an implied lock prefix, because that is how x86 produces a sequentially consistent store. So a Store is a read-modify-write at the hardware level even though your program discards the value read.

Terminal
Load: MOVQ (AX), CX plain read, 1.17 ns
Store: XCHGQ CX, (AX) locked exchange, 5.34 ns

This almost never matters, because most flags are stored once and read constantly. It starts to matter when the write is the hot operation — a gauge that every request Stores into is a write-heavy workload wearing a read-shaped API, and its cost is §11.2.7's Add column, not its Load column.

shutdown_111.go
// Illustrative snippet — not a complete program
var shutdown atomic.Bool
var config atomic.Pointer[Config]

shutdown.Store(true)                              // Signal shutdown
config.Store(&Config{Timeout: 30 * time.Second})  // Update config

Use Store when: Publishing a new value that other goroutines will read.

server_111.go
// Illustrative snippet — not a complete program
// Shutdown flag pattern
func (s *Server) Shutdown() {
    s.shutdown.Store(true)  // All readers immediately see true
}

func (s *Server) worker() {
    for !s.shutdown.Load() {
        // Do work...
    }
}
Shutdown Flags Require Active Polling

This pattern only works when the worker actively polls the flag. If the worker blocks on I/O or a channel, it won’t check the flag until unblocked. For blocking workers, use context.Context or a done channel instead.


Test Your Understanding: Load/Store

Question: Why doesn’t this code work correctly?

cache_111.go
// Illustrative snippet — not a complete program
type Cache struct {
    data atomic.Pointer[CacheData]
}

func (c *Cache) Update() {
    current := c.data.Load()
    current.Count++  // Modify field directly
}
What is wrong with Update, and what does the fix look like?

Data race. Atomic Load/Store protects the pointer, not the pointed-to data.

snippet_111.go
// Illustrative snippet — not a complete program
c.data.Load()         // Atomically reads *CacheData pointer
current.Count++       // NON-atomic modification of the struct field

Multiple goroutines loading the same pointer and modifying Count creates a data race on the Count field.

Fix: Create a new struct and swap the pointer (copy-on-write):

cache_111_2.go
// Illustrative snippet — not a complete program
func (c *Cache) Update() {
    for {
        old := c.data.Load()
        newData := &CacheData{Count: old.Count + 1}
        if c.data.CompareAndSwap(old, newData) {
            return
        }
    }
}

Key insight: Atomic pointer operations protect the pointer itself. Treat pointed-to data as immutable.


Add: Atomic Increment/Decrement

Add atomically adds a delta to the current value and returns the new value. Use negative delta for decrement.

counter_111_7.go
// Illustrative snippet — not a complete program
var counter atomic.Int64

newValue := counter.Add(1)   // Increment, returns new value
newValue = counter.Add(-1)   // Decrement, returns new value
newValue = counter.Add(10)   // Add 10, returns new value

Available for: Int32, Int64, Uint32, Uint64, Uintptr

Not available for: Bool, Pointer[T] (use Store or CompareAndSwap)

ATOMIC ADD OPERATION

A three-row table showing a counter starting at zero. Add of 1 returns 1, Add of 5 returns 6, Add of minus 2 returns 4. Each call returns the value after the addition, never the value before it, and no intermediate state is visible to any other goroutine.

Add Returns the New Value; Swap, And and Or Return the Old One

C and C++ spell this the other way round — fetch_add hands back the value from before — so this is a reliable source of off-by-one bugs in code ported from either. There is no principle to derive it from; it simply is what the API does.

counter_111_8.go
// Illustrative snippet — not a complete program
var counter atomic.Int64  // starts at 0
result := counter.Add(5)  // result is 5, the value AFTER the add
result = counter.Add(3)   // result is 8, likewise

If you need the value before this goroutine’s operation:

after_111.go
// Illustrative snippet — not a complete program
after := counter.Add(delta)
before := after - delta  // valid only for THIS goroutine's Add

⚠️ This doesn’t give you the “previous” value if other goroutines are concurrently modifying the counter—it gives you the value immediately before your Add in the atomic sequence.


Test Your Understanding: Add

Question: This metrics code occasionally shows errors > requests. Why?

metrics_111_2.go
// Illustrative snippet — not a complete program
type Metrics struct {
    requests atomic.Int64
    errors   atomic.Int64
}

func (m *Metrics) RecordError() {
    m.errors.Add(1)
    m.requests.Add(1)  // Count as request too
}

func (m *Metrics) Stats() (requests, errors int64) {
    return m.requests.Load(), m.errors.Load()
}
Why can a reader see more errors than requests?

Race between RecordError and Stats:

Terminal
Time Goroutine 1 (RecordError) Goroutine 2 (Stats)
T0 errors.Add(1) → 10
T1 requests.Load() = 9
T2 errors.Load() = 10
T3 requests.Add(1) → 10
Result: requests=9, errors=10 — impossible state!

Each individual operation is atomic, but the sequence is not. Between the two Add calls, another goroutine reads an inconsistent snapshot.

Fix 1: Order both sides—and it takes both.

Reversing the writer is the obvious half:

metrics_111_3.go
// Illustrative snippet — not a complete program
func (m *Metrics) RecordError() {
    m.requests.Add(1)  // increment the larger counter first
    m.errors.Add(1)    // then the one it must never fall below
}

That alone does not hold the invariant, and it is worth seeing why, because the same trap reappears in §11.4. Stats still loads requests and then errors. A reader can load requests at 9, get preempted, and by the time it loads errors a whole RecordError has run—so it reads 10. Ordering the writer moved the window; it did not close it.

The reader has to be the writer’s mirror:

metrics_111_4.go
// Illustrative snippet — not a complete program
func (m *Metrics) Stats() (requests, errors int64) {
    errors = m.errors.Load()      // read the smaller counter first
    requests = m.requests.Load()  // then the one never behind it
    return requests, errors
}

Now the reasoning closes. errors is read first, so whatever it returns, every one of those errors was preceded—in program order, in the goroutine that recorded it—by its own requests.Add. requests is read afterwards and only grows, so it cannot come back smaller.

The rule, and it is the transferable part: the writer updates in one direction, the reader reads in the other. Writer ascending, reader descending.

Measured with the writer fixed and the reader left alone, 400,000 paired reads against a goroutine recording errors in a tight loop produced errors > requests on 20, 3, 4, 0 and 19 occasions across five runs. With both sides ordered: zero, every run.

Note what this buys and what it does not. errors ≤ requests becomes an invariant a reader can rely on. The pair still is not a snapshot: a reader can catch a request that has been counted before its error has. If you need both to move together, that is Fix 2.

Fix 2: Use mutex for multi-field coordination, or store both values in an immutable struct behind atomic.Pointer[T].

Key insight: Individual atomic operations don’t compose into atomic compound operations. For invariants across multiple values, use a mutex or pointer to immutable struct.


Swap: Atomic Exchange

Swap atomically stores a new value and returns the old value.

flag_111.go
// Illustrative snippet — not a complete program
var flag atomic.Bool
var counter atomic.Int64

wasSet := flag.Swap(true)     // Returns previous value
oldCount := counter.Swap(0)   // Reset and get previous count

Use Swap when: You need to replace a value and know what was replaced.

metrics_111_5.go
// Illustrative snippet — not a complete program
// Pattern: Drain and reset
func (m *Metrics) DrainRequests() int64 {
    return m.pendingRequests.Swap(0)  // Get count, reset to zero
}

// Pattern: Claim ownership (only first caller proceeds)
func (s *Server) Shutdown() {
    if s.running.Swap(false) {
        // Was true, now false—we're responsible for cleanup
        s.cleanup()
    }
    // Was already false—another goroutine handled it
}
ATOMIC SWAP OPERATION

A counter holding 42. Calling Swap with 100 leaves the counter at 100 and returns 42, the previous value. Both halves happen as one indivisible operation, so no goroutine can observe the counter between the read and the write.


CompareAndSwap (CAS): Conditional Update

CompareAndSwap is the most powerful atomic operation. It atomically:

  1. Compares the current value to an expected old value
  2. If they match, replaces with new and returns true
  3. If they don’t match, leaves unchanged and returns false
swapped_111.go
// Illustrative snippet — not a complete program
var state atomic.Int64
state.Store(10)

swapped := state.CompareAndSwap(10, 20)  // Expects 10, sets to 20
fmt.Println(swapped)        // true
fmt.Println(state.Load())   // 20

swapped = state.CompareAndSwap(10, 30)   // Expects 10, but value is 20
fmt.Println(swapped)        // false (no change)
fmt.Println(state.Load())   // 20
COMPARE-AND-SWAP OPERATION

A decision tree for CompareAndSwap. It tests whether the current value equals the expected old value. If yes, the value becomes the new one and the call returns true; if no, nothing changes and the call returns false. The comparison and the assignment together are one atomic operation.

Mental Model: Optimistic Concurrency

CAS is like optimistic concurrency control in databases: assume no conflict, compute your update, then verify nothing changed before committing. If verification fails, retry with fresh data.

The CAS Loop Pattern

Most CAS usage follows a retry loop:

value_111.go
// Illustrative snippet — not a complete program
for {
    old := value.Load()            // 1. read the current value
    next := computeNext(old)       // 2. compute the one you want
    if value.CompareAndSwap(old, next) {
        break                      // 3. success
    }
    // Someone else won. Loop back to step 1 -- and note that it
    // really is step 1: you must re-Load, not retry with the stale
    // `old`, or the CAS will fail forever.
}

Example: Lock-free maximum tracking

update_max_111.go
// Illustrative snippet — not a complete program
func updateMax(target *atomic.Int64, value int64) {
    for {
        current := target.Load()
        if value <= current {
            return  // Current max already >= value
        }
        if target.CompareAndSwap(current, value) {
            return  // Successfully updated
        }
        // CAS failed: another goroutine changed it; retry
    }
}
CAS Loop Termination and Fairness

CAS loops guarantee system-wide progress (lock-free property):

  1. If this goroutine’s CAS succeeds → we exit
  2. If this goroutine’s CAS fails → another goroutine succeeded
  3. At least one goroutine always makes progress

However, individual goroutines are not guaranteed to succeed under high contention. A single goroutine might retry many times (or theoretically indefinitely) if constantly preempted by others. A mutex bounds that wait: §9.2 describes Go’s two modes, where a waiter that has been queued for roughly a millisecond flips the mutex into starvation mode and is handed the lock directly. A CAS loop has no such backstop.

When contention is sustained, mutexes may be more appropriate than CAS loops for this reason.

When CAS Loops Become Slower Than Mutexes

Under high contention (many goroutines competing for the same atomic):

  • CAS loops retry repeatedly → wasted CPU cycles
  • Mutexes queue waiters fairly → better throughput under contention

There is no profiler for this. runtime/pprof has no CAS-retry profile, so if you want the number you have to count it yourself—an atomic.Int64 incremented on each failed attempt, read in the same place you read the metric the loop maintains. What you can profile is the alternative: the mutex profile (§9.4, runtime.SetMutexProfileFraction) tells you what the lock was costing before you replaced it, which is the honest way to decide whether replacing it helped.

Always Handle CAS Failure

CompareAndSwap returns a bool that the compiler will happily let you discard, and discarding it means the update silently did not happen. Every call needs either a check or a loop around it.

snippet_111_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the return value is the only report of failure
counter.CompareAndSwap(10, 11)

// ✓ CORRECT: act on it
if !counter.CompareAndSwap(10, 11) {
    // retry, log, or return an error -- but decide
}
Common Mistake: Loading Twice

Calling Load twice inside one CompareAndSwap reads the variable at two different instants, so the value you compare against is not the value you derived the update from.

snippet_111_x_3.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: two separate reads, two different instants
counter.CompareAndSwap(counter.Load(), counter.Load()+1)
//                     ^^^^ first      ^^^^ second, possibly newer

Between the two loads another goroutine can change the value. The CAS then compares against a value that is already stale and fails — or, worse, succeeds against a number that briefly came back around.

Correct: load once, into a variable.

counter_111_9.go
// Illustrative snippet — not a complete program
old := counter.Load()
counter.CompareAndSwap(old, old+1)

Even better for simple increments:

snippet_111_4.go
// Illustrative snippet — not a complete program
counter.Add(1)  // Atomic, no CAS needed

Test Your Understanding: CompareAndSwap

Question: Why doesn’t this increment work correctly?

counter_111_10.go
// Illustrative snippet — not a complete program
var counter atomic.Int64

// Attempt to increment
counter.CompareAndSwap(counter.Load(), counter.Load()+1)
Why does this CAS fail, and what should it be instead?

Problem: The two Load() calls are separate operations:

snippet_111_5.go
// Illustrative snippet — not a complete program
counter.CompareAndSwap(counter.Load(), counter.Load()+1)
                       ^^^^^^^^^^^^1    ^^^^^^^^^^^^2

Between Load 1 and Load 2, another goroutine can modify counter:

Terminal
Time Counter This Goroutine Other Goroutine
T0 10
T1 10 Load() → 10 (first call)
T2 11 Add(1) → 11
T3 11 Load() → 11 (second call)
T4 11 CAS(10, 12) → fails! (current is 11, not 10)

Fix: Store the loaded value in a variable:

counter_111_11.go
// Illustrative snippet — not a complete program
for {
    old := counter.Load()
    if counter.CompareAndSwap(old, old+1) {
        break
    }
}

Better fix: Use Add for simple increments:

snippet_111_6.go
// Illustrative snippet — not a complete program
counter.Add(1)  // Simpler, faster, correct

And and Or: Atomic Bit Manipulation

Added in Go 1.23, And and Or apply a bitmask atomically and return the value before the operation:

flags_111.go
// Illustrative snippet — not a complete program
var flags atomic.Uint32

// Type the constants. An untyped `1 << 1` is fine going in, but the
// complement below is not: ^flagDraining on an untyped constant is
// -3, which does not fit a uint32 and will not compile.
const (
    flagReady    uint32 = 1 << 0
    flagDraining uint32 = 1 << 1
    flagFailed   uint32 = 1 << 2
)

old := flags.Or(flagReady)       // set a bit, learn whether it was set
was := flags.And(^flagDraining)  // clear a bit, learn its old state

Or sets bits; And with a complemented mask clears them. ^flagDraining is every bit except that one, so ANDing with it leaves the rest untouched and turns that one off. It is the standard idiom, and typing the constants is what makes it compile: on an untyped constant ^ produces a negative number, and uint32 has nowhere to put it.

Before Go 1.23 this needed a CAS loop, which is the version you will still see in most code:

flags_111_2.go
// Illustrative snippet — not a complete program
// The pre-1.23 spelling. Correct, but three lines and a loop for
// something the CPU does in one instruction.
for {
    old := flags.Load()
    if flags.CompareAndSwap(old, old|flagReady) {
        break
    }
}

Where this earns its place: a set of independent boolean flags that several goroutines toggle. Four atomic.Bool fields cost four cache lines' worth of contention (§11.3.7) and cannot be read as a consistent set. One atomic.Uint32 of bit flags is a single word — one Load gives you all of them at an instant that really existed.

conn_111.go
// Illustrative snippet — not a complete program
// A connection's state as a bitset rather than four flags.
type Conn struct {
    flags atomic.Uint32
}

func (c *Conn) MarkDraining() {
    c.flags.Or(flagDraining)
}

func (c *Conn) Snapshot() (ready, draining, failed bool) {
    f := c.flags.Load()   // one load, one coherent view
    return f&flagReady != 0, f&flagDraining != 0, f&flagFailed != 0
}

That last method is the interesting one, and it is the exception that proves §11.3.1's rule. Atomics normally cannot give you a consistent view of several values — but these are not several values. They are one word that you have chosen to read in pieces, so the snapshot is genuinely atomic. Pack related flags into one integer and the multi-field problem stops existing.

And and Or Return the OLD Value

Add returns the new value; And, Or and Swap return the old one.

f_111.go
// Illustrative snippet — not a complete program
var f atomic.Uint32
f.Store(0b0001)
prev := f.Or(0b0010)   // prev == 0b0001, f is now 0b0011

The old value is what makes these useful for claiming: if old&flagReady == 0 tells you this call was the one that set the bit, exactly the way Swap(true) identifies the first caller in §11.4.1.


The ABA Problem (Advanced)

A subtle pitfall with pointer-based CAS: if a value changes from A→B→A between your Load and CAS, the CAS succeeds even though the pointer was modified.

Terminal
Time This Goroutine Other Goroutines
T0 old := ptr.Load() → A
T1 ptr.Store(B)
T2 ptr.Store(A) // Back to A!
T3 ptr.CAS(A, new) → true! // But A is "different" now

This can cause issues in lock-free data structures where the pointer’s identity matters, not just its value.

For most Go application code, ABA is not a concern, and the reason is the garbage collector rather than good luck. In C, ABA usually arrives through the allocator: you free a node, the allocator hands the same address straight back out, and a stale pointer now compares equal to a completely different object. Go cannot do that. While any goroutine still holds a reference to the old node, the address cannot be recycled — so a pointer that compares equal really does refer to the same object.

That removes the classic version of the problem. It does not remove ABA where the value rather than the address carries the meaning: a counter that wraps back to a number you were watching for, or a version field that laps you. If you are building a lock-free structure where identity matters, pair the pointer with a generation counter (§11.4.9) — or use a mutex, which is the right answer more often than the literature’s enthusiasm for lock-free structures suggests.


11.1.4 Operation Selection Guide

OPERATION SELECTION GUIDE

A branching guide from intent to operation. Reading a value calls Load; setting one calls Store; incrementing or decrementing an integer calls Add; replacing a value while needing the previous one calls Swap; and updating conditionally on the current value calls CompareAndSwap inside a retry loop, as updateMax does.

11.1.5 Operation Summary

Operation
Load()
Store(v)
Add(d)
Swap(v)
CompareAndSwap(o,n)

Method availability by type:

METHOD AVAILABILITY BY TYPE

A table of atomic types against operations. Int32 and Int64, Uint32 and Uint64, and Uintptr support all seven operations. Bool, Pointer and Value support Load, Store, Swap and CompareAndSwap only, because arithmetic and bitwise operations exist only where arithmetic means something. And and Or require Go 1.23 or later.


11.1.6 atomic.Pointer[T]: Type-Safe Pointers

Go 1.19 introduced atomic.Pointer[T], providing type-safe atomic pointer operations.

config_111.go
// Illustrative snippet — not a complete program
type Config struct {
    Timeout  time.Duration
    MaxConns int
}

var globalConfig atomic.Pointer[Config]

func init() {
    globalConfig.Store(&Config{
        Timeout:  30 * time.Second,
        MaxConns: 100,
    })
}

func GetConfig() *Config {
    return globalConfig.Load()  // Returns *Config directly
}

func UpdateConfig(cfg *Config) {
    globalConfig.Store(cfg)
}
atomic.Pointer[T] vs atomic.Value:
Aspect
Type safety
Nil handling
Type assertion
Use for
CAS on Pointers Compares Addresses, Not Contents

CompareAndSwap on an atomic.Pointer[T] asks whether the stored pointer is the same pointer. Two structs with identical fields are two different addresses, and CAS will tell them apart even though == on the values they point at would not.

a_111_x_1.go
// Illustrative snippet — not a complete program
a := &Config{Port: 8080}
b := &Config{Port: 8080}  // same contents, different address

config.Store(a)
config.CompareAndSwap(b, next)  // ✗ fails: b is not a
config.CompareAndSwap(a, next)  // ✓ works:  a is a

So the expected value always has to come from a Load, never from a struct you built yourself:

config_111_2.go
// Illustrative snippet — not a complete program
for {
    old := config.Load()      // the pointer actually stored
    next := &Config{...}
    if config.CompareAndSwap(old, next) {
        break
    }
}
Pointer Atomicity Is Not Content Atomicity

atomic.Pointer[T] makes the pointer atomic. It says nothing at all about the struct on the other end of it, which every reader that has called Load is holding a live reference to.

cfg_111.go
// Illustrative snippet — not a complete program
cfg := globalConfig.Load()
cfg.Timeout = 5 * time.Second  // DATA RACE with every current reader

The fix is to publish a new value rather than edit the old one — copy-on-write:

update_timeout_111.go
// Illustrative snippet — not a complete program
func UpdateTimeout(newTimeout time.Duration) {
    for {
        old := globalConfig.Load()
        next := &Config{
            Timeout:  newTimeout,
            MaxConns: old.MaxConns,
        }
        if globalConfig.CompareAndSwap(old, next) {
            return
        }
    }
}

11.1.7 atomic.Value: Arbitrary Types

atomic.Value stores and loads values of any type. It predates generics and remains useful for non-pointer types.

m_111.go
// Illustrative snippet — not a complete program
var cache atomic.Value

// Store a map (non-pointer type)
cache.Store(map[string]int{"a": 1, "b": 2})

// Load requires type assertion
m := cache.Load().(map[string]int)

Critical constraints:

ATOMIC.VALUE RULES

Three rules for atomic.Value. First, every Store must use the same concrete type: storing a string and then an integer panics. Second, storing an untyped nil panics, though a typed nil pointer is accepted. Third, a Load before any Store returns nil rather than panicking.

Nil Handling Note

atomic.Value panics on Store(nil) but accepts typed nil pointers like Store((*Config)(nil)). However, this creates confusing semantics—Load() returns an any containing a typed nil, which behaves unexpectedly in type assertions.

For pointer types that may be nil, use atomic.Pointer[T] which handles nil naturally.

Safe loading pattern:

get_config_111.go
// Illustrative snippet — not a complete program
func GetConfig() *Config {
    v := configValue.Load()
    if v == nil {
        return nil  // Not yet initialized
    }
    return v.(*Config)
}
Prefer atomic.Pointer[T]

For pointer types, always use atomic.Pointer[T]. Use atomic.Value only for non-pointer types (maps, slices, structs by value) or pre-Go 1.19 compatibility.


11.1.8 Memory Ordering Guarantees

Atomic operations provide more than indivisibility—they create happens-before relationships that synchronize memory across goroutines.

The Go memory model states it in two sentences:

What the Memory Model Actually Says

Two sentences, quoted from the specification rather than paraphrased:

If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B.

All the atomic operations executed in a program behave as though executed in some sequentially consistent order.

The first sentence is the one you use daily: all writes before the atomic Store are visible after an atomic Load that observes it.

The second is stronger than it looks, and §11.4 depends on it. Every atomic operation in the whole program—not just the ones touching a single variable—falls into one agreed order. That is what lets two atomics be read in a deliberate sequence and have the result mean something.

data_111.go
// Illustrative snippet — not a complete program
var ready atomic.Bool
var data string

// Goroutine A
data = "hello"        // Regular write
ready.Store(true)     // Atomic Store (release)

// Goroutine B
if ready.Load() {     // Atomic Load (acquire)
    fmt.Println(data) // Guaranteed to see "hello"
}
ATOMIC HAPPENS-BEFORE GUARANTEE

Goroutine A writes two ordinary variables and then calls Store on an atomic boolean. Goroutine B calls Load on the same boolean. When B’s Load observes the stored value, the store synchronizes with it, and both of A’s earlier ordinary writes are guaranteed visible to B.

Connection to Chapter 8

This is the happens-before relationship from §8.4. Atomic operations create synchronization points just like channel operations and mutex Lock/Unlock pairs. The key guarantee: if goroutine B observes a value from goroutine A’s atomic Store, then B also sees all of A’s writes that preceded that Store.

data_111_x_2.go
// Illustrative snippet — not a complete program
// ✗ WRONG: No synchronization (data race)
var data string
var ready bool  // Non-atomic!

// Writer
data = "hello"
ready = true  // Non-atomic write

// Reader
if ready {    // Non-atomic read
    // May see empty string! No happens-before guarantee
    fmt.Println(data)
}

11.1.9 The Legacy Function-Based API

Before Go 1.19, all atomic operations used package-level functions:

counter_111_12.go
// Illustrative snippet — not a complete program
var counter int64

atomic.AddInt64(&counter, 1)
value := atomic.LoadInt64(&counter)
atomic.StoreInt64(&counter, 0)
swapped := atomic.CompareAndSwapInt64(&counter, 0, 1)

Why avoid the legacy API:

Problem 1: Alignment on 32-bit systems

On 32-bit architectures, 64-bit atomic operations require 8-byte alignment. The function-based API doesn’t enforce this:

stats_111_x_1.go
// Illustrative snippet — not a complete program
// ✗ May CRASH on 32-bit systems
type Stats struct {
    flag    bool    // 1 byte
    counter int64   // May not be 8-byte aligned!
}

func (s *Stats) Increment() {
    atomic.AddInt64(&s.counter, 1)
    // PANIC on 32-bit ARM: "panic: unaligned 64-bit atomic operation"
}

// ✓ CORRECT: Typed atomics handle alignment
type Stats struct {
    flag    bool
    counter atomic.Int64  // Always properly aligned
}

Problem 2: Easy to forget atomic access

bad_111.go
// Illustrative snippet — not a complete program
var counter int64  // Is this atomic? Who knows!

func bad() {
    counter++  // Oops: should have used atomic.AddInt64
}

With typed atomics, non-atomic access is impossible:

good_111.go
// Illustrative snippet — not a complete program
var counter atomic.Int64  // Obviously atomic

func good() {
    counter.Add(1)  // The only way to modify it
}

11.1.10 Common Mistakes

Mixing atomic and non-atomic access
✗
Problem

Data race on the non-atomic access

✓
Fix

Use typed atomics; all access via methods

Copying atomic values
✗
Problem

Copy loses atomic semantics

✓
Fix

Pass by pointer; never copy

Mutating data after storing pointer
✗
Problem

Data race on pointed-to fields

✓
Fix

Copy-on-write: create new, then Store

Assuming multi-field atomicity
✗
Problem

Fields updated separately; inconsistent view

✓
Fix

Use mutex or pointer to immutable struct

Ignoring CAS failure
✗
Problem

Lost updates, incorrect state

✓
Fix

Always check result; use retry loop

Using atomic.Value with varying types
✗
Problem

Panic at runtime

✓
Fix

All Stores must use same concrete type

Storing nil in atomic.Value
✗
Problem

Panic

✓
Fix

Store typed nil: (*T)(nil), or use atomic.Pointer[T]

Legacy API on 32-bit systems
✗
Problem

Crash from misalignment

✓
Fix

Use typed atomics

Double Load in CAS
✗
Problem

Stale expected value

✓
Fix

Store Load result in variable

Never Copy Atomic Values

increment_111_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Copying loses atomicity
var counter atomic.Int64
counter.Store(42)

counterCopy := counter      // Copies the struct
counterCopy.Add(1)          // Operates on COPY, not original
fmt.Println(counter.Load()) // Still 42!

// ✓ CORRECT: Pass by pointer
func increment(c *atomic.Int64) {
    c.Add(1)
}

Never Pass Atomic by Value

counter_111_x_13.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Function receives a copy
func incrementByValue(c atomic.Int64) {
    c.Add(1)  // Modifies the COPY, not the original
}

var count atomic.Int64
count.Store(10)
incrementByValue(count)
fmt.Println(count.Load())  // Still 10!

// ✓ CORRECT: Pass pointer to struct containing atomic
type Counter struct {
    value atomic.Int64
}

func (c *Counter) Increment() {
    c.value.Add(1)  // Modifies the actual atomic
}

Summary: The sync/atomic Package

Type
atomic.Int32/64
atomic.Uint32/64
atomic.Bool
atomic.Pointer[T]
atomic.Value
Connection to Previous Chapters
  • Chapter 8: Atomics eliminate data races for single-value operations
  • Chapter 9: Atomics are the lock-free alternative to mutexes for simple cases
  • Chapter 10: Atomics cannot produce a lock-ordering deadlock—there is no lock to hold while waiting for another. They trade it for livelock and starvation, and a goroutine polling a flag that nobody ever sets is just as stuck as one in §10.3's AB-BA cycle.

Self-Check Questions: The sync/atomic Package

1. What’s wrong with this code?

metrics_111_6.go
// Illustrative snippet — not a complete program
type Metrics struct {
    requests int64
}

func (m *Metrics) Inc() {
    atomic.AddInt64(&m.requests, 1)
}

func (m *Metrics) Get() int64 {
    return m.requests  // Direct read
}
Where is the data race, and how do you close it?

Data race. Inc() uses atomic access, but Get() reads m.requests directly. One goroutine may be in AddInt64 while another does a direct read.

Fix: Use atomic for both operations:

metrics_111_7.go
// Illustrative snippet — not a complete program
func (m *Metrics) Get() int64 {
    return atomic.LoadInt64(&m.requests)
}

Better: Use typed atomics:

metrics_111_8.go
// Illustrative snippet — not a complete program
type Metrics struct {
    requests atomic.Int64
}

func (m *Metrics) Inc() { m.requests.Add(1) }
func (m *Metrics) Get() int64 { return m.requests.Load() }

2. This counter is sometimes off by one. Why?

increment_111_2.go
// Illustrative snippet — not a complete program
func Increment() int64 {
    counter.Add(1)
    return counter.Load()
}
Why is the returned count wrong?

Between Add and Load, another goroutine may call Add, so Load returns a value different from what this goroutine just added.

Fix: Use the return value of Add:

increment_111_3.go
// Illustrative snippet — not a complete program
func Increment() int64 {
    return counter.Add(1)  // Returns the new value atomically
}

3. Why does this panic?

init_111.go
// Illustrative snippet — not a complete program
var cache atomic.Value

func Init() {
    cache.Store(map[string]int{})
}

func Reset() {
    cache.Store([]string{})
}
Which rule of atomic.Value does this break?

atomic.Value requires all Stores to use the same concrete type. Init stores map[string]int; Reset stores []string. The second Store panics.

Fix: Use consistent types, or use separate atomic.Value variables.

4. What does Swap return on a zero-value atomic?

flag_111_2.go
// Illustrative snippet — not a complete program
var flag atomic.Bool
result := flag.Swap(true)
What does result hold, and what is that useful for?

false (the zero value of bool).

Swap atomically stores the new value and returns the old value. Since flag was never set, its value is false, so Swap(true) returns false and sets the value to true.

This property enables initialization patterns:

snippet_111_7.go
// Illustrative snippet — not a complete program
if !flag.Swap(true) {
    // First caller wins—perform initialization
}

5. Why does this CAS always fail?

config_111_3.go
// Illustrative snippet — not a complete program
config.Store(&Config{Port: 8080})

expected := &Config{Port: 8080}  // Same contents
newCfg := &Config{Port: 9090}
success := config.CompareAndSwap(expected, newCfg)
Why does CompareAndSwap never succeed here?

Pointer CAS compares addresses, not contents.

expected is a newly created pointer with the same contents as the stored config, but a different address. CAS compares expected == stored, which is false because they’re different pointers.

Fix: Load the actual pointer first:

config_111_4.go
// Illustrative snippet — not a complete program
config.Store(&Config{Port: 8080})

old := config.Load()  // Get the actual stored pointer
newCfg := &Config{Port: 9090}
success := config.CompareAndSwap(old, newCfg)  // Now works!

Key Takeaways

  • Atomic operations execute as indivisible CPU instructions—no other goroutine can observe a partial state.
  • Always use typed atomics (Go 1.19+) for new code: atomic.Int64, atomic.Bool, atomic.Pointer[T].
  • Load and Store are the foundation—use them for all reads and writes to atomic variables.
  • Add returns the NEW value, not the old. This differs from C/C++ fetch_add.
  • Swap returns the OLD value after storing the new one—useful for read-and-reset patterns.
  • CompareAndSwap enables conditional updates—the building block for lock-free algorithms.
  • CAS fails for exactly one reason—the value was not what you expected, because another goroutine got there first. Go’s CAS is strong: it never fails spuriously.
  • atomic.Pointer[T] protects the pointer, not the pointed-to data—treat loaded data as immutable.
  • atomic.Value stores arbitrary types but requires type assertions and has stricter rules.
  • And and Or (Go 1.23) apply a bitmask and return the OLD value—so a set of related flags can live in one word and be read as one coherent snapshot.
  • Every access must go through the package—mixing an atomic write with a plain read is a data race, and copying an atomic silently detaches the copy. go vet catches the copy; nothing catches the mixed access but -race.
  • Memory ordering guarantee: a Store happens-before any Load that observes the stored value, and all atomic operations in the program share one sequentially consistent order.

Next: §11.2 examines when atomics are the right choice—the specific scenarios where they provide real benefits over mutexes.

11.2 When to Use Atomics

§11.1 taught you how atomics work. This section answers the more important question: when should you reach for them?

The short answer: atomics excel at simple, high-frequency operations on single values. The longer answer requires a decision framework.


The Three Questions

Before reaching for atomics, answer these three questions:

THE ATOMIC DECISION FRAMEWORK

Three questions to answer before choosing an atomic. Is it a single value, or several that must stay consistent? Is the operation simple, or does it involve conditionals and multiple steps? Is this path actually hot? Any no points at a mutex.

Rule of thumb: Start with a mutex. Switch to atomics only when all three questions point to atomics.

ATOMIC SWEET SPOT

The four conditions under which atomics are the right tool, listed together: a single value, a simple operation such as read, write, increment or swap, high frequency, and independence from any other state. Anything outside those four points at a mutex or a channel.

Default to Simplicity

When in doubt, use a mutex. Atomics are an optimization—reach for them when:

  1. You have a proven performance bottleneck (profiling shows it)
  2. The state is genuinely simple (single value, no relationships)
  3. The operations are trivial (increment, load, store, swap)

This section presents the scenarios where atomics provide clear advantages, with production-ready patterns for each.


11.2.1 Shutdown Flags and State Signals

The simplest and most common atomic use case: boolean flags that signal state changes across goroutines.

Graceful Shutdown

server_112.go
// Illustrative snippet — not a complete program
type Server struct {
    shutdown atomic.Bool
    wg       sync.WaitGroup
}

func (s *Server) Shutdown() {
    s.shutdown.Store(true)  // Signal all workers
    s.wg.Wait()             // Wait for completion
}

func (s *Server) worker(id int) {
    defer s.wg.Done()

    for !s.shutdown.Load() {  // Lock-free check on every iteration
        s.doWork()  // If this blocks long, shutdown check is delayed
    }
    log.Printf("Worker %d exiting", id)
}
Why atomics work here
Single value
✓ One boolean
Simple operation
✓ Load/Store only
No cross-value invariants
✓ Flag is independent
High frequency
✓ Checked every iteration
Comparison with channel-based shutdown:
Aspect
Size
Check cost
Signal cost
Blocking wait
Broadcast
Reusable
Measured go1.26.1, darwin/amd64, Intel i7-10700K, uncontended, minimum of eight passes. Store costs about four and a half times what Load does, which is worth noticing — §11.1.3 explains why.

The size figures are exact rather than approximate: unsafe.Sizeof reports 4 bytes for atomic.Bool and 96 for the runtime’s hchan.

Channels vs Atomic Flags vs Context
  • Use channels when you need blocking notification (wait for shutdown)
  • Use atomic flags for hot polling loops (check shutdown every iteration)
  • Use context.Context when:
  • Workers block on I/O or other channels (won’t check flag promptly)
  • You need cascading cancellation across goroutine trees
  • Deadline/timeout semantics are needed

For most production code, prefer context—it composes better (see Chapter 13). Use atomic flags when you have tight loops that can’t afford select overhead.

State Machine with CAS

CompareAndSwap expresses a state transition directly: move from exactly this state to that one, or tell me you couldn’t.

conn_state_112.go
// Illustrative snippet — not a complete program
// A named type over uint32 keeps the states readable; the atomic
// holds the underlying value, so transitions convert at the call.
// §11.4.6 builds the whole machine this way.
type ConnState uint32

const (
    StateDisconnected ConnState = iota
    StateConnecting
    StateConnected
)

// Only one concurrent caller can win this transition; the rest get
// the error and know they did not start the connection attempt.
if !c.state.CompareAndSwap(
    uint32(StateDisconnected), uint32(StateConnecting)) {
    return errors.New("invalid state for connect")
}

That works as long as the transition really is just a state change. §11.4.6 builds the full machine, and §11.3.4 covers the boundary: the moment a transition also has to close a socket or notify listeners, the state and the side effect must move together, and only a lock can do that.

Feature Toggles

Runtime-toggleable features without restart:

feature_flags_112.go
// Illustrative snippet — not a complete program
type FeatureFlags struct {
    debugMode      atomic.Bool
    newAlgorithm   atomic.Bool
    rateLimiting   atomic.Bool
}

func ProcessRequest(req *Request) *Response {
    if features.debugMode.Load() {
        log.Printf("Processing request: %+v", req)
    }

    if features.newAlgorithm.Load() {
        return processWithNewAlgorithm(req)
    }
    return processWithOldAlgorithm(req)
}

// Toggle returns the new value
func (f *FeatureFlags) ToggleDebugMode() bool {
    for {
        old := f.debugMode.Load()
        next := !old
        if f.debugMode.CompareAndSwap(old, next) {
            return next  // report what we actually stored
        }
        // Another goroutine toggled; retry
    }
}
FLAG USE CASES

Five boolean flag patterns and how each is written: a shutdown flag and an initialized flag are each set to true once, while feature toggles, circuit breakers and rate-limit flags are set both ways. All five share the same shape of one writer and many readers.


11.2.2 Counters and Metrics

Metrics are the canonical atomic use case. Every request, every error, every byte—these high-frequency operations benefit from lock-free updates.

server_metrics_112.go
// Illustrative snippet — not a complete program
type ServerMetrics struct {
    requestsTotal   atomic.Int64
    requestsActive  atomic.Int64
    bytesReceived   atomic.Int64
    bytesSent       atomic.Int64
    errorsTotal     atomic.Int64
}

func (m *ServerMetrics) HandleRequest(req *Request) {
    m.requestsActive.Add(1)
    defer m.requestsActive.Add(-1)

    m.requestsTotal.Add(1)
    m.bytesReceived.Add(int64(req.Size))

    resp, err := processRequest(req)
    if err != nil {
        m.errorsTotal.Add(1)
        return
    }

    m.bytesSent.Add(int64(resp.Size))
}

Why atomics win here:

  1. Independence: Each counter is modified independently—no invariants span multiple fields
  2. Simplicity: Just incrementing or adding—no complex logic
  3. Frequency: Metrics are updated on every request (potentially millions per second)
  4. Read pattern: Metrics are written frequently, read occasionally (for dashboards)

The same method written both ways:

metrics_112.go
// Illustrative snippet — not a complete program
// Mutex: four steps, one of which can block.
func (m *Metrics) Inc() {
    m.mu.Lock()
    m.count++
    m.mu.Unlock()
}
metrics_112_2.go
// Illustrative snippet — not a complete program
// Atomic: one instruction, and it never blocks.
func (m *Metrics) Inc() {
    m.count.Add(1)
}
Per call
Work done
Can it block?
Uncontended
16 goroutines
Measured go1.26.1, darwin/amd64 — the figures from §11.2.7.
Performance Numbers Are Approximate

Every figure in this section came from the benchmark run described in §11.2.7 — go1.26.1, darwin/amd64, one machine, one afternoon. They will not be your numbers. Architecture moves them (arm64 differs), CPU generation moves them, and Go 1.24's mutex rewrite moved them by a factor of two on its own. What survives the move is the shape: a read is far cheaper than a lock and scales, a read-modify-write is about twice as cheap and does not.

When Counters Need Mutexes

Not all counting benefits from atomics. Use a mutex when counters have invariants:

stats_112.go
// Illustrative snippet — not a complete program
// Multiple related counters with invariants
type Stats struct {
    mu       sync.Mutex
    success  int64
    failure  int64
    total    int64  // Invariant: total == success + failure
}

func (s *Stats) RecordSuccess() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.success++
    s.total++  // must move together, or the invariant is briefly false
}

func (s *Stats) RecordFailure() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.failure++
    s.total++
}

func (s *Stats) Snapshot() (success, failure, total int64) {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.success, s.failure, s.total  // Consistent snapshot
}
Decision Rule: Counters
  • Independent counters → Use atomics
  • Counters with invariants across them → Use mutex or atomic.Pointer to immutable struct

Periodic Metric Collection with Swap

Monitoring systems scrape on an interval, and the counter has to be read and cleared as one step or increments land in the gap:

interval_metrics_112.go
// Illustrative snippet — not a complete program
// Swap is the whole point: Load followed by Store(0) would lose
// every increment that arrived between the two.
func (m *IntervalMetrics) Collect() (requests, errors int64) {
    return m.requests.Swap(0), m.errors.Swap(0)
}

Each Swap is atomic; the pair is not, so a scrape can attribute one interval’s last request to the next interval’s error count. For a dashboard that skew is invisible. For anything you bill on, it is not — §11.3.2. §11.4.3 has the full collector.

Min/Max Tracking

There is no atomic “store if greater”, so a high-water mark needs a CAS loop:

update_max_112.go
// Illustrative snippet — not a complete program
func updateMax(target *atomic.Int64, value int64) {
    for {
        current := target.Load()
        if value <= current {
            return              // not a new maximum; nothing to do
        }
        if target.CompareAndSwap(current, value) {
            return
        }
        // another goroutine raised it; re-read and reconsider
    }
}

Note the early return: this loop does not always retry. If the value it is holding is no longer a candidate, it stops. §11.4.2 defines updateMax and updateMin once for reuse, and §11.4.3 builds a latency tracker on them.

ID Generator

Add returns the new value, so a counter is a unique-ID source with no coordination at all:

idgenerator_112.go
// Illustrative snippet — not a complete program
type IDGenerator struct{ next atomic.Uint64 }

func (g *IDGenerator) Next() uint64 { return g.next.Add(1) }

Two goroutines cannot receive the same number, because the increment and the read are one operation. §11.4.2 adds block reservation for batch inserts.

11.2.3 Single-Value State

Atomics work well for any single value that represents independent state.

Timestamps

activity_112.go
// Illustrative snippet — not a complete program
type Activity struct {
    lastSeen atomic.Int64  // Unix nano timestamp
}

func (a *Activity) Touch() {
    a.lastSeen.Store(time.Now().UnixNano())
}

func (a *Activity) LastSeen() time.Time {
    ns := a.lastSeen.Load()
    if ns == 0 {
        return time.Time{}  // Zero time
    }
    return time.Unix(0, ns)
}

func (a *Activity) IdleDuration() time.Duration {
    ns := a.lastSeen.Load()
    if ns == 0 {
        return 0
    }
    return time.Since(time.Unix(0, ns))
}

One-Time Initialization

An atomic flag can publish a value that was built under a lock:

lazy_resource_112.go
// Illustrative snippet — not a complete program
type LazyResource struct {
    ready    atomic.Bool
    mu       sync.Mutex
    resource *ExpensiveResource
}

func (r *LazyResource) Get() *ExpensiveResource {
    if r.ready.Load() {          // fast path: no lock
        return r.resource
    }

    r.mu.Lock()
    defer r.mu.Unlock()
    if r.ready.Load() {          // another goroutine won the race
        return r.resource
    }
    r.resource = createExpensiveResource()
    r.ready.Store(true)          // publishes the write above
    return r.resource
}
Yes, This Is the Double-Checked Locking Chapter 8 Called Broken

§8.4 has a section titled The Double-Checked Locking Anti-Pattern which declares this shape broken. It is worth being precise about why both are right, because the difference is one word.

Chapter 8's version does its first check on a plain pointer: if instance == nil. That read is unsynchronized, so there is no happens-before edge to the write inside the lock. A reader can see a non-nil pointer to a struct whose fields are not yet visible.

This version’s first check is r.ready.Load(), an atomic read. §8.4's seventh happens-before rule does the rest: r.resource = ... precedes ready.Store(true) in program order, and the Load that observes true synchronizes with that Store. Everything written before the store is visible after the load.

So the idiom is not broken in Go; the unsynchronized first check is. Java needs volatile for exactly the same reason, and C++ needs std::atomic. Go gives it to you the moment the flag is an atomic.Bool instead of a bool.

Prefer sync.OnceValue

Since Go 1.21 the whole listing above is one line:

get_resource_112.go
// Illustrative snippet — not a complete program
var getResource = sync.OnceValue(createExpensiveResource)

It carries the same happens-before guarantee (§8.4), and it removes the package-level variable the flag was there to guard — the thing being initialized is no longer reachable except through the guard. sync.OnceFunc does the same for an initializer that returns nothing.

Reach for the explicit flag only when you need something sync.Once cannot do: inspect whether initialization has happened without triggering it, retry after a failure, or reset. §11.4.7 builds each of those.

11.2.4 Configuration Hot-Reload

When configuration is read frequently but updated rarely, atomic.Pointer[T] provides lock-free reads.

config_112.go
// Illustrative snippet — not a complete program
type Config struct {
    Timeout    time.Duration
    MaxRetries int
    Endpoints  []string
    Features   map[string]bool
}

// One pointer, swapped whole. Readers take whatever it points at.
var config atomic.Pointer[Config]

// Called on every request. 1.17 ns, and unlike a read lock it
// scales: nobody writes the line, so every core can hold it.
func Current() *Config { return config.Load() }

// Called on a reload or an admin action, perhaps hourly.
func Publish(cfg *Config) { config.Store(cfg) }

The asymmetry is the whole point: the reader pays almost nothing and the writer pays for a whole new Config. That is a good trade at a thousand reads per write and a bad one at two. §11.4.4 builds this into a ConfigManager with versioning, a Modify helper, and file watching.

ATOMIC CONFIG PATTERN

Request handlers on the left call Load on an atomic pointer many times a second and use the fields of whatever Config they receive. A config updater on the right parses a new file and calls Store, rarely. Readers never block, each one sees a complete Config, and superseded configs are garbage collected once nothing references them.

READ-HEAVY ACCESS PATTERN

A timeline with a dense band of reads against three isolated writes. Below it the measurement: with one reader an RWMutex read lock costs 9.20 nanoseconds against the atomic’s 1.17, about nine times. With sixteen readers the RWMutex degrades to 34.21 because RLock writes to a reader counter, while the atomic’s per-core cost does not move at all.

Illustrative the read and write bands are schematic — they show the shape of the access pattern, not a trace of any particular service. The two rows of figures beneath them are the measurements from §11.2.7.
atomic.Value vs atomic.Pointer[T]
  • Use atomic.Pointer[T] (Go 1.19+) for type-safe pointer atomics
  • Use atomic.Value when:
  • Supporting Go <1.19
  • Storing heterogeneous types (different types at different times)
  • Storing non-pointer values (though typed atomics are better for primitives)

atomic.Pointer[T] is preferred in modern Go for its compile-time type safety.

Copy-on-Write Updates

Updating a field means publishing a whole new Config, never touching the one readers already hold:

config_manager_112.go
// Illustrative snippet — not a complete program
func (cm *ConfigManager) SetTimeout(timeout time.Duration) {
    for {
        old := cm.config.Load()
        updated := *old            // copy the struct
        updated.Timeout = timeout  // change the copy
        if cm.config.CompareAndSwap(old, &updated) {
            return
        }
        // someone else published in the meantime; retry from theirs
    }
}

Two things this does not do, both covered in §11.3.6: it does not deep-copy slices or maps, and it does not protect the Config a reader is already holding. §11.4.4 gives the general Modify form and the deep-copy rules.

11.2.5 Progress Tracking and Coordination

Atomics excel at lightweight coordination where goroutines share simple progress information.

Progress Reporter

A worker pool reporting completion is the same shape: one counter that only grows, read by whatever is drawing the progress bar.

progress_tracker_112.go
// Illustrative snippet — not a complete program
type ProgressTracker struct {
    total     int64          // set in the constructor, then read-only
    completed atomic.Int64
}

func (p *ProgressTracker) Complete(n int64) { p.completed.Add(n) }
func (p *ProgressTracker) Done() int64 { return p.completed.Load() }

total needs no atomic because nothing writes it after the constructor returns — §8.5's immutability strategy, not an oversight. §11.4.5 builds this out with failure counts, an ETA and a reporter goroutine.

Request Limiter with CAS

request_limiter_112.go
// Illustrative snippet — not a complete program
type RequestLimiter struct {
    active atomic.Int64
    limit  int64
}

func (r *RequestLimiter) TryAcquire() bool {
    for {
        current := r.active.Load()
        if current >= r.limit {
            return false  // At capacity
        }
        if r.active.CompareAndSwap(current, current+1) {
            return true  // Acquired
        }
        // CAS failed—another goroutine changed it; retry
    }
}

func (r *RequestLimiter) Release() {
    r.active.Add(-1)
}
Check-Then-Act Needs CAS, Not Two Atomics

The version below has no data race — every access goes through an atomic — and it still admits more requests than the limit allows. Load and Add are two operations, and the decision made by the first is stale by the time the second runs.

request_limiter_112_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the check and the increment are two operations
func (r *RequestLimiter) TryAcquire() bool {
    if r.active.Load() < r.limit {
        r.active.Add(1)  // two goroutines can both reach here
        return true
    }
    return false
}

Two goroutines can both see active=99 (under limit of 100), both pass the check, both call Add(1), resulting in active=101.

Always use CAS for check-then-act patterns with atomics.


11.2.6 Mixed Synchronization: Combining Atomics and Mutexes

Sometimes the best approach combines both primitives:

cache_112.go
// Illustrative snippet — not a complete program
type Cache struct {
    mu    sync.RWMutex
    data  map[string][]byte

    // High-frequency atomic counters
    hits   atomic.Int64
    misses atomic.Int64
}

func (c *Cache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    val, ok := c.data[key]
    c.mu.RUnlock()

    if ok {
        c.hits.Add(1)    // Lock-free increment
    } else {
        c.misses.Add(1)  // Lock-free increment
    }

    return val, ok
}
MIXED SYNCHRONIZATION STRATEGY

One cache using two mechanisms. The map is guarded by an RWMutex because it is a complex structure needing compound operations. The hit and miss counters are plain atomics because they are simple integers, independent of the map, and touched on every call. The statistics therefore never slow the cache down.

This pattern works because the counters are independent of the cache data. The hit/miss counts don’t need to be consistent with the map contents at any point in time—they just need to be accurate totals.

When Mixed Sync Fails

If you need a consistent view of BOTH the map and the counters:

cache_112_x_2.go
// Illustrative snippet — not a complete program
// ✗ Inconsistent: size and hits/misses captured at different moments
func (c *Cache) Stats() (size int, hits, misses int64) {
    c.mu.RLock()
    size = len(c.data)
    c.mu.RUnlock()

    hits = c.hits.Load()
    misses = c.misses.Load()
    return  // size may not line up with hits+misses
}

Mixed synchronization works when the atomic values are independent of mutex-protected state.


11.2.7 What Atomics Actually Cost

Atomics are cheaper than mutexes. The interesting question is how much, and the honest answer splits in two — because one atomic operation is in a different category from the others, and the rest are merely a bit faster than a lock.

Measured go1.26.1, darwin/amd64, Intel i7-10700K. Every row is b.RunParallel, so all figures are the same metric and can be compared with each other; -cpu 1 is the uncontended cost of one operation, -cpu 16 is what the same operation costs when sixteen goroutines are doing it at once. -benchtime 700ms -count 6, minimum reported. Chapter 9 measured this mutex at 10.8 ns independently, which is the row below to within a rounding error.
Operation
atomic.Int64.Load
atomic.Pointer[T].Load
atomic.Int64.Add
atomic.Int64.Store
atomic.Int64.CompareAndSwap
sync.RWMutex RLock+RUnlock
sync.Mutex Lock+Unlock
How to Read the Second Column

b.RunParallel divides total wall time by the total number of iterations across every goroutine. So for an operation that scales perfectly, the number falls in proportion to the cores — sixteen goroutines each doing the work at the same speed finish sixteen times as many iterations in the same wall time.

That is why Load reads as 0.09 ns. The load did not get faster. Its cost per core is flat at about 1.17 ns; only the divisor moved. What the number genuinely tells you is that the operation scales — sixteen cores deliver thirteen times the throughput, and the shortfall from sixteen is measurement overhead rather than contention.

Every other row moves the other way. Those operations get more expensive per operation as goroutines are added, because they all write to the same cache line and the cores have to pass it between them.

Reads are the category difference. An atomic.Pointer load is 1.17 ns against an RWMutex read lock’s 9.20 ns — roughly 9× cheaper uncontended. More importantly it is the only row in the table that scales: adding readers adds throughput, because nothing writes the line and every core can hold it at once. The RWMutex cannot do that, and the reason is visible in its own implementation — RLock increments a reader counter, so a read lock is a write to shared memory, and sixteen of them contend exactly the way sixteen Adds do. That is why §11.2.4's configuration pattern is the strongest case in this chapter.

Writes are worth about a factor of two. An uncontended Add is 5.34 ns against a mutex’s 10.70 ns. Under sixteen-way contention the gap widens a little — 17.02 against 57.76, about 3.4× — because the mutex degrades faster than the atomic does. Both degrade. Neither scales. An atomic counter is a better counter than a mutex-guarded one, and it is not a different category of thing.

The Rule This Produces

Reach for an atomic when the hot operation is a read. That is the case where you are not buying a percentage, you are buying the ability to add cores and get something back.

For read-modify-write — Add, Swap, CompareAndSwap — you are buying a factor of two, widening to three or four under contention. Take it on a path that runs on every request. Do not restructure a design around it.

Store Is Not a Cheap Load

Load is 1.17 ns and Store is 5.34 ns — the same cost as Add, for what looks like the same operation in the other direction. §11.1.3 has the mechanism: on amd64 a load compiles to a plain MOVQ, while a sequentially consistent store compiles to XCHGQ, a locked exchange. A Store is a read-modify-write at the hardware level; it simply discards the value it read.

This matters when the write is the hot operation. A shutdown flag written once is free. A gauge that a thousand goroutines Store into every millisecond is a write-heavy workload wearing a read-shaped API, and it belongs in the Add row of that table, not the Load row.

When the Difference Shows Up

The saving is 5.36 ns per operation — mutex minus atomic, uncontended. It only becomes visible when there are a great many operations and the work around them is small.

Atomic operations per second
10 M
1 M
100 K
under 100 K
Derived operations per second × 5.36 ns, from the two uncontended figures in the table above. Check any row yourself.

A service handling 100,000 requests a second with four counter updates each is doing 400,000 atomic operations a second, which saves about 2 ms of CPU per second against mutexes — two tenths of one percent of one core. That is not a reason to change anything. At ten times the traffic it is 21 ms, still small. The frequencies where this arithmetic starts to matter are the ones where a single counter is being hit millions of times a second by every request path in the process.

The one thing the arithmetic understates is contention. These are CPU-time figures, and a contended mutex does something worse than burn CPU: it parks goroutines, which shows up in tail latency rather than in a throughput average. If your p99 is the problem and the mutex profile (§9.4) points at one lock guarding one integer, the atomic is worth reaching for well below the frequencies in that table.

Benchmarking It Yourself

atomic_add_112.go
// Illustrative snippet — not a complete program
func BenchmarkAtomicAdd(b *testing.B) {
    var c atomic.Int64          // declared here, not at package level
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            c.Add(1)
        }
    })
}

func BenchmarkMutexAdd(b *testing.B) {
    var mu sync.Mutex           // a fresh lock, for the same reason
    var c int64
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            c++
            mu.Unlock()
        }
    })
}

Four things to get right, all of which produce wrong numbers silently:

Give each benchmark its own primitive. Sharing a package-level mutex between benchmarks leaves it in whatever mode the previous one left it in.

Use -cpu deliberately. RunParallel at the default -cpu measures throughput on all your cores; at -cpu 1 it measures the cost of one operation. They answer different questions and the table above needs both.

Read the minimum across -count passes, not the mean. A benchmark measuring single-digit nanoseconds is measuring your machine’s mood as much as your code.

Do not compare a RunParallel number with a plain-loop number. They are different metrics — one is aggregate, one is per-operation — and putting them in adjacent columns is the easiest way to convince yourself an operation got faster when only the divisor changed.

Terminal
$ $ go test -bench . -benchtime 700ms -count 6 -cpu 1 ./...
$ $ go test -bench . -benchtime 700ms -count 6 -cpu 16 ./...
Numbers Older Than Go 1.24 Are Wrong Now

Go 1.24 rewrote the mutex fast path. Comparisons published before it — which is most of what a search will find, and was the source of the “~25 ns mutex” figure this chapter used to quote — overstate the uncontended mutex by roughly a factor of two, and therefore overstate the case for atomics. Check the date on any benchmark you are about to believe, including this one.

11.2.8 Decision Framework

Use this framework to choose between atomics and mutexes:

SHOULD I USE ATOMICS?

A three-question flowchart. If the state is not a single value, use a mutex. If the operation is not simple, use a mutex. If the frequency is not high, either works and a mutex is clearer. Only when all three answers point the same way do atomics win.

Quick Reference Table

Scenario
Simple counter
Shutdown flag
Config (read-heavy)
Request ID generator
Statistics (approximate OK)
Timestamps / last seen
Min/max tracker
Multiple related counters
Complex state transitions
Infrequent operations
Data with invariants
Check-then-act without CAS
State change with cleanup

Summary: When Atomics Fit

Use atomics when ALL of these apply:

  1. ✓ Operating on a single value
  2. ✓ Using simple operations (Load, Store, Add, Swap, CAS)
  3. ✓ High frequency (thousands+ per second) OR read-heavy config
  4. ✓ No invariants with other values
  5. ✓ Approximate consistency is acceptable (for multi-field reads)
Common atomic use cases:
Use Case
Request counter
Shutdown flag
Feature toggle
Connection state
Timestamp / last seen
ID generator
Config (read-heavy)
Min/max tracking
Request limiter
Progress tracker

The golden rule: start with a mutex. Switch to atomics only when profiling shows a bottleneck on a single simple value where an atomic actually helps — which, per §11.2.7, means a read on a hot path far more often than it means a counter.

The workflow that produces it:

  1. Write the correct version with mutexes.
  2. Measure, with pprof and the mutex profile (§9.4).
  3. Find the contention. If there isn’t any, stop here.
  4. Replace the single-value fields the profile pointed at — and only those.
  5. Measure again, and keep the change only if the number moved.

An atomic is an optimization. Optimizations get applied when data asks for them, not when a type looks like it would suit one.


Self-Check Questions: Choosing Atomics

1. A developer argues: “We should use atomics everywhere for maximum performance.” What’s wrong with this reasoning?

This reasoning is flawed for several reasons:

  1. Premature optimization: If code isn’t on a hot path (thousands of operations per second), the performance difference between atomics and mutexes is negligible. An uncontended mutex is 10.70 ns (§11.2.7)—invisible at human-perceivable timescales, and only about twice an atomic Add.
  2. Complexity cost: Atomics are harder to reason about. CAS loops, memory ordering, and the inability to compose operations make atomic-heavy code more error-prone.
  3. Limited applicability: Atomics only work for single values with simple operations. Most real-world synchronization involves multiple variables or complex logic that atomics can’t express.
  4. False economy: Using atomics incorrectly (e.g., multiple atomic operations that should be atomic together) creates bugs. A correct mutex-based solution is infinitely faster than an incorrect atomic-based one.

The right approach: Use mutexes by default. Profile to find actual bottlenecks. Apply atomics surgically where profiling shows they help.

2. For each scenario, should you use atomics or mutexes?

Atomic, mutex, or both — and for which fields?

A. Atomic

  • Single int64 counter
  • Simple operations (increment on start, decrement on finish)
  • High frequency (every goroutine creation/exit)

B. Mutex

  • Three related fields
  • Invariant: if IsAuthenticated changes, likely need to update LastAccess
  • Need atomic view of all fields for correct session state

C. Mixed

  • Map needs mutex (complex data structure)
  • Hit count is independent—can use atomic
cache_112_3.go
// Illustrative snippet — not a complete program
type Cache struct {
    mu   sync.RWMutex
    data map[string]Data

    hits atomic.Int64  // Independent counter
}

3. This code uses atomics for a feature flag. Is it correct?

feature_112.go
// Illustrative snippet — not a complete program
type Feature struct {
    enabled atomic.Bool
    config  map[string]string  // Feature configuration
}

func (f *Feature) Enable(cfg map[string]string) {
    f.config = cfg
    f.enabled.Store(true)
}

func (f *Feature) GetConfig() map[string]string {
    if f.enabled.Load() {
        return f.config
    }
    return nil
}
Is this correct? If not, what is unprotected?

This code has a data race.

The config map is not protected. A reader calling GetConfig() might:

  1. See enabled == true
  2. Read config while Enable() is still writing to it

The atomic bool only protects the bool itself, not the map.

Fix using atomic.Pointer:

feature_112_2.go
// Illustrative snippet — not a complete program
type Feature struct {
    state atomic.Pointer[featureState]
}

type featureState struct {
    enabled bool
    config  map[string]string
}

func (f *Feature) Enable(cfg map[string]string) {
    f.state.Store(&featureState{
        enabled: true,
        config:  cfg,
    })
}

func (f *Feature) GetConfig() map[string]string {
    s := f.state.Load()
    if s != nil && s.enabled {
        return s.config
    }
    return nil
}

Key insight: Atomic operations protect only the atomic variable itself. Related data must either be immutable, protected by the same atomic pointer, or protected by a mutex.

4. What’s wrong with this request limiter?

request_limiter_112_3.go
// Illustrative snippet — not a complete program
func (r *RequestLimiter) TryAcquire() bool {
    if r.active.Load() < r.limit {
        r.active.Add(1)
        return true
    }
    return false
}
How can active exceed limit?

Race between check and increment. Two goroutines can both see active=99 (under limit of 100), both pass the check, both call Add(1), resulting in active=101.

Fix: Use CAS loop:

request_limiter_112_4.go
// Illustrative snippet — not a complete program
func (r *RequestLimiter) TryAcquire() bool {
    for {
        current := r.active.Load()
        if current >= r.limit {
            return false
        }
        if r.active.CompareAndSwap(current, current+1) {
            return true
        }
    }
}

5. You’re adding metrics to a logger. The logger already uses a mutex for file writes. Should the write counter be atomic?

logger_112.go
// Illustrative snippet — not a complete program
type Logger struct {
    mu        sync.Mutex
    file      *os.File
    lineCount int64  // Should this be atomic?
}
Should lineCount become an atomic.Int64?

No—keep it under the mutex. The counter increment happens during file writes, which already require the mutex. Adding atomics:

  • Gains nothing (file I/O is ~1000x slower than mutex overhead)
  • Adds complexity (mixing sync primitives)
  • May break invariants (if lineCount should match actual lines written)

Keep it simple:

logger_112_2.go
// Illustrative snippet — not a complete program
func (l *Logger) WriteLine(line string) error {
    l.mu.Lock()
    defer l.mu.Unlock()

    _, err := l.file.WriteString(line + "\n")
    if err == nil {
        l.lineCount++  // under the mutex; free next to the file I/O
    }
    return err
}

Key Takeaways

  • Use atomics for single values with simple operations—counters, flags, timestamps, pointers to immutable data.
  • The Three Questions: (1) Single value? (2) Simple operation? (3) High frequency or read-heavy? If yes to all, consider atomics.
  • Counters and IDs are the canonical atomic use case—Add is faster and simpler than mutex lock/unlock.
  • Shutdown flags benefit from atomics—cheap polling in tight loops without blocking.
  • Config hot-reload uses atomic.Pointer[T]—lock-free reads on the hot path, rare writes.
  • Metrics collection with Swap(0) enables accurate interval measurement without losing increments.
  • Mixed synchronization works when atomic counters are independent of mutex-protected data.
  • Performance only matters at scale—millions of operations per second. Profile before optimizing.
  • Approximate consistency is acceptable for monitoring dashboards but not for billing or SLAs.
  • The golden rule: Start with a mutex. Switch to atomics only when profiling proves contention on a single simple value.

Next: §11.3 examines when atomics are the wrong choice—the traps, limitations, and anti-patterns that make mutexes the better option.

11.3 When NOT to Use Atomics

§11.2 showed where atomics shine. This section examines where they fail—scenarios where reaching for atomics creates bugs, false confidence, or unnecessary complexity. Understanding these anti-patterns is essential for choosing the right synchronization tool.

The Simplicity Test

If you can’t explain the correctness of your atomic code in one sentence, use a mutex instead.


The Core Problem: Atomics Don’t Compose

Atomics operate on single values with single operations. The moment you need:

...atomics become dangerous. They give you the illusion of safety while hiding subtle bugs.

THE COMPOSABILITY PROBLEM

Three atomic operations shown first in isolation, each individually safe, then shown in sequence with gaps between them. Every gap is a window in which another goroutine can observe the values half-updated, which is why individually atomic operations do not compose into an atomic whole.


11.3.1 Multi-Field Invariants

The most common mistake: using separate atomics for values that must be consistent together.

The Bug: Two Atomics, One Invariant

account_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Invariant violation
type Account struct {
    balance atomic.Int64
    limit   atomic.Int64  // Invariant: balance <= limit
}

func (a *Account) Deposit(amount int64) {
    a.balance.Add(amount)
    // What if balance now exceeds limit?
    // No way to check-and-update atomically across both fields
}

func (a *Account) SetLimit(newLimit int64) {
    // What if current balance exceeds newLimit?
    a.limit.Store(newLimit)  // Invariant may already be broken
}

func (a *Account) IsValid() bool {
    balance := a.balance.Load()
    limit := a.limit.Load()
    // Between these two loads either value can change.
    return balance <= limit
}

The problem: Each atomic operation is individually atomic, but there’s no atomicity across operations. Between loading balance and loading limit, another goroutine can change either value.

READING A PAIR IS NOT A SNAPSHOT

A timeline for two goroutines against a balance and a limit. Goroutine B loads the balance as 50. Goroutine A then adds 60, making it 110. B loads the limit as 100 and concludes that 50 is within 100, so the account is valid, when the balance is actually 110. Neither load raced; the pair was simply never a snapshot.

The Fix: Put the Invariant Behind One Lock

account_113_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Mutex protects the invariant
type Account struct {
    mu      sync.Mutex
    balance int64
    limit   int64
}

func (a *Account) Deposit(amount int64) error {
    a.mu.Lock()
    defer a.mu.Unlock()

    newBalance := a.balance + amount
    if newBalance > a.limit {
        return errors.New("deposit would exceed limit")
    }
    a.balance = newBalance
    return nil
}

func (a *Account) SetLimit(newLimit int64) error {
    a.mu.Lock()
    defer a.mu.Unlock()

    if a.balance > newLimit {
        return errors.New("current balance exceeds new limit")
    }
    a.limit = newLimit
    return nil
}

Alternative: Atomic Pointer to Immutable Struct

If reads vastly outnumber writes, atomic pointer with copy-on-write works:

account_113_3.go
// Illustrative snippet — not a complete program
type AccountState struct {
    Balance int64
    Limit   int64
}

type Account struct {
    state atomic.Pointer[AccountState]
}

func (a *Account) Deposit(amount int64) error {
    for {
        old := a.state.Load()
        newBalance := old.Balance + amount
        if newBalance > old.Limit {
            return errors.New("deposit would exceed limit")
        }

        newState := &AccountState{
            Balance: newBalance,
            Limit:   old.Limit,
        }

        if a.state.CompareAndSwap(old, newState) {
            return nil
        }
        // Another goroutine changed state; retry
    }
}

func (a *Account) Get() (balance, limit int64) {
    s := a.state.Load()
    return s.Balance, s.Limit  // Guaranteed consistent
}

This works because the entire state is swapped atomically as a unit.

Memory Ordering Guarantee

atomic.Pointer[T].Store(ptr) establishes a happens-before relationship with subsequent Load() calls. Any goroutine that loads the pointer is guaranteed to see the fully initialized struct. This is why immutability is required—the data must be complete before storing the pointer.

MULTI-FIELD INVARIANT SOLUTIONS

Three approaches to keeping a balance and a limit consistent. Separate atomics fail, because the two values can be read at different instants. A mutex works, protecting both together. An atomic pointer to an immutable struct also works, swapping the whole state at once. Choose the mutex when updates are frequent or complex, the pointer when reads dominate.


11.3.2 Composite Operations

Even when values are independent, multiple atomic operations on the same values don’t compose into one larger atomic operation.

The Bug: Money Disappears

You have met this function before. Chapter 10's exercise was a Transfer that took two mutexes in argument order and deadlocked when two goroutines moved money in opposite directions; §10.5.3 fixed it by giving the locks a total order. Here it has no locks at all, and it fails a different way — no deadlock is possible, and the money still goes missing.

transfer_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Bank transfer loses money
func Transfer(from, to *atomic.Int64, amount int64) error {
    if from.Load() < amount {
        return errors.New("insufficient funds")
    }

    from.Add(-amount)  // Deducted!
    // ← another goroutine reading both accounts here sees
    //   the money in neither of them
    to.Add(amount)

    return nil
}

The two Add calls are each atomic; the pair is not. Between them the money exists in neither account, and any goroutine that totals the two sees a system that has lost $100:

MONEY BETWEEN TWO ACCOUNTS

A timeline of a transfer built from two separate atomic adds. The first add removes 100 from the source account. Before the second add credits the destination, an observer totals both accounts and sees 900 instead of 1000. The money exists in neither account for the length of the gap.

The Fix: One Owner for Both Balances

bank_113.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Mutex ensures atomic transfer
type Bank struct {
    mu       sync.Mutex
    accounts map[string]int64
}

func (b *Bank) Transfer(from, to string, amount int64) error {
    b.mu.Lock()
    defer b.mu.Unlock()

    if b.accounts[from] < amount {
        return errors.New("insufficient funds")
    }

    b.accounts[from] -= amount
    b.accounts[to] += amount
    return nil
}

When Approximate Consistency Is Acceptable

For monitoring dashboards, slight inconsistency is often fine—you’re tracking trends, not exact values. Document this clearly:

stats_113.go
// Illustrative snippet — not a complete program
// Snapshot returns approximately-consistent stats.
// Values may be from slightly different instants.
// Suitable for monitoring; NOT suitable for billing or SLAs.
func (s *Stats) Snapshot() (requests, errors, latency int64) {
    return s.requests.Load(), s.errors.Load(), s.latency.Load()
}

11.3.3 Check-Then-Act Without CAS

This anti-pattern appears so frequently it deserves its own section: reading a value, making a decision, then acting—without ensuring the value hasn’t changed.

The Bug: Check Then Act

pool_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Race between check and act
type Pool struct {
    available atomic.Int64
    max       int64
}

func (p *Pool) Acquire() bool {
    if p.available.Load() > 0 {
        // another goroutine may have taken the last one
        p.available.Add(-1)
        return true
    }
    return false
}

Timeline of the bug:

CHECK-THEN-ACT WITHOUT CAS

A timeline of two goroutines acquiring from a pool with one item left. Both load the count as 1, both test it as greater than zero, and both then decrement, leaving the count at minus 1. The check and the act are two separate operations, so the answer from the first is stale by the time the second runs.

Both goroutines pass the check, both decrement. Result: negative available count.

The Fix: CAS Loop

pool_113_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: CAS ensures check and act are atomic
func (p *Pool) Acquire() bool {
    for {
        current := p.available.Load()
        if current <= 0 {
            return false  // Nothing available
        }
        if p.available.CompareAndSwap(current, current-1) {
            return true  // Successfully acquired
        }
        // CAS failed—another goroutine changed it; retry
    }
}

The Fix: Mutex (Often Simpler)

pool_113_3.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Mutex is often clearer for check-then-act
type Pool struct {
    mu        sync.Mutex
    available int64
    max       int64
}

func (p *Pool) Acquire() bool {
    p.mu.Lock()
    defer p.mu.Unlock()

    if p.available <= 0 {
        return false
    }
    p.available--
    return true
}

Common Patterns

Initialize Once (Wrong):

get_config_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Multiple goroutines might initialize
var initialized atomic.Bool
var data *Config

func GetConfig() *Config {
    if !initialized.Load() {
        data = loadConfig()      // several goroutines can reach here
        initialized.Store(true)
    }
    return data
}

Initialize Once (Correct):

get_config_113_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Use sync.Once
var once sync.Once
var data *Config

func GetConfig() *Config {
    once.Do(func() {
        data = loadConfig()  // Guaranteed to run exactly once
    })
    return data
}

Try-Claim Pattern (Wrong):

worker_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Multiple goroutines might both "claim"
func (w *Worker) TryClaim() bool {
    if !w.claimed.Load() {
        w.claimed.Store(true)
        return true  // Another goroutine might also return true!
    }
    return false
}

Try-Claim Pattern (Correct):

worker_113_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: CAS guarantees only one succeeds
func (w *Worker) TryClaim() bool {
    return w.claimed.CompareAndSwap(false, true)
}
CAS Complexity Guideline

If your CAS loop body has complex logic—multiple conditionals, several computations, or method calls—use a mutex instead. CAS loops should be trivially simple, typically just a few lines. Complex CAS loops are hard to reason about and easy to get wrong.

CAS Contention Warning

Under extreme contention, CAS loops can spin many times, wasting CPU. If you observe high retry rates, a mutex may actually be faster. At very high contention, consider sharding the data or redesigning the access pattern.


11.3.4 State Changes with Side Effects

When state changes require actions beyond updating a variable—closing connections, notifying handlers, cleaning up resources—atomics become inadequate.

The Bug: A State Change With Consequences

connection_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: State change has side effects
type Connection struct {
    state    atomic.Uint32  // a ConnState (§11.2.1)
    socket   net.Conn
    handlers []Handler
}

func (c *Connection) Close() error {
    if !c.state.CompareAndSwap(
        uint32(StateConnected), uint32(StateClosing)) {
        return errors.New("not connected")
    }

    // Problem: others see StateClosing while the socket is still open
    c.socket.Close()

    // Problem: handlers run after the close, before the final state
    for _, h := range c.handlers {
        h.OnClose()
    }

    c.state.Store(StateClosed)
    return nil
}

func (c *Connection) Send(data []byte) error {
    if c.state.Load() != StateConnected {
        return errors.New("not connected")
    }
    // Race: state might change to StateClosing between check and write
    _, err := c.socket.Write(data)
    return err
}

Problems:

  1. Send() can pass the state check, then fail because socket is being closed
  2. Handlers see StateClosing but resources aren’t fully cleaned up
  3. No transactional guarantee: partial cleanup if error occurs

The Fix: Bundle the State With Its Side Effects

connection_113_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Mutex bundles state change with side effects
type Connection struct {
    mu       sync.Mutex
    state    int32
    socket   net.Conn
    handlers []Handler
}

func (c *Connection) Close() error {
    c.mu.Lock()
    defer c.mu.Unlock()

    if c.state != StateConnected {
        return errors.New("not connected")
    }

    c.state = StateClosing

    // All cleanup happens atomically from observers' perspective
    if err := c.socket.Close(); err != nil {
        // In production prefer StateError: the socket may be broken.
        // Simplified rollback for illustration purposes.
        c.state = StateConnected
        return err
    }

    for _, h := range c.handlers {
        h.OnClose()
    }

    c.state = StateClosed
    return nil
}

func (c *Connection) Send(data []byte) error {
    c.mu.Lock()
    defer c.mu.Unlock()

    if c.state != StateConnected {
        return errors.New("not connected")
    }
    _, err := c.socket.Write(data)
    return err
}
STATE TRANSITIONS: ATOMIC VS MUTEX

When a state machine can use an atomic and when it cannot. Atomics suffice when the state is only a value, transitions are a single CAS or Store, and nothing needs cleaning up. A mutex is required once a transition also closes a socket or notifies listeners, once several operations must happen together, once failure needs a rollback, or once other methods need a consistent view of the state and the resources at the same time.


11.3.5 The “Just Add an Atomic” Reflex

A dangerous pattern: seeing a data race and reflexively making the variable atomic without understanding the actual synchronization requirement.

The Bug: Race-Free and Still Meaningless

service_113.go
// Illustrative snippet — not a complete program
// Original: Data race on lastError
type Service struct {
    lastError error
}

func (s *Service) DoWork() error {
    err := s.work()
    s.lastError = err  // DATA RACE
    return err
}

func (s *Service) LastError() error {
    return s.lastError  // DATA RACE
}
service_113_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN "fix": atomic.Value doesn't solve the real problem
type Service struct {
    lastError atomic.Value  // Stores error
}

func (s *Service) DoWork() error {
    err := s.work()
    s.lastError.Store(err)
    return err
}

func (s *Service) LastError() error {
    if v := s.lastError.Load(); v != nil {
        return v.(error)
    }
    return nil
}

It does not even run. Before we get to the semantics, this program panics twice over, and both panics are the rules §11.1.7 already stated:

Terminal
panic: sync/atomic: store of nil value into Value
    DoWork stores err on every call -- including the successful ones,
    where err is nil.
panic: sync/atomic: store of inconsistently typed value into Value
    The first error might be *errors.errorString; the next one from the
    network layer is *net.OpError. Different concrete type, panic.

error is close to the worst possible thing to put in an atomic.Value. It is an interface, so its concrete type varies with wherever the error came from, and its most common value is nil — the two things atomic.Value refuses. Reaching for atomic.Value here was not a cheap fix that happened to be imprecise; it was a fix that never worked.

And the semantics were never defined either.

Suppose you swap in atomic.Pointer[myError] and the panics go away. The race detector is happy. Now ask what the field is for:

Semantic ambiguity in action:

WHICH ERROR IS THE LAST ERROR

A timeline in which worker A stores a timeout error and worker B then stores an authentication error into the same field. A caller reading between the two sees the timeout; a caller reading after both sees the authentication failure. Neither answer is wrong, which is the point: the field has no defined meaning under concurrency.

The Real Question

Before adding any synchronization, ask: What invariant am I protecting?

service_113_3.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Define the semantics clearly
type Service struct {
    mu          sync.Mutex
    errors      []error        // All errors from current batch
    lastError   error          // Most recent error (any worker)
    errorCount  int            // Total errors
}

func (s *Service) RecordError(err error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.errors = append(s.errors, err)
    s.lastError = err
    s.errorCount++
}

func (s *Service) ErrorSummary() ([]error, error, int) {
    s.mu.Lock()
    defer s.mu.Unlock()

    // Return consistent snapshot
    errorsCopy := make([]error, len(s.errors))
    copy(errorsCopy, s.errors)
    return errorsCopy, s.lastError, s.errorCount
}
The Atomic Reflex

When the race detector complains, resist the urge to “just make it atomic.” Instead:

  1. Define the invariant: What consistency guarantee do callers need?
  2. Consider the semantics: What does this value mean under concurrency?
  3. Choose appropriate synchronization: Atomic, mutex, or channel based on requirements

An atomic that’s technically race-free but semantically meaningless is still a bug.


11.3.6 Mutable Data Behind Pointers

atomic.Pointer[T] protects the pointer, not the data it points to.

POINTER VS CONTENT

An atomic.Pointer to a Config, with the pointer at the top and the struct’s fields below it. Swapping the pointer is atomic. Modifying the Timeout, Endpoints or Features underneath it is not, and races with every reader currently holding that pointer.

The Bug: Mutating What the Pointer Points At

server_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Modifying data through atomic pointer
type Config struct {
    Timeout   time.Duration
    Endpoints []string
}

type Server struct {
    config atomic.Pointer[Config]
}

func (s *Server) AddEndpoint(endpoint string) {
    cfg := s.config.Load()
    cfg.Endpoints = append(cfg.Endpoints, endpoint)  // DATA RACE!
    // Other goroutines are reading cfg.Endpoints right now
}

The problem: Loading the pointer gives you access to the underlying struct. Modifying that struct races with concurrent readers.

The Fix: Copy-on-Write

server_113_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Create new config, never modify existing
func (s *Server) AddEndpoint(endpoint string) {
    for {
        old := s.config.Load()

        // Deep copy the slice
        newEndpoints := make([]string, len(old.Endpoints)+1)
        copy(newEndpoints, old.Endpoints)
        newEndpoints[len(old.Endpoints)] = endpoint

        newCfg := &Config{
            Timeout:   old.Timeout,
            Endpoints: newEndpoints,
        }

        if s.config.CompareAndSwap(old, newCfg) {
            return
        }
    }
}

Maps Are Especially Dangerous

server_113_x_3.go
// Illustrative snippet — not a complete program
type Config struct {
    Features map[string]bool
}

// ✗ BROKEN: Map modification through atomic pointer
func (s *Server) EnableFeature(name string) {
    cfg := s.config.Load()
    cfg.Features[name] = true  // DATA RACE on map!
}

// ✓ CORRECT: Clone the map
func (s *Server) EnableFeature(name string) {
    for {
        old := s.config.Load()

        newFeatures := make(map[string]bool, len(old.Features)+1)
        for k, v := range old.Features {
            newFeatures[k] = v
        }
        newFeatures[name] = true

        newCfg := &Config{Features: newFeatures}

        if s.config.CompareAndSwap(old, newCfg) {
            return
        }
    }
}

The Immutability Contract

When using atomic.Pointer[T], establish a team rule:

Immutability Contract for atomic.Pointer

Once stored through an atomic pointer, the pointed-to data MUST NEVER be modified. Treat it as frozen. Updates create new data. This enables lock-free safe reading.

Copy-on-Write Performance Tradeoffs

Copy-on-write has costs that increase with struct size and update frequency:

Update Frequency
Rare (minutes+)
Moderate (seconds)
Frequent (ms)
Any frequency

Rule of thumb: if copying the struct costs more than a mutex lock/unlock — call it 16 ns from §11.2.7, so roughly a couple of hundred bytes — consider using a mutex instead. For large structs or frequent updates, copy-on-write becomes a bottleneck.


11.3.7 False Sharing

A performance anti-pattern: atomics on different fields sharing the same CPU cache line cause hidden contention.

FALSE SHARING

Four counters laid out inside a single 64-byte cache line, with a different core writing each one. When core 1 writes its counter, the whole line is invalidated for all four cores even though no two of them touch the same variable. The counts stay correct; the cores spend their time passing the line between each other.

The Bug: Counters Sharing a Cache Line

counters_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ BAD: Counters share cache line, causing hidden contention
type Counters struct {
    reads   atomic.Int64  // These are likely in the same
    writes  atomic.Int64  // 64-byte cache line
    errors  atomic.Int64
    retries atomic.Int64
}

When multiple cores update adjacent counters, they invalidate each other’s cache lines even though they’re accessing different variables. This is the one anti-pattern in §11.3 that costs only speed — the counts stay correct — which is also why it hides so well.

Measured go1.26.1, darwin/amd64, Intel i7-10700K: sixteen goroutines split between two counters, each doing nothing but Add(1).
Layout
Two counters adjacent
Same two, padded 64 bytes apart

2.3× slower for a struct that is correct either way, and no profile will name the cause: both versions show time in Add, and the second one just does less of it.

The Fix: Padding

counters_113_2.go
// Illustrative snippet — not a complete program
// ✓ BETTER: Pad to separate cache lines
type Counters struct {
    reads   atomic.Int64
    _pad1   [56]byte      // Fills rest of 64-byte cache line
    writes  atomic.Int64
    _pad2   [56]byte
    errors  atomic.Int64
    _pad3   [56]byte
    retries atomic.Int64
    _pad4   [56]byte
}

When This Matters

False sharing only matters when:

For most applications, the simpler struct is fine. Only add padding after profiling proves false sharing is the bottleneck.

Padding Is Best-Effort, Not a Guarantee

The 56-byte fillers separate the counters from each other, which is what matters. They do not guarantee that either counter starts at a cache-line boundary: Go promises 8-byte alignment for a struct’s first word, not 64, and the allocator’s size classes decide the rest. Two padded counters can still straddle a line, in which case one of them shares with whatever is allocated next door.

In practice the separation is enough — the measurement above is real. But it is a mitigation, not an invariant, and it is one more reason to measure rather than assume.

Cache Line Sizes

x86-64 and most ARM64 use 64-byte lines. Apple silicon uses 128, so padding tuned for 64 leaves those counters sharing a line on exactly the machines many Go developers now build on. unsafe.Sizeof cannot tell you this; the value is a property of the CPU, not the language. Verify it for the hardware you deploy to, and prefer measuring the two layouts to reasoning about them.


11.3.8 Partial Synchronization

Mixing atomic and non-atomic access to the same variable, or protecting only some access points.

The Bug: Half the Accesses Synchronized

server_113_x_4.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Inconsistent access patterns
type Server struct {
    requestCount atomic.Int64
    config       *Config  // Sometimes accessed without synchronization
}

func (s *Server) HandleRequest() {
    s.requestCount.Add(1)  // Atomic
    // Bug: Reading config without synchronization elsewhere
}

func (s *Server) UpdateConfig(c *Config) {
    s.config = c  // Non-atomic write!
}

func (s *Server) GetConfig() *Config {
    return s.config  // Non-atomic read!
}

The Rules

  1. All or nothing: If a variable needs synchronization, ALL access must be synchronized
  2. Consistent mechanism: Use the same synchronization for all access (all atomic, or all mutex-protected)
  3. No mixing: Don’t mix atomic and non-atomic access to the same memory location
server_113_5.go
// Illustrative snippet — not a complete program
// ✓ CORRECT: Consistent atomic access
type Server struct {
    requestCount atomic.Int64
    config       atomic.Pointer[Config]  // All access is atomic
}

func (s *Server) UpdateConfig(c *Config) {
    s.config.Store(c)  // Atomic write
}

func (s *Server) GetConfig() *Config {
    return s.config.Load()  // Atomic read
}
Typed Atomics Prevent Bugs

Go 1.19+ typed atomics make the two ways of getting this wrong visible, though not by the same mechanism—and the difference is worth knowing, because one of them will not stop your build.

count++ is a compile error: atomic.Int64 has no +=. But _ = count, other := count, and passing an atomic.Int64 to a function by value all compile cleanly. They copy the struct, and the copy is a separate variable that silently diverges from the original.

What catches those is go vet, through the same copylocks analyzer §9.2 uses for mutexes—atomic.Int64 embeds a noCopy field for exactly this purpose:

Terminal
assignment copies lock value to counterCopy:
sync/atomic.Int64 contains sync/atomic.noCopy

So the rule is the same as for mutexes: never copy one, always pass a pointer, and keep go vet in CI where it can tell you that you did.


11.3.9 Premature Optimization

Using atomics for “performance” when there’s no proven performance problem.

The Unnecessary Optimization

app_113_x_1.go
// Illustrative snippet — not a complete program
// ✗ UNNECESSARY: Atomics for rarely-accessed config
type App struct {
    debugMode atomic.Bool  // Checked once at startup
}

func main() {
    var app App
    app.debugMode.Store(os.Getenv("DEBUG") == "true")
    // ... app runs, debugMode checked occasionally ...
}

A mutex here would be essentially just as fast for such infrequent access—the 8 ns difference (§11.2.7) is irrelevant when the operation happens once at startup or a few times per second.

The True Cost of Atomics

THE TRUE COST OF ATOMICS

A ledger with both sides. A mutex costs 10.70 nanoseconds uncontended but has obvious semantics, composes across several operations, and is easy to debug. An atomic costs 1.17 nanoseconds to Load and 5.34 to Add but has subtle semantics, does not compose, and is hard to debug when wrong. The 5.4 nanoseconds saved matter only at millions of operations per second, whereas one subtle atomic bug can cost days.

The Proper Workflow

OPTIMIZATION DECISION TREE

A decision tree starting from a mutex. If there is no measured performance problem, stop. If there is, profile to find the bottleneck. If the bottleneck is not mutex contention, stop. If it is, ask whether the operation fits in a single atomic; if it does, convert it, verify correctness and benchmark to confirm, and if it does not, consider sharding or another strategy.

Blocking vs Polling

Atomics can lead to polling patterns that waste resources:

server_113_x_6.go
// Illustrative snippet — not a complete program
// ✗ WASTEFUL: Busy polling
func (s *Server) WaitForShutdown() {
    for !s.shutdown.Load() {
        time.Sleep(10 * time.Millisecond)  // Adds up to 10ms latency
    }
}

// ✓ BETTER: Block until signaled
func (s *Server) WaitForShutdown() {
    <-s.shutdownCh  // parks the goroutine; no CPU, instant wake
}

When you need to wait for a condition, channels or sync.Cond are better than polling atomics. Polling wastes resources and adds latency compared to blocking primitives that wake immediately when signaled.


11.3.10 Summary: Anti-Pattern Hierarchy

Some atomic anti-patterns are worse than others:

Severity
Critical
Critical
Critical
Severe
Severe
Moderate
Moderate
Minor
Minor

Decision Matrix

Scenario
Single counter, high frequency
Single flag, rare updates
Two related values
Many fields with invariants
Check-then-act
State change with cleanup
Config with nested data
Metrics, approximate is fine
Metrics, exact snapshot required
Waiting for something

The whole of §11.3 reduces to one sentence, and it is worth carrying out of the chapter on its own: an atomic does one thing to one value; if correctness needs two of either, an atomic cannot express it. Every anti-pattern above is some version of asking one to.

Summary: When They Don’t

Atomics are the wrong choice when:
Scenario
Multi-field invariants
Composing operations
Check-then-act (without CAS)
State with side effects
Modifying pointed-to data
Fixing race detector warnings
No proven performance need
Waiting for conditions
The Principle Behind All Nine

Atomics protect single values with single operations. The moment you need consistency across values, composition of operations, or a side effect that has to accompany a state change, you have left what an atomic can express — and no amount of care with the atomic will get it back.

So the decision rule runs the other way round from the way people usually reach for it. Use a mutex unless all three of these hold: profiling proves contention is a real bottleneck, the operation genuinely fits inside one atomic, and you have checked that no invariant spans two values.


Self-Check Questions: Atomic Anti-Patterns

1. What’s wrong with this rate limiter?

rate_limiter_113.go
// Illustrative snippet — not a complete program
type RateLimiter struct {
    requests atomic.Int64
    window   atomic.Int64  // Unix timestamp of window start
    limit    int64
}

func (r *RateLimiter) Allow() bool {
    now := time.Now().Unix()

    // Reset if new window
    if now > r.window.Load() {
        r.window.Store(now)
        r.requests.Store(0)
    }

    if r.requests.Load() < r.limit {
        r.requests.Add(1)
        return true
    }
    return false
}
How many separate defects are in this Allow method?

Multiple race conditions:

  1. Window reset race: Two goroutines can both see now > window, both reset. Requests from goroutine A’s increment might be lost when B resets to 0.
  2. Check-then-act race: Between r.requests.Load() < r.limit and r.requests.Add(1), other goroutines can increment, exceeding the limit.
  3. Multi-value inconsistency: window and requests can be in inconsistent states. A goroutine might see the new window but old request count.

Fix with mutex:

rate_limiter_113_2.go
// Illustrative snippet — not a complete program
type RateLimiter struct {
    mu       sync.Mutex
    requests int64
    window   int64
    limit    int64
}

func (r *RateLimiter) Allow() bool {
    r.mu.Lock()
    defer r.mu.Unlock()

    now := time.Now().Unix()
    if now > r.window {
        r.window = now
        r.requests = 0
    }

    if r.requests < r.limit {
        r.requests++
        return true
    }
    return false
}

2. This code uses atomic.Pointer for a cache. Is it correct?

cache_113.go
// Illustrative snippet — not a complete program
type Cache struct {
    data atomic.Pointer[map[string]string]
}

func (c *Cache) Set(key, value string) {
    m := c.data.Load()
    if m == nil {
        m = &map[string]string{}
        c.data.Store(m)
    }
    (*m)[key] = value
}

func (c *Cache) Get(key string) string {
    m := c.data.Load()
    if m == nil {
        return ""
    }
    return (*m)[key]
}
Is this correct? Count the bugs before opening the answer.

Multiple bugs:

  1. Race on nil check: Two goroutines can both see m == nil, both create new maps, one’s map gets lost.
  2. Modifying through atomic pointer: (*m)[key] = value modifies the map that other goroutines are reading. This is a data race on the map itself.
  3. Map is not thread-safe: Even with atomic pointer, concurrent map read/write is a race.

Fix with copy-on-write:

cache_113_2.go
// Illustrative snippet — not a complete program
type Cache struct {
    data atomic.Pointer[map[string]string]
}

func NewCache() *Cache {
    c := &Cache{}
    empty := make(map[string]string)
    c.data.Store(&empty)
    return c
}

func (c *Cache) Set(key, value string) {
    for {
        old := c.data.Load()

        // Handle uninitialized cache (defensive)
        var oldMap map[string]string
        if old != nil {
            oldMap = *old
        }

        // Create new map with all old entries plus new one
        newMap := make(map[string]string, len(oldMap)+1)
        for k, v := range oldMap {
            newMap[k] = v
        }
        newMap[key] = value

        if c.data.CompareAndSwap(old, &newMap) {
            return
        }
    }
}

func (c *Cache) Get(key string) string {
    m := c.data.Load()
    if m == nil {
        return ""  // Uninitialized cache
    }
    return (*m)[key]  // Safe: we never modify *m
}

Note: This requires NewCache() for proper initialization. Using sync.Map may be simpler if your access pattern fits its optimization (write-once-read-many, or disjoint keys per goroutine).

3. A team is debugging a production issue where a “shutdown complete” log appears before all workers have stopped. Here’s the code:

server_113_7.go
// Illustrative snippet — not a complete program
type Server struct {
    workerCount atomic.Int32
    shutdown    atomic.Bool
}

func (s *Server) Shutdown() {
    s.shutdown.Store(true)

    // Wait for workers to finish
    for s.workerCount.Load() > 0 {
        time.Sleep(10 * time.Millisecond)
    }

    log.Println("shutdown complete")
}

func (s *Server) worker() {
    s.workerCount.Add(1)
    defer s.workerCount.Add(-1)

    for !s.shutdown.Load() {
        // Do work
    }
}
What’s wrong?

The bug is in worker startup timing:

COUNTING WORKERS THAT HAVE NOT STARTED

A timeline in which main starts two worker goroutines and immediately calls Shutdown. The shutdown flag is set and the worker count is read as zero, so shutdown reports itself complete. Only afterwards do the workers run, increment the count to 1, observe the flag and exit. The count was read before the workers had registered themselves.

Problem: workerCount.Add(1) happens inside the goroutine. If Shutdown() runs before workers increment, it sees 0 workers and exits immediately.

Fix: Use sync.WaitGroup:

server_113_8.go
// Illustrative snippet — not a complete program
type Server struct {
    wg       sync.WaitGroup
    shutdown atomic.Bool
}

func (s *Server) StartWorker() {
    // wg.Go does the Add before it starts the goroutine and the Done
    // when the function returns -- which is exactly the ordering the
    // hand-rolled version got wrong. This is the one place in the
    // chapter where that ordering is the whole point, so it is worth
    // saying explicitly: the counter is incremented by the caller,
    // before there is a goroutine to race with.
    s.wg.Go(func() {
        for !s.shutdown.Load() {
            // Do work
        }
    })
}

func (s *Server) Shutdown() {
    s.shutdown.Store(true)
    s.wg.Wait()  // Guaranteed to wait for all workers
    log.Println("shutdown complete")
}

Key insight: sync.WaitGroup is designed for this exact pattern. The atomics-only approach has a fundamental ordering problem.

4. A colleague proposes using atomics for a connection pool. Review this design:

pool_113_4.go
// Illustrative snippet — not a complete program
type Pool struct {
    connections atomic.Int32
    maxConn     int32
}

func (p *Pool) Acquire() (*Conn, error) {
    for {
        current := p.connections.Load()
        if current >= p.maxConn {
            return nil, errors.New("pool exhausted")
        }
        if p.connections.CompareAndSwap(current, current+1) {
            return newConnection(), nil
        }
    }
}

func (p *Pool) Release(c *Conn) {
    c.Close()
    p.connections.Add(-1)
}
What’s the issue?

The atomic only tracks count, not actual connections. There’s no way to:

  1. Reuse connections: Released connections are closed, not returned to a pool
  2. Enforce consistency: Count can drift from actual connections if errors occur
  3. Handle connection failures: If newConnection() fails after CAS succeeds, count is wrong

This is a case where atomics track a shadow of state rather than the state itself.

Fix: Track actual connections:

pool_113_5.go
// Illustrative snippet — not a complete program
type Pool struct {
    mu      sync.Mutex
    idle    []*Conn  // Available connections
    created int      // Total connections ever created
    maxConn int
}

func (p *Pool) Acquire() (*Conn, error) {
    p.mu.Lock()
    defer p.mu.Unlock()

    // Reuse existing connection if available
    if len(p.idle) > 0 {
        c := p.idle[len(p.idle)-1]
        p.idle = p.idle[:len(p.idle)-1]
        return c, nil
    }

    // Create new if under limit
    if p.created < p.maxConn {
        c, err := newConnection()
        if err != nil {
            return nil, err
        }
        p.created++
        return c, nil
    }

    return nil, errors.New("pool exhausted")
}

func (p *Pool) Release(c *Conn) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.idle = append(p.idle, c)  // Return to pool, don't close
}

Or use a buffered channel as the pool, which is a common Go pattern.

5. When would you use atomic operations despite the complexity, instead of a simpler mutex?

Use atomics when ALL of these conditions are met:

  1. Profiling proves mutex contention is a bottleneck - Not theoretical concern—actual measured contention - High-frequency operations (millions/second) - Contention causing latency spikes or throughput issues
  2. The operation genuinely fits atomic constraints - Single value - Simple operation (Add, Load, Store, Swap, or simple CAS) - No multi-value invariants - No side effects required
  3. Lock-free progress is required (optional) - Real-time systems where blocking is unacceptable - Signal handlers (can’t use mutexes) - Priority inversion concerns
  4. The team has expertise - Understanding of memory models - Ability to reason about concurrent correctness - Thorough code review process for concurrent code

Examples where atomics are justified:

  • Metrics counters on every request (millions/sec)
  • Read-heavy config with rare updates
  • Lock-free data structures in performance-critical libraries

Examples where mutex is better despite atomics being possible:

  • Low-frequency operations
  • Complex business logic
  • Multiple related values
  • Team unfamiliar with atomics
  • Prototype or rapidly-changing code

Key Takeaways

  • Atomics don’t compose—two atomic operations don’t form an atomic pair.
  • Multi-field invariants require mutex—separate atomics can’t maintain consistency across values.
  • Check-then-act without CAS is broken—always use CAS loops or mutex for conditional updates.
  • State changes with side effects need mutex—atomics can’t bundle cleanup with state transitions.
  • Don’t “just add atomic” to fix race detector warnings—understand the semantic requirement first.
  • atomic.Pointer protects the pointer, not the data—never modify loaded data; use copy-on-write.
  • False sharing is a performance issue, not correctness—only add padding after profiling proves it matters.
  • Partial synchronization (mixing atomic and non-atomic access) is always wrong.
  • Premature optimization with atomics adds complexity for no benefit—mutex overhead is negligible at low frequency.
  • When in doubt, use a mutex—atomics are an optimization, not a default choice.

Next: §11.4 presents common atomic patterns and idioms—battle-tested code structures for the scenarios where atomics genuinely shine.

11.4 Common Atomic Patterns and Idioms

The previous sections taught you what atomics are and how they work. This section shows you how to use them effectively. We’ll explore battle-tested patterns that appear repeatedly in production Go code—patterns that solve real coordination problems while avoiding the pitfalls that trap less experienced developers.

Each pattern here represents a case where atomics genuinely shine: simple state that multiple goroutines need to access, situations where mutex overhead matters, or coordination points that don’t fit neatly into channel-based designs.

Pattern Catalog

Before diving into each pattern, here’s an overview of what we’ll cover:

Terminal
ATOMIC PATTERNS BY COMPLEXITY
  Simple: Load / Store / Add
    shutdown signals metrics collection
    counters and IDs configuration publishing
    progress tracking
  Medium: CAS loops
    state machines max/min tracking
    lazy initialization generation counters
    copy-on-write collections
  Advanced: mixed synchronization
    claim and ownership testing coordination
  Complexity here means "how hard is it to convince
  yourself it is correct", not "how fast is it".

11.4.1 Shutdown Coordination

The shutdown signal is perhaps the most common atomic pattern. A goroutine needs to know when to stop, and the signal must be visible immediately across all cores.

Basic Shutdown Signal

server_114.go
// Illustrative snippet — not a complete program
type Server struct {
    shutdown atomic.Bool
}

func (s *Server) Shutdown() error {
    // Swap atomically sets to true and returns the previous value.
    // If previous was true, someone else already initiated shutdown.
    if s.shutdown.Swap(true) {
        return errors.New("shutdown already in progress")
    }

    // First caller proceeds with cleanup
    return s.performCleanup()
}

func (s *Server) IsShuttingDown() bool {
    return s.shutdown.Load()
}

func (s *Server) handleRequest(req Request) error {
    if s.shutdown.Load() {
        return ErrServerShuttingDown
    }
    // Process request...
    return nil
}

The Swap operation is key here: it atomically sets the flag and returns the previous value, making shutdown idempotent. Multiple calls to Shutdown() are safe—only the first caller performs the actual cleanup.

Shutdown with Active Request Tracking

Real servers need graceful shutdown: stop accepting new work while waiting for in-flight requests to complete.

graceful_server_114.go
// Illustrative snippet — not a complete program
type GracefulServer struct {
    // The gate: the shutdown flag and the registration of a new
    // request have to happen as one step, so they live behind one
    // lock rather than in an atomic.
    mu       sync.RWMutex
    shutdown bool
    active   sync.WaitGroup
}

func (s *GracefulServer) HandleRequest(req Request) error {
    // Readers share the lock, so handlers never serialize against
    // each other -- only against Shutdown, which runs once.
    s.mu.RLock()
    if s.shutdown {
        s.mu.RUnlock()
        return ErrServerShuttingDown
    }
    s.active.Add(1)   // registered while the gate is still open
    s.mu.RUnlock()

    defer s.active.Done()
    return s.process(req)
}

func (s *GracefulServer) Shutdown(ctx context.Context) error {
    s.mu.Lock()
    if s.shutdown {
        s.mu.Unlock()
        return errors.New("shutdown already in progress")
    }
    s.shutdown = true
    s.mu.Unlock()

    // Every Add that will ever happen has already happened. Any
    // handler that read shutdown == false finished its Add before
    // releasing the read lock, and Lock() waited for all of them.
    done := make(chan struct{})
    go func() {
        s.active.Wait()
        close(done)
    }()

    select {
    case <-done:
        return nil
    case <-ctx.Done():
        return fmt.Errorf("shutdown timeout: %w", ctx.Err())
    }
}
Why This One Cannot Be an Atomic Flag

A tempting version keeps shutdown as an atomic.Bool, checks it, then calls active.Add(1). It does not work, and the reason is worth following, because the obvious patch does not fix it either.

A handler reads the flag as false and is preempted before it registers. Shutdown sets the flag and calls Wait. The counter is zero, so Wait returns at once and the caller logs “shutdown complete” with a request still to come. The failure is not that Wait hangs—defer Done() guarantees it drains—it is that Wait returns too early.

Checking the flag a second time after Add(1) narrows the damage, since the handler then declines to do the work. It does not fix the defect. The Add(1) has still landed after Wait returned, and sync.WaitGroup names that directly:

Note that calls with a positive delta that occur when the counter is zero must happen before a Wait.

The check and the registration have to be one indivisible step, and no single atomic can make two operations indivisible. That is §11.3's thesis arriving in the middle of a patterns section: this is exactly the shape atomics cannot hold, so the gate takes a lock. The flag stays cheap where it is only a hint (§11.4.1's first listing, or a hot loop’s early reject); it becomes a lock the moment something must be true while another operation happens.

Terminal
Shutdown gate -- who is inside when the door closes
  handler A RLock read false Add(1) RUnlock ...work... Done
                 |________________________________|
                          holds the read lock
  Shutdown Lock ...... blocked ...... acquired
                                                                    |
                                          set shutdown = true, Unlock
                                                                    |
                                                                  Wait
                                          returns only after A's Done
  handler B RLock read true reject
  A is counted because it registered while holding the read lock.
  B is refused because it arrived after the flag was set.
  There is no gap between the two states for a request to fall into.

Channels vs Atomics for Shutdown

§11.2.1 compares the two on cost and semantics. The rule that falls out of it: use a channel when the worker is going to block anyway, and an atomic flag when it is in a tight loop that can check cheaply. A worker sitting in select on a job channel should have the shutdown channel as another case; a worker grinding through a slice should poll a flag.

Common Mistake: Channel Receive Without Close

server_114_2.go
// Illustrative snippet — not a complete program
// Problematic for multi-reader scenarios
type Server struct {
    shutdown chan struct{}
}

func (s *Server) worker() {
    for {
        select {
        case <-s.shutdown:
            // If using send (not close), only ONE worker receives!
            return
        case work := <-s.workQueue:
            s.process(work)
        }
    }
}

Receiving from a channel consumes the value. If you send to a channel with 10 workers listening, only one receives. The correct channel approach is to close the channel, which broadcasts to all receivers. But you can only close once—the atomic approach handles repeated shutdown calls gracefully and allows polling without consumption.


11.4.2 Counters and ID Generation

Counters are the simplest atomic pattern, yet they appear everywhere: request counts, connection tracking, metrics, and unique ID generation.

Basic Counter with Statistics

request_stats_114.go
// Illustrative snippet — not a complete program
type RequestStats struct {
    total     atomic.Uint64
    succeeded atomic.Uint64
    failed    atomic.Uint64
}

func (s *RequestStats) RecordSuccess() {
    s.total.Add(1)
    s.succeeded.Add(1)
}

func (s *RequestStats) RecordFailure() {
    s.total.Add(1)
    s.failed.Add(1)
}

// Snapshot returns a point-in-time view.
// Note: individual reads are atomic, but the combination is not—
// values may come from slightly different instants under load.
func (s *RequestStats) Snapshot() (total, succeeded, failed uint64) {
    total = s.total.Load()
    succeeded = s.succeeded.Load()
    failed = s.failed.Load()
    return
}

// SuccessRate calculates approximate success percentage.
// Due to independent atomic loads, the result is approximate
// under concurrent updates.
func (s *RequestStats) SuccessRate() float64 {
    total := s.total.Load()
    if total == 0 {
        return 0
    }
    succeeded := s.succeeded.Load()
    return float64(succeeded) / float64(total) * 100
}

Monotonic ID Generation

For unique IDs within a process, atomic increment is unbeatable:

idgenerator_114.go
// Illustrative snippet — not a complete program
type IDGenerator struct {
    next atomic.Uint64
}

func (g *IDGenerator) Next() uint64 {
    // Add returns the NEW value (after increment).
    // This differs from C/C++ fetch_add which returns the old value.
    return g.next.Add(1)
}

// Reserve allocates a block of IDs for batch operations.
// Returns the inclusive range [start, end] containing count IDs.
func (g *IDGenerator) Reserve(count uint64) (start, end uint64) {
    end = g.next.Add(count)
    start = end - count + 1
    return start, end
}

The Reserve method is useful when you need multiple IDs atomically—for example, when inserting a batch of records that each need unique identifiers:

insert_batch_114.go
// Illustrative snippet — not a complete program
var globalID IDGenerator

func InsertBatch(records []Record) {
    start, _ := globalID.Reserve(uint64(len(records)))
    for i := range records {
        records[i].ID = start + uint64(i)
    }
    // All IDs guaranteed unique, no gaps between them
}

Active Connection Tracking

Track concurrent connections with increment on connect, decrement on disconnect:

connection_pool_114.go
// Illustrative snippet — not a complete program
type ConnectionPool struct {
    active atomic.Int64
    total  atomic.Uint64  // Lifetime total
    peak   atomic.Int64   // High-water mark
}

// updateMax atomically updates target if value is greater.
// Uses CAS loop because there's no atomic "store if greater" operation.
func updateMax(target *atomic.Int64, value int64) {
    for {
        current := target.Load()
        if value <= current {
            return  // Not a new maximum
        }
        if target.CompareAndSwap(current, value) {
            return  // Successfully updated
        }
        // CAS failed - another goroutine updated; retry
    }
}

func (p *ConnectionPool) OnConnect() func() {
    p.total.Add(1)
    current := p.active.Add(1)

    // Update peak if we've exceeded it
    updateMax(&p.peak, current)

    // Return cleanup function
    return func() {
        p.active.Add(-1)
    }
}

func (p *ConnectionPool) Stats() (
    active int64, total uint64, peak int64,
) {
    return p.active.Load(), p.total.Load(), p.peak.Load()
}

The cleanup-function pattern ensures connections are always properly decremented, even if the caller forgets:

handle_connection_114.go
// Illustrative snippet — not a complete program
func handleConnection(conn net.Conn) {
    cleanup := pool.OnConnect()
    defer cleanup()  // Guaranteed to run

    // Handle connection...
}

11.4.3 Metrics Collection

Production systems need metrics that can be collected by monitoring systems. Atomics enable lock-free metrics that don’t impact request latency.

Interval-Based Metrics

Many monitoring systems scrape metrics at regular intervals. Using Swap to atomically read and reset enables accurate interval measurement:

interval_metrics_114.go
// Illustrative snippet — not a complete program
type IntervalMetrics struct {
    requests atomic.Int64
    errors   atomic.Int64
    bytes    atomic.Int64
}

func (m *IntervalMetrics) RecordRequest(size int64, err error) {
    m.requests.Add(1)
    m.bytes.Add(size)
    if err != nil {
        m.errors.Add(1)
    }
}

// Collect returns metrics since last collection and resets counters.
// Swap is critical here: Load followed by Store(0) would lose
// increments that happen between the two operations.
func (m *IntervalMetrics) Collect() (requests, errors, bytes int64) {
    requests = m.requests.Swap(0)
    errors = m.errors.Swap(0)
    bytes = m.bytes.Swap(0)
    return
}

The metrics collector runs on its own schedule:

interval_metrics_114_2.go
// Illustrative snippet — not a complete program
// StartReporter runs until ctx is cancelled. Both the goroutine and
// the ticker have an exit -- chapter 2's first question, asked of the
// most forgettable goroutine in any service.
func (m *IntervalMetrics) StartReporter(
    ctx context.Context, interval time.Duration,
) {
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()

        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                reqs, errs, bytes := m.Collect()
                log.Printf("Interval: %d requests, %d errors, %d bytes",
                    reqs, errs, bytes)
            }
        }
    }()
}

Gauge with High-Water Mark

For values that fluctuate (queue depth, memory usage), track both current value and peak:

gauge_114.go
// Illustrative snippet — not a complete program
type Gauge struct {
    current atomic.Int64
    peak    atomic.Int64
}

func (g *Gauge) Set(value int64) {
    g.current.Store(value)
    updateMax(&g.peak, value)
}

func (g *Gauge) Add(delta int64) int64 {
    newVal := g.current.Add(delta)
    if delta > 0 {
        updateMax(&g.peak, newVal)
    }
    return newVal
}

The same pattern works for tracking minimums:

update_min_114.go
// Illustrative snippet — not a complete program
func updateMin(target *atomic.Int64, value int64) {
    for {
        current := target.Load()
        if value >= current {
            return
        }
        if target.CompareAndSwap(current, value) {
            return
        }
    }
}

Latency Tracking

Combine min/max tracking for latency statistics:

latency_tracker_114.go
// Illustrative snippet — not a complete program
type LatencyTracker struct {
    count atomic.Int64
    sum   atomic.Int64  // Microseconds
    min   atomic.Int64  // Initialized to MaxInt64
    max   atomic.Int64
}

func NewLatencyTracker() *LatencyTracker {
    t := &LatencyTracker{}
    t.min.Store(math.MaxInt64)
    return t
}

func (t *LatencyTracker) Record(d time.Duration) {
    us := d.Microseconds()
    t.count.Add(1)
    t.sum.Add(us)
    updateMin(&t.min, us)
    updateMax(&t.max, us)
}

// Stats returns latency statistics.
// Individual values are atomic, but the combination is approximately
// consistent—values may be from slightly different instants.
func (t *LatencyTracker) Stats() (
    count int64, avg, min, max time.Duration,
) {
    count = t.count.Load()
    if count == 0 {
        return 0, 0, 0, 0
    }
    sum := t.sum.Load()
    minUs := t.min.Load()
    maxUs := t.max.Load()

    return count,
        time.Duration(sum/count) * time.Microsecond,
        time.Duration(minUs) * time.Microsecond,
        time.Duration(maxUs) * time.Microsecond
}

11.4.4 Configuration Hot-Reload

Applications often need to update configuration without restart. The atomic pointer pattern provides lock-free access to immutable configuration snapshots.

config_manager_114.go
// Illustrative snippet — not a complete program
type Config struct {
    MaxConnections int
    Timeout        time.Duration
    Features       map[string]bool
    // ... other fields
}

type ConfigManager struct {
    current atomic.Pointer[Config]
    version atomic.Uint64
}

func NewConfigManager(initial *Config) *ConfigManager {
    cm := &ConfigManager{}
    cm.current.Store(initial)
    cm.version.Store(1)   // not 0: see below
    return cm
}

func (cm *ConfigManager) Get() *Config {
    return cm.current.Load()
}

func (cm *ConfigManager) Update(cfg *Config) {
    // Writer ascending: publish the config, then the version that
    // announces it. A reader can then never see the version without
    // the config it names.
    cm.current.Store(cfg)
    cm.version.Add(1)
}

// GetIfNewer returns config only if the version changed.
// Useful for caching derived values.
func (cm *ConfigManager) GetIfNewer(
    lastVersion uint64,
) (*Config, uint64, bool) {
    // Reader descending: version first, config second. See the callout
    // below — reading these in the writer's order latches stale config
    // permanently.
    version := cm.version.Load()
    cfg := cm.current.Load()

    if version == lastVersion {
        return nil, lastVersion, false
    }
    return cfg, version, true
}
Read the Pair in the Opposite Order to the Write

This is §11.1.3's rule again—writer ascending, reader descending—and here getting it backwards does lasting damage rather than momentary damage.

Suppose the reader loads the config first. It gets v4. It is preempted. The updater publishes v5 and bumps the version. The reader wakes, loads the version, and gets 5. It now returns the v4 config stamped version 5, and the caller caches that pair. On every later call GetIfNewer compares 5 against 5, reports no change, and hands back the stale derived values. The service keeps the old configuration until it restarts.

Reading the version first cannot do that. The config is then always at least as new as the version naming it, so the worst case is one redundant recompute—a reader that briefly believes it is one version behind when it is already current.

If the two really must agree exactly, don’t keep them in two atomics at all. §11.3.1's answer applies: put the version inside the config struct and swap the pair as one pointer.

Start the Version at 1, Not 0

A handler’s lastVersion field starts at the zero value. If the manager also starts at version 0, the very first GetIfNewer(0) compares 0 against 0, reports “unchanged”, and the handler never derives anything from the config the manager was constructed with. It stays uninitialised until the first reload — which on a healthy service may be never.

Starting at 1 makes “I have seen nothing” and “I have seen the first config” different values, which is what the caller’s zero value needs to mean. The chapter exercise takes the other route and documents version 0 as the initial state; either works, as long as it is written down.

The GetIfNewer method enables efficient polling—callers can skip expensive reprocessing when configuration hasn’t changed:

handler_114.go
// Illustrative snippet — not a complete program
func (h *Handler) handleRequest(req Request) {
    // Recompute the derived values only when the config has moved.
    cfg, ver, changed := configMgr.GetIfNewer(h.lastVersion)
    if changed {
        h.recomputeDerivedValues(cfg)
        h.lastVersion = ver
    }
    // ... use the cached derived values
}
Memory Ordering Guarantee

atomic.Pointer[T].Store(ptr) establishes a happens-before relationship with Load(). Any goroutine that loads the pointer is guaranteed to see the complete, fully-initialized struct that was stored. This is why immutability is critical—the data must be complete before storing the pointer.

Safe Configuration Updates

When configuration depends on the current state, use a modify pattern:

config_manager_114_2.go
// Illustrative snippet — not a complete program
func (cm *ConfigManager) Modify(fn func(*Config) *Config) {
    for {
        current := cm.current.Load()
        // Create new config based on current.
        // IMPORTANT: fn must not modify current, only return new.
        newConfig := fn(current)
        if cm.current.CompareAndSwap(current, newConfig) {
            cm.version.Add(1)
            return
        }
        // Another update happened; retry with fresh config
    }
}

// Usage: toggle a feature
configMgr.Modify(func(c *Config) *Config {
    newCfg := *c  // Shallow copy of struct

    // Deep copy the map to avoid modifying the original.
    // Slices and maps would need deep copying too.
    newCfg.Features = make(map[string]bool, len(c.Features))
    for k, v := range c.Features {
        newCfg.Features[k] = v
    }
    newCfg.Features["newFeature"] = true
    return &newCfg
})

File-Based Hot Reload

Combine with file watching for automatic reload:

config_manager_114_3.go
// Illustrative snippet — not a complete program
// Note: requires github.com/fsnotify/fsnotify
func (cm *ConfigManager) WatchFile(
    ctx context.Context, path string,
) error {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        return err
    }

    go func() {
        defer watcher.Close()
        for {
            select {
            case <-ctx.Done():
                return
            case event := <-watcher.Events:
                if event.Op&fsnotify.Write != 0 {
                    cfg, err := loadConfigFromFile(path)
                    if err == nil {
                        cm.Update(cfg)
                        log.Println("Configuration reloaded")
                    } else {
                        log.Printf("Config reload failed: %v", err)
                    }
                }
            case err := <-watcher.Errors:
                log.Printf("Watcher error: %v", err)
            }
        }
    }()

    return watcher.Add(path)
}

11.4.5 Progress Tracking

Long-running operations need progress reporting. Atomics enable non-blocking updates from workers and reads from UI/monitoring code.

progress_114.go
// Illustrative snippet — not a complete program
type Progress struct {
    total     atomic.Int64
    completed atomic.Int64
    failed    atomic.Int64
    startTime atomic.Int64  // UnixNano
}

func NewProgress(total int64) *Progress {
    p := &Progress{}
    p.total.Store(total)
    p.startTime.Store(time.Now().UnixNano())
    return p
}

func (p *Progress) Complete(n int64) {
    p.completed.Add(n)
}

func (p *Progress) Fail(n int64) {
    p.failed.Add(n)
}

func (p *Progress) Status() (
    completed, failed, total int64, pct float64,
) {
    completed = p.completed.Load()
    failed = p.failed.Load()
    total = p.total.Load()
    if total > 0 {
        pct = float64(completed+failed) / float64(total) * 100
    }
    return
}

func (p *Progress) ETA() time.Duration {
    completed := p.completed.Load() + p.failed.Load()
    total := p.total.Load()
    if completed == 0 || completed >= total {
        return 0
    }

    elapsed := time.Since(time.Unix(0, p.startTime.Load()))
    rate := float64(completed) / elapsed.Seconds()
    remaining := float64(total - completed)

    // Scale in float, then convert once: time.Duration(remaining/rate)
    // would truncate to whole nanoseconds before the multiply.
    return time.Duration(remaining / rate * float64(time.Second))
}

func (p *Progress) String() string {
    completed, failed, total, pct := p.Status()
    eta := p.ETA()

    if eta > 0 {
        return fmt.Sprintf("%d/%d (%.1f%%) - %d failed - ETA: %v",
            completed, total, pct, failed, eta.Round(time.Second))
    }
    return fmt.Sprintf("%d/%d (%.1f%%) - %d failed",
        completed, total, pct, failed)
}

Workers update progress independently:

process_files_114.go
// Illustrative snippet — not a complete program
func processFiles(files []string, progress *Progress) {
    var wg sync.WaitGroup
    sem := make(chan struct{}, 10)  // at most 10 files in flight

    for _, file := range files {
        // Acquire BEFORE starting the goroutine. Acquiring inside it
        // would bound the work but not the goroutines: all len(files)
        // of them would exist at once, blocked on the semaphore.
        sem <- struct{}{}

        // file is per-iteration since Go 1.22 (§1.3), so the closure
        // can capture it directly -- no parameter needed.
        wg.Go(func() {
            defer func() { <-sem }()

            if err := processFile(file); err != nil {
                progress.Fail(1)
            } else {
                progress.Complete(1)
            }
        })
    }

    wg.Wait()
}

// Reporter runs separately
func reportProgress(p *Progress, done <-chan struct{}) {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            fmt.Printf("\r%s", p.String())
        case <-done:
            fmt.Printf("\r%s\n", p.String())
            return
        }
    }
}

11.4.6 State Machines with CAS

State machines require atomic transitions to prevent invalid state sequences. CAS ensures only valid transitions succeed.

state_114.go
// Illustrative snippet — not a complete program
type State uint32

const (
    StateIdle State = iota
    StateStarting
    StateRunning
    StateStopping
    StateStopped
)

func (s State) String() string {
    names := [...]string{
        "Idle", "Starting", "Running", "Stopping", "Stopped",
    }
    if int(s) >= len(names) {
        // The value came out of an atomic.Uint32 that anything can
        // Store into, and String is called from the error paths that
        // report a bad state. Panicking while reporting the problem
        // is not a good look.
        return fmt.Sprintf("State(%d)", uint32(s))
    }
    return names[s]
}

type Service struct {
    state atomic.Uint32
}

// Transition attempts to move from expected state to new state.
func (s *Service) Transition(from, to State) bool {
    return s.state.CompareAndSwap(uint32(from), uint32(to))
}

// TransitionFrom moves to `to` from any of the valid source states.
// Uses a CAS loop to handle concurrent state changes.
func (s *Service) TransitionFrom(validFrom []State, to State) bool {
    for {
        current := State(s.state.Load())

        // Check if current state is in valid set
        allowed := false
        for _, valid := range validFrom {
            if current == valid {
                allowed = true
                break
            }
        }

        if !allowed {
            return false  // Current state not in valid list
        }

        if s.state.CompareAndSwap(uint32(current), uint32(to)) {
            return true
        }
        // State changed during CAS; retry
    }
}

func (s *Service) State() State {
    return State(s.state.Load())
}

The state machine enforces valid transitions:

SERVICE STATE MACHINE

A lifecycle with five states: Idle, Starting, Running, Stopping and Stopped. Start moves Idle to Starting, which becomes Running when initialization succeeds or returns to Idle on error. Stop moves Running to Stopping and then to Stopped. Every arrow into a state that two goroutines might enter at once is a CompareAndSwap; the arrows out of states only one goroutine can occupy are plain Stores.

Implementing the service lifecycle:

service_114.go
// Illustrative snippet — not a complete program
func (s *Service) Start() error {
    if !s.Transition(StateIdle, StateStarting) {
        return fmt.Errorf(
            "cannot start: current state is %s", s.State())
    }

    // Perform initialization...
    if err := s.initialize(); err != nil {
        s.state.Store(uint32(StateIdle))  // Rollback
        return err
    }

    s.state.Store(uint32(StateRunning))
    return nil
}

func (s *Service) Stop() error {
    // Can stop from either Starting or Running
    valid := []State{StateStarting, StateRunning}
    if !s.TransitionFrom(valid, StateStopping) {
        return fmt.Errorf("cannot stop: current state is %s", s.State())
    }

    // Perform cleanup...
    s.cleanup()

    s.state.Store(uint32(StateStopped))
    return nil
}

11.4.7 Lazy Initialization

Some resources are expensive to create. Lazy initialization defers creation until first use. While sync.Once is the standard solution, atomic patterns offer more flexibility.

Basic sync.Once (Preferred for Simple Cases)

client_114.go
// Illustrative snippet — not a complete program
type Client struct {
    conn     *Connection
    connOnce sync.Once
}

func (c *Client) getConn() *Connection {
    c.connOnce.Do(func() {
        c.conn = establishConnection()
    })
    return c.conn
}

Lazy Initialization with Retry

sync.Once runs the function exactly once—even if it fails. For resources that might fail transiently, an atomic state machine allows retries:

lazy_resource_114.go
// Illustrative snippet — not a complete program
type LazyResource struct {
    state atomic.Uint32  // 0=pending, 1=initializing, 2=ready, 3=failed
    value atomic.Pointer[Resource]
    err   atomic.Pointer[error]
}

const (
    resourcePending uint32 = iota
    resourceInitializing
    resourceReady
    resourceFailed
)

func (l *LazyResource) Get() (*Resource, error) {
    for {
        switch l.state.Load() {
        case resourceReady:
            return l.value.Load(), nil

        case resourceFailed:
            if errPtr := l.err.Load(); errPtr != nil {
                return nil, *errPtr
            }
            return nil, errors.New("initialization failed")

        case resourcePending:
            // Try to become the initializer
            if l.state.CompareAndSwap(
                resourcePending, resourceInitializing) {
                resource, err := createResource()
                if err != nil {
                    l.err.Store(&err)
                    l.state.Store(resourceFailed)
                    return nil, err
                }
                l.value.Store(resource)
                l.state.Store(resourceReady)
                return resource, nil
            }
            // Another goroutine is initializing; fall through to spin

        case resourceInitializing:
            // Another goroutine is initializing.
            // This is a busy-wait—acceptable for short initializations.
            // For expensive ones use sync.Cond or a channel.
            runtime.Gosched()
        }
    }
}

// Reset allows retry after failure
func (l *LazyResource) Reset() {
    l.state.CompareAndSwap(resourceFailed, resourcePending)
}

Resettable One-Time Initialization

For resources that can be invalidated and need reinitialization. The first attempt at this is a one-liner, and it is wrong in a way worth dwelling on, because it looks exactly like sync.Once:

resettable_once_114_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN — the CAS loser does not wait
type ResettableOnce struct {
    done atomic.Uint32
}

func (o *ResettableOnce) Do(f func()) {
    if o.done.CompareAndSwap(0, 1) {
        f()
    }
    // A goroutine that loses the CAS returns from here immediately,
    // while f() is still running.
}

sync.Once.Do blocks every caller until f has returned. This one only blocks the winner. Every other caller sails past and reads whatever f has not yet written:

get_cached_114_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN — returns nil to almost everyone
func GetCachedValue() *ExpensiveResult {
    once.Do(func() {
        result := computeExpensiveResult()  // takes a few ms
        cachedValue.Store(result)
    })
    return cachedValue.Load()  // nil for every caller but the winner
}
Measured eight goroutines calling GetCachedValue concurrently against a 5 ms initializer, go1.26.1/darwin-amd64 — seven of the eight received nil, stable across runs. With sync.Once, zero.

The fix is to give the losers something to wait on. A channel closed after f returns does it, and keeps the reset ability that motivated the type in the first place:

resettable_once_114_2.go
// Illustrative snippet — not a complete program
// ✓ CORRECT — losers wait for the winner to finish
type ResettableOnce struct {
    mu    sync.Mutex
    done  bool
    ready chan struct{}   // closed when the current f() has returned
}

func NewResettableOnce() *ResettableOnce {
    return &ResettableOnce{ready: make(chan struct{})}
}

func (o *ResettableOnce) Do(f func()) {
    o.mu.Lock()
    if o.done {
        ready := o.ready
        o.mu.Unlock()
        <-ready       // someone else is running or has run f; wait
        return
    }
    o.done = true
    ready := o.ready
    o.mu.Unlock()

    f()
    close(ready)      // release everyone waiting
}

// Reset arms the next Do. Callers already waiting on the previous
// generation are released by that generation's own channel.
func (o *ResettableOnce) Reset() {
    o.mu.Lock()
    defer o.mu.Unlock()
    if !o.done {
        return
    }
    o.done = false
    o.ready = make(chan struct{})
}

That is a mutex and a channel, not an atomic — which is the honest answer. The CAS version is not a cheaper sync.Once; it is a different, weaker thing: “run this at most once, and do not tell me when it finished.” If that is genuinely what you want, name it TryDo and have it return the bool, so no caller can mistake it for a completion guarantee:

try_once_114.go
// Illustrative snippet — not a complete program
// A different type, because it makes a different promise. The name
// and the return value say only "did I run it", never "is it ready".
type TryOnce struct {
    done atomic.Bool
}

func (o *TryOnce) TryDo(f func()) (ran bool) {
    if o.done.CompareAndSwap(false, true) {
        f()
        return true
    }
    return false   // someone else ran it, or is running it right now
}

11.4.8 Copy-on-Write Collections

When reads vastly outnumber writes, copy-on-write provides lock-free reads at the cost of expensive writes.

subscribers_114.go
// Illustrative snippet — not a complete program
type Subscribers struct {
    list atomic.Pointer[[]Subscriber]
}

func NewSubscribers() *Subscribers {
    s := &Subscribers{}
    empty := make([]Subscriber, 0)
    s.list.Store(&empty)
    return s
}

// GetAll returns current subscribers - lock-free.
// The returned slice must not be modified—it may be shared
// with concurrent readers and the internal state.
func (s *Subscribers) GetAll() []Subscriber {
    return *s.list.Load()
}

// Add appends a subscriber using copy-on-write
func (s *Subscribers) Add(sub Subscriber) {
    for {
        current := s.list.Load()
        // Create new slice with additional capacity
        newList := make([]Subscriber, len(*current)+1)
        copy(newList, *current)
        newList[len(*current)] = sub

        if s.list.CompareAndSwap(current, &newList) {
            return
        }
        // Concurrent modification; retry
    }
}

// Remove removes a subscriber by ID
func (s *Subscribers) Remove(id string) bool {
    for {
        current := s.list.Load()
        idx := -1
        for i, sub := range *current {
            if sub.ID == id {
                idx = i
                break
            }
        }
        if idx == -1 {
            return false  // Not found
        }

        // Create new slice without the removed element
        newList := make([]Subscriber, len(*current)-1)
        copy(newList[:idx], (*current)[:idx])
        copy(newList[idx:], (*current)[idx+1:])

        if s.list.CompareAndSwap(current, &newList) {
            return true
        }
        // Concurrent modification; retry
    }
}

Broadcasting to all subscribers is now lock-free:

subscribers_114_2.go
// Illustrative snippet — not a complete program
func (s *Subscribers) Broadcast(event Event) {
    // Safe even if Add/Remove called concurrently
    for _, sub := range s.GetAll() {
        sub.Notify(event)
    }
}

When to use copy-on-write:


11.4.9 Generation Counter

Generation counters detect changes without comparing entire data structures—useful for cache invalidation and consistency checks.

versioned_data_114.go
// Illustrative snippet — not a complete program
type VersionedData struct {
    generation atomic.Uint64
    data       atomic.Pointer[Data]
}

func (v *VersionedData) Update(d *Data) {
    // Writer ascending: data, then the generation that announces it.
    v.data.Store(d)
    v.generation.Add(1)
}

func (v *VersionedData) Read() (*Data, uint64) {
    // Reader descending: generation, then data. Reading these in the
    // writer's order returns old data stamped with the new generation,
    // and HasChanged then reports "unchanged" forever (§11.4.4).
    gen := v.generation.Load()
    data := v.data.Load()
    return data, gen
}

// HasChanged checks if data changed since the given generation
func (v *VersionedData) HasChanged(since uint64) bool {
    return v.generation.Load() != since
}

Clients can efficiently check for updates:

cached_client_114.go
// Illustrative snippet — not a complete program
type CachedClient struct {
    source     *VersionedData
    cached     *Data
    generation uint64
}

func (c *CachedClient) Get() *Data {
    if c.source.HasChanged(c.generation) {
        c.cached, c.generation = c.source.Read()
    }
    return c.cached
}

For cache invalidation across a system:

cache_114.go
// Illustrative snippet — not a complete program
type Cache struct {
    generation atomic.Uint64
    entries    sync.Map
}

func (c *Cache) Invalidate() {
    c.generation.Add(1)
    // Entries check generation before returning cached values
}

func (c *Cache) Get(key string) (any, bool) {
    entry, ok := c.entries.Load(key)
    if !ok {
        return nil, false
    }

    cached := entry.(*cacheEntry)
    if cached.generation != c.generation.Load() {
        // Stale entry
        c.entries.Delete(key)
        return nil, false
    }

    return cached.value, true
}

11.4.10 Claim and Ownership Patterns

Some work must be done by exactly one goroutine, and the others need to find out cheaply that it isn’t them. A single CAS answers that with no lock and no blocking.

Try-Claim

The claimant needs an identity, and it must not be the goroutine’s — §1.4: goroutines are anonymous, with no ID, name or handle the language will give you. Use an identifier the caller already has: a worker index, a lease token, a request ID.

resource_114.go
// Illustrative snippet — not a complete program
type Resource struct {
    owner atomic.Uint64  // 0 = unclaimed; anything else is a token
}

// TryClaim takes ownership if the resource is free. The token must be
// non-zero and unique to the caller -- a worker index, a lease id, a
// counter from an IDGenerator. Not a goroutine id: Go does not have
// those (§1.4).
func (r *Resource) TryClaim(token uint64) bool {
    return r.owner.CompareAndSwap(0, token)
}

// Release gives it up, and only the holder can. A caller passing
// someone else's token gets false and changes nothing.
func (r *Resource) Release(token uint64) bool {
    return r.owner.CompareAndSwap(token, 0)
}

func (r *Resource) HeldBy(token uint64) bool {
    return r.owner.Load() == token
}

The CompareAndSwap in Release is doing real work. A plain Store(0) would let any goroutine release a resource it never held, which turns a lost claim into two goroutines believing they own the same thing.

This Is a Try-Lock, and It Should Be Named Like One

TryClaim never blocks and never queues. A caller that loses simply does not get the resource, and nothing tells it when the resource is free again — it has to ask again.

That is the right shape for “is this already being handled? then skip it”. It is the wrong shape for “I need this resource, wait for it”, which is a sync.Mutex — and since Go 1.18 mu.TryLock() gives you the non-blocking form of that with less ceremony than this type.

Reach for the atomic version when the token has to outlive the goroutine holding it: a lease recorded in a struct, a claim you can inspect from elsewhere, an owner you want to log. A mutex cannot tell you who holds it; this can.

Once-Per-Process Work

The most common real use is not ownership but election: several goroutines could do a job, and exactly one should.

elected_114.go
// Illustrative snippet — not a complete program
type Elected struct {
    claimed atomic.Bool
}

// Do runs f in the first caller only. Subsequent callers return
// immediately and do not wait -- see §11.4.7 for why that matters,
// and use sync.Once when callers need f's results.
func (e *Elected) Do(f func()) (ran bool) {
    if e.claimed.CompareAndSwap(false, true) {
        f()
        return true
    }
    return false
}
worker_114.go
// Illustrative snippet — not a complete program
// Each worker tries; the first one to arrive runs the migration.
func worker(id int, migration *Elected, work <-chan Task) {
    migration.Do(func() {
        log.Printf("worker %d is running the migration", id)
        runMigration()
    })

    for task := range work {
        processTask(task)   // everyone does the ordinary work
    }
}

Note what this deliberately is not. It is not leader election in the distributed sense — there is no term, no lease, no heartbeat, and no way to notice that the winner died halfway through. Within one process that is usually fine, because a goroutine that dies takes the process with it (§1.4). Across processes it is not remotely enough, and the primitive you want lives in etcd or Consul, not in sync/atomic.

11.4.11 Testing Helpers

Atomics are valuable in tests for coordinating goroutines and verifying concurrent behavior.

Test Atomic Code Under -race — and Know What It Cannot Tell You

The race detector catches the mistake atomics are most prone to: one access going through the package and another going straight at the variable. Run every test with -race, in CI, always.

Then remember what a clean run proves. It proves there is no data race. It says nothing about whether two individually atomic operations add up to something meaningful — §11.3 is fourteen pages of programs the detector is perfectly happy with. This chapter’s exercise is one of them.

Terminal
$ go test -race ./...

Call Counter

call_counter_114.go
// Illustrative snippet — not a complete program
type CallCounter struct {
    count atomic.Int64
}

func (c *CallCounter) Inc() {
    c.count.Add(1)
}

func (c *CallCounter) Count() int64 {
    return c.count.Load()
}

func (c *CallCounter) Reset() {
    c.count.Store(0)
}

// WaitForCount blocks until the counter reaches n or timeout.
// Uses polling—acceptable for tests, but for production code
// consider channel-based signaling.
func (c *CallCounter) WaitForCount(
    n int64, timeout time.Duration,
) bool {
    deadline := time.Now().Add(timeout)
    for time.Now().Before(deadline) {
        if c.count.Load() >= n {
            return true
        }
        time.Sleep(time.Millisecond)
    }
    return false
}

Synchronization Barrier

barrier_114.go
// Illustrative snippet — not a complete program
// Barrier is a one-shot synchronization point.
// All goroutines block at Wait() until the target count is reached.
// Once released, it stays released—create a new Barrier for reuse.
type Barrier struct {
    waiting atomic.Int64
    target  int64
    release atomic.Bool
}

func NewBarrier(n int64) *Barrier {
    return &Barrier{target: n}
}

// Wait blocks until all goroutines reach the barrier.
// Uses busy-wait with Gosched—acceptable for tests.
// For production, use sync.WaitGroup or channels instead.
func (b *Barrier) Wait() {
    count := b.waiting.Add(1)
    if count == b.target {
        b.release.Store(true)
    }
    for !b.release.Load() {
        runtime.Gosched()
    }
}

Race Condition Detector

concurrent_access_test_114.go
// Illustrative snippet — not a complete program
func TestConcurrentAccess(t *testing.T) {
    var concurrent atomic.Int64
    var maxConcurrent atomic.Int64

    resource := NewResource()
    var wg sync.WaitGroup

    for range 100 {
        wg.Go(func() {
            current := concurrent.Add(1)
            updateMax(&maxConcurrent, current)

            resource.Access()  // should be serialized

            concurrent.Add(-1)
        })
    }

    wg.Wait()

    // If resource properly serializes access, max should be 1
    if max := maxConcurrent.Load(); max > 1 {
        t.Errorf("detected %d concurrent accesses; want serialized",
            max)
    }
}

Pattern Selection Guide

Choosing the right pattern depends on your access characteristics:

Pattern
Simple counter
Boolean flag
Atomic pointer
CAS loop
Copy-on-write
Generation counter

Decision flowchart:

PATTERN SELECTION FLOWCHART

A flowchart from data shape to pattern. A single primitive that only increases or decreases uses Add. A boolean uses atomic.Bool. Anything else single-valued uses Store and Load, or CompareAndSwap for a conditional update. Data that is not a single value uses an atomic pointer with copy-on-write when reads outnumber writes by more than a hundred to one, and a mutex otherwise.


Common Mistakes

When working with atomic patterns, watch out for these pitfalls:

Terminal
COMMON ATOMIC MISTAKES
  1. Writer and reader touching a pair in the same order
  2. Retrying a CAS without re-loading the current value
  3. Mutating data reached through an atomic.Pointer
  4. Spin-waiting without yielding
  5. Reaching for an atomic where a mutex would be clearer
  6. Shallow-copying a struct that holds a slice or map

Self-Check Questions: Atomic Patterns

Each question links back to the section it comes from. Work out your answer before opening the panel.

1. The graceful shutdown gate uses an RWMutex rather than an atomic.Bool. What goes wrong with the atomic version, and why doesn’t checking the flag a second time after Add(1) fix it?

With an atomic flag the check and the registration are two separate steps, and a request can slip between them:

  1. Handler reads shutdown → false
  2. Shutdown sets the flag and calls Wait()
  3. The counter is zero, so Wait() returns immediately
  4. The caller logs “shutdown complete”
  5. Only now does the handler call active.Add(1)

Note which way it fails. Wait() does not block for too long—defer Done() always drains it—it returns too early, while a request is still on its way in.

A second check after Add(1) stops that handler doing any work, which is better than nothing, but the defect survives: the Add(1) still lands after Wait() returned, and sync.WaitGroup documents a positive Add on a zero counter concurrent with Wait as misuse.

The fix is not another check. It is making the check and the registration one indivisible step, which no single atomic can do—so the gate takes a read lock and Shutdown takes the write lock. Handlers still don’t contend with each other; they contend only with the one-time shutdown.

2. In the copy-on-write subscriber pattern, what happens if two goroutines try to Add() simultaneously? How does the pattern handle this?

Both goroutines enter the CAS loop:

  • Both load the current list
  • Both create new lists with their additions
  • First CAS succeeds, second fails (list pointer changed)
  • Failed goroutine retries with fresh list (now containing first addition)
  • Second CAS succeeds
  • Both additions end up in the list (order depends on timing)
3. When would you choose atomic-based lazy initialization over sync.Once? What are the tradeoffs?

Use atomic lazy init over sync.Once when you need:

  • To check initialization status without triggering init (check state without calling Do)
  • Retry after failure (sync.Once doesn’t retry even if the function fails)
  • Reset/reinitialize the resource (sync.Once can’t be reset)

Tradeoffs: More complex code, but more flexibility. For simple one-time init that can’t fail, sync.Once is cleaner.

4. The metrics Collect() method uses Swap(0) instead of Load() followed by Store(0). Why is this important for accurate interval measurement?

Load() + Store(0) is two separate operations:

Terminal
Thread A: Load() → 100
Thread B: Load() → 100 (before A's Store)
Thread A: Store(0)
Thread B: Store(0)
Thread A: reports 100
Thread B: reports 100 (double-counted!)

With Swap(0), only one thread gets the accumulated value—it’s a single atomic operation that reads and clears simultaneously.

5. In the generation counter and configuration patterns, the writer stores data before incrementing the version. What must the reader do, and what happens if it doesn’t?

The reader must read them in the opposite order: version first, then data. Ordering only the writer is not enough, and this is the part that catches people.

The writer’s order rules out one interleaving:

Terminal
✗ writer descending
Writer: version.Add(1) → 2
Reader: version.Load() → 2 (sees the new version)
Reader: data.Load() → OLD DATA
Writer: data.Store(new)

But with the writer fixed and the reader still reading data first, the same outcome arrives by the other road:

Terminal
✗ reader ascending
Reader: data.Load() → OLD DATA
Writer: data.Store(new)
Writer: version.Add(1) → 2
Reader: version.Load() → 2 (the new version, on the old data)

Either way the reader caches (old data, new version). HasChanged then compares 2 against 2 on every later call, reports no change, and the stale data is latched permanently—this is not a brief skew that resolves on the next poll.

Both sides ordered—writer ascending, reader descending—and the reader’s data is always at least as new as the version naming it. The cost of being wrong in the safe direction is one redundant refresh.

If you need the two to agree exactly rather than safely, stop using two atomics: put the version inside the struct and swap them as a single pointer (§11.3.1).


Key Takeaways

  • Shutdown coordination uses Swap(true) for idempotent shutdown and double-check pattern for graceful draining.
  • Counters are the simplest pattern—Add for increment, Swap(0) for read-and-reset.
  • Metrics collection with separate atomics provides lock-free updates; accept approximate snapshots.
  • Config hot-reload uses atomic.Pointer[T] with immutable config structs and copy-on-write updates.
  • State machines use CAS for valid transitions—reject invalid state changes atomically.
  • Lazy initialization with atomics allows retry after failure, unlike sync.Once.
  • Copy-on-write collections provide lock-free reads at the cost of O(n) writes—use when reads dominate.
  • Generation counters detect changes efficiently, and both sides of the pair have an order: the writer stores the data and then the version, the reader loads the version and then the data. Writer ascending, reader descending; fixing only the writer leaves the bug intact.
  • Claim patterns use CompareAndSwap(0, id) for exclusive ownership without mutex.
  • Testing helpers use atomics for call counting and coordination—always run tests with -race.

Next: §11.5 provides a quick reference—compact tables and templates you can consult while writing concurrent code.

11.5 Quick Reference

This section provides a compact reference for atomic operations in Go. Use it as a quick lookup while coding or reviewing concurrent code.


Atomic Types at a Glance

Terminal
GO 1.19+ TYPED ATOMICS
  atomic.Bool atomic.Int32 atomic.Int64
  atomic.Uint32 atomic.Uint64 atomic.Uintptr
  atomic.Pointer[T] atomic.Value
THE OPERATIONS
  Load() read the current value
  Store(val) write a new value
  Swap(new) write, return the OLD value
  CompareAndSwap write only if current == old; report
    (old, new) whether it happened
  Add(delta) add, return the NEW value (integers)
  And(mask) bitwise AND, return OLD (integers)
  Or(mask) bitwise OR, return OLD (integers)
  Add returns the new value. Swap, And and Or return
  the old one. That asymmetry is the API, not a typo.

Type-Method Matrix

Type
atomic.Bool
atomic.Int32
atomic.Int64
atomic.Uint32
atomic.Uint64
atomic.Uintptr
atomic.Pointer[T]
atomic.Value

Operation Semantics

counter_115.go
// Illustrative snippet — not a complete program
// Load: Returns current value atomically
value := counter.Load()

// Store: Sets value atomically
counter.Store(100)

// Add: Adds delta and returns NEW value (not old!)
newVal := counter.Add(5)  // If was 10, returns 15

// Swap: Sets value and returns PREVIOUS value
oldVal := counter.Swap(0)  // Returns old value, sets to 0

// CompareAndSwap: Conditional update
success := counter.CompareAndSwap(expected, newValue)
// Returns true if current == expected (and updates to newValue)
// Returns false if current != expected (no change)

CAS Loop Template

atomic_update_115.go
// Illustrative snippet — not a complete program
// Standard CAS loop pattern
func atomicUpdate(target *atomic.Int64, transform func(int64) int64) {
    for {
        current := target.Load()
        newVal := transform(current)
        if target.CompareAndSwap(current, newVal) {
            return  // Success
        }
        // CAS failed—another goroutine modified; retry
    }
}

Common CAS operations:

update_max_115.go
// Illustrative snippet — not a complete program
// Update maximum
func updateMax(target *atomic.Int64, value int64) {
    for {
        current := target.Load()
        if value <= current {
            return
        }
        if target.CompareAndSwap(current, value) {
            return
        }
    }
}

// Update minimum
func updateMin(target *atomic.Int64, value int64) {
    for {
        current := target.Load()
        if value >= current {
            return
        }
        if target.CompareAndSwap(current, value) {
            return
        }
    }
}

// Conditional increment (only if below limit)
func incrementIfBelow(target *atomic.Int64, limit int64) bool {
    for {
        current := target.Load()
        if current >= limit {
            return false
        }
        if target.CompareAndSwap(current, current+1) {
            return true
        }
    }
}

atomic.Value Quick Reference

cfg_115.go
// Illustrative snippet — not a complete program
var config atomic.Value

// Store any type (must be consistent)
config.Store(myConfig)  // First store sets the type

// Load returns any -- type assertion required
cfg := config.Load().(*Config)

// Swap (Go 1.17+)
old := config.Swap(newConfig).(*Config)

// CompareAndSwap (Go 1.17+)
success := config.CompareAndSwap(oldConfig, newConfig)

Rules for atomic.Value:


atomic.Pointer[T] Quick Reference

cfg_115_2.go
// Illustrative snippet — not a complete program
var ptr atomic.Pointer[Config]

// Store pointer
ptr.Store(&Config{...})

// Load pointer (nil-safe)
cfg := ptr.Load()  // Returns *Config (may be nil)

// Swap
old := ptr.Swap(&newConfig)

// CompareAndSwap
success := ptr.CompareAndSwap(expectedPtr, &newConfig)

Prefer atomic.Pointer[T] over atomic.Value when:


Memory Ordering Guarantees

HAPPENS-BEFORE

A Store connected by an arrow to the Load that observes it, with everything written before the Store guaranteed visible after that Load returns. The edge exists only because the Load observed that particular stored value; a Load returning something else is not synchronized with that Store at all.

Key guarantees:

Critical ordering rule for version/generation patterns. Both sides are part of the rule; fixing only the writer leaves the bug intact.

gen_115.go
// Illustrative snippet — not a complete program
// Writer ascending — data, then the version naming it
data.Store(newData)
version.Add(1)

// Reader descending — version, then the data it names
gen := version.Load()
d := data.Load()
d_115_x_1.go
// Illustrative snippet — not a complete program
// ✗ WRONG (writer): version first
version.Add(1)      // reader sees the new version...
data.Store(newData) // ...but the old data

// ✗ ALSO WRONG (reader): data first
d := data.Load()       // old data...
gen := version.Load()  // ...stamped new, and cached forever

Pattern Selection Cheat Sheet

Scenario
Stop signal
Request counter
Active connections
Unique ID generation
Interval metrics
Config hot-reload
State machine
Max/min tracking
Lazy initialization
Copy-on-write
Generation counter
Resource claiming

Decision Flowchart

CHOOSING A SYNCHRONIZATION PRIMITIVE

A decision tree over the whole chapter. Single values with simple operations go to Add for counters, atomic.Bool for flags, atomic.Pointer for immutable data, and a CAS loop for conditional updates. Multiple related values with invariants go to a mutex. Producer-consumer communication goes to channels. A state machine with side effects goes to a mutex or channel coordination.


Common Patterns—Compact Examples

Shutdown Signal

server_115.go
// Illustrative snippet — not a complete program
type Server struct {
    shutdown atomic.Bool
}

func (s *Server) Shutdown() bool {
    return !s.shutdown.Swap(true)  // Returns true if first caller
}

func (s *Server) IsShuttingDown() bool {
    return s.shutdown.Load()
}

Counter with Reset

counter_115_2.go
// Illustrative snippet — not a complete program
type Counter struct {
    value atomic.Int64
}

func (c *Counter) Inc() int64    { return c.value.Add(1) }
func (c *Counter) Dec() int64    { return c.value.Add(-1) }
func (c *Counter) Get() int64    { return c.value.Load() }
func (c *Counter) Reset() int64  { return c.value.Swap(0) }

Config Hot-Reload

config_manager_115.go
// Illustrative snippet — not a complete program
type ConfigManager struct {
    current atomic.Pointer[Config]
}

func (cm *ConfigManager) Get() *Config {
    return cm.current.Load()
}

func (cm *ConfigManager) Update(cfg *Config) {
    cm.current.Store(cfg)  // Readers immediately see new config
}

State Transition

service_115.go
// Illustrative snippet — not a complete program
func (s *Service) Start() error {
    if !s.state.CompareAndSwap(StateIdle, StateRunning) {
        return errors.New("invalid state for start")
    }
    return nil
}

Try-Once Execution

Runs f at most once and reports whether this caller was the one who ran it. It is not a sync.Once: callers that lose the CAS return immediately rather than waiting for f to finish, so they must not assume anything f produces is ready. When you need callers to wait, use sync.Once or sync.OnceValue (§11.4.7).

try_once_115.go
// Illustrative snippet — not a complete program
type TryOnce struct {
    done atomic.Bool
}

func (o *TryOnce) TryDo(f func()) (ran bool) {
    if o.done.CompareAndSwap(false, true) {
        f()
        return true
    }
    return false   // someone else ran it, or is running it right now
}

Legacy API Mapping (Pre-Go 1.19)

Modern (Go 1.19+)
v.Load()
v.Store(x)
v.Add(x)
v.Swap(x)
v.CompareAndSwap(old, new)

Migration tip: Replace var counter int64 with var counter atomic.Int64 and update call sites from atomic.AddInt64(&counter, 1) to counter.Add(1). Since Go 1.27 the toolchain will do it for you: go fix ./... includes an atomictypes modernizer that rewrites atomic.AddInt64(&v, …)-style calls and their variable declarations to the typed API. Review its diff the way you would a colleague’s — it cannot know about the 32-bit alignment trap in §11.1.9 that the rewrite quietly fixes.


Common Mistakes—Quick Checklist

Pair read in the writer’s order
✗
Problem

Reader latches stale data under the newest version, permanently

✓
Fix

Writer ascending (data, then version); reader descending (version, then data)

Missing CAS retry
✗
Problem

Lost updates under contention

✓
Fix

Wrap CAS in for loop

Modifying through pointer
✗
Problem

Data races on underlying struct

✓
Fix

Always create new struct, store pointer

Spinning on a flag
✗
Problem

A core burned doing nothing

✓
Fix

Block on a channel. Gosched() yields the P but leaves the goroutine runnable, so the CPU is still spent (§11.3.9)

Load + Store instead of Swap
✗
Problem

Lost increments in interval metrics

✓
Fix

Use Swap(0) to read-and-reset

Atomics for multi-field invariants
✗
Problem

Inconsistent state between fields

✓
Fix

Use mutex instead


Performance Characteristics

Operation
atomic.Int64.Load
atomic.Pointer[T].Load
atomic.Int64.Store
atomic.Int64.Add
atomic.Int64.CompareAndSwap
sync.RWMutex RLock+RUnlock
sync.Mutex Lock+Unlock
select with default
Measured go1.26.1, darwin/amd64, Intel i7-10700K, minimum of eight passes, each primitive freshly declared in its own benchmark. Absolutes move with the machine and the toolchain; the two ratios that don’t are worth memorizing:

Re-measure before you rely on any of this: Go 1.24 rewrote the mutex fast path, so anything published earlier overstates the mutex by roughly half.


Testing Atomics

Terminal
# Always test with race detector
$ go test -race ./...
# Run benchmarks to verify performance assumptions
$ go test -bench=. -benchmem ./...

Test pattern for concurrent correctness:

concurrent_counter_test_115.go
// Illustrative snippet — not a complete program
func TestConcurrentCounter(t *testing.T) {
    var counter atomic.Int64
    var wg sync.WaitGroup

    for range 1000 {
        wg.Go(func() { counter.Add(1) })
    }

    wg.Wait()

    if got := counter.Load(); got != 1000 {
        t.Errorf("counter = %d; want 1000", got)
    }
}

Quick Import

snippet_115.go
// Illustrative snippet — not a complete program
import "sync/atomic"

All typed atomics (atomic.Int64, atomic.Bool, atomic.Pointer[T], etc.) are in the sync/atomic package. No additional imports needed.


Summary Table

When You Need
Thread-safe counter
Shutdown flag
Swappable config
Arbitrary type storage
State transitions
Read-and-reset metrics
Maximum tracking
One-time init
Ownership claim

Chapter Summary

This chapter covered Go’s atomic operations for lock-free synchronization of single values.

The sync/atomic package provides typed atomics (Go 1.19+) with seven operations:

Use atomics when:

Don’t use atomics when:

Key patterns: shutdown flags, counters, metrics collection, config hot-reload, state machines, lazy initialization, copy-on-write collections, generation counters.

The golden rule: start with a mutex, and switch to an atomic only when profiling proves contention on a single simple value where the atomic actually helps.

Performance reality:

Memory ordering: Store establishes happens-before with Load that observes the value. All writes before Store are visible after the corresponding Load.

Chapter Connections

Chapter
Chapter 1
Chapter 2
Chapter 7
Chapter 8
Chapter 9
Chapter 10
Chapter 12
Chapter 13

Final Checklist

Before moving to Chapter 12, ensure you can:


Exercise 11.1 — Give the Version Something to Promise

Your move

Give the Version Something to Promise

This store is correct in every way the previous chapters taught you to check. It compiles. go vet is happy. go test -race has nothing to say about it, because every access really does go through an atomic — there is no data race here to find.

It still serves stale settings forever.

Publish writes the settings and then bumps the version that announces them. That half is right. Read loads them back in the same order, and that is the whole bug: a reader preempted between its two loads comes away holding the old settings stamped with the new version. Every later Changed() call compares that version against itself, reports “nothing new”, and the caller never refreshes.

This is the failure §11.4.4 warns about, and it is worth doing by hand because of what it teaches about tooling. The race detector is a data-race detector. This is not a data race; it is a correct program computing the wrong answer, and no tool in the book will find it for you.

ch11/settings.go
package ch11

import "sync/atomic"

// Settings is a snapshot of service configuration. It is published
// whole and never modified after publication -- see §11.3.6.
type Settings struct {
	Timeout    int
	MaxRetries int
}

// TODO(reader): This store is correct in every way the previous
// chapters taught you to check. It compiles. `go vet` is happy. Run
// `go test -race -run TestPublishAndRead ./...` and the race detector
// has nothing to say about it, because every access really does go
// through an atomic.
//
// It still serves stale settings forever.
//
// Publish writes the settings, then bumps the version that announces
// them -- that half is right. Read loads them back in the *same*
// order, and that is the bug. A reader that loads the settings, gets
// preempted, and then loads the version comes away holding the old
// settings stamped with the new version. Every later Changed() call
// compares that version against itself, reports "nothing new", and
// the caller never refreshes. §11.4.4 has the callout.
//
// Two tests gate the fix, and the second one is the interesting half:
//
//  1. TestReaderNeverLatchesStaleSettings forces the interleaving and
//     fails today.
//  2. TestReadIsAtomicUnderLoad hammers it from many goroutines and
//     checks the same invariant statistically -- so a "fix" that only
//     handles the forced case does not pass.
//
// You may not add a mutex to Read: the point of this type is that
// readers never block. Fix the ordering, or collapse the two atomics
// into one.
type Store struct {
	version  atomic.Uint64
	settings atomic.Pointer[Settings]
}

// New returns a Store holding s at version 0.
func New(s *Settings) *Store {
	st := &Store{}
	st.settings.Store(s)
	return st
}

// Publish replaces the settings and announces them.
func (st *Store) Publish(s *Settings) {
	st.settings.Store(s)
	st.version.Add(1)
}

// Read returns the current settings and the version that names them.
func (st *Store) Read() (*Settings, uint64) {
	settings := st.settings.Load() // <- your move
	version := st.version.Load()
	return settings, version
}

// Changed reports whether anything has been published since the
// caller last read version `since`.
func (st *Store) Changed(since uint64) bool {
	return st.version.Load() != since
}

And the tests that gate it:

ch11/settings_test.go
package ch11

import (
	"fmt"
	"sync"
	"testing"
)

// Publish numbers each snapshot so a test can tell which one it is
// holding: after the Nth Publish, Timeout == N and version == N.
func publishSeq(st *Store, n int) {
	st.Publish(&Settings{Timeout: n, MaxRetries: n})
}

// Correctness first: whatever you change, the store must still hand
// back what was published, and the version must still advance.
func TestPublishAndRead(t *testing.T) {
	st := New(&Settings{Timeout: 0})

	got, v0 := st.Read()
	if got.Timeout != 0 {
		t.Fatalf("Read() = %+v; want Timeout 0", got)
	}

	publishSeq(st, 1)
	got, v1 := st.Read()
	if got.Timeout != 1 {
		t.Fatalf("after Publish, Read() = %+v; want Timeout 1", got)
	}
	if v1 <= v0 {
		t.Fatalf("version went %d -> %d; it must increase", v0, v1)
	}
	if !st.Changed(v0) {
		t.Fatalf("Changed(%d) = false after a Publish; want true", v0)
	}
	if st.Changed(v1) {
		t.Fatalf("Changed(%d) = true with nothing published since", v1)
	}
}

// The point of the exercise.
//
// Read returns a (settings, version) pair. The version is a promise
// about the settings: "these are the settings as of version N". For
// that promise to hold, the settings must never be OLDER than the
// version handed out with them -- if they are, a caller that stores
// the version and calls Changed() will be told it is current while
// holding data that is not.
//
// Timeout doubles as the publication sequence number, so the
// invariant is simply: settings.Timeout >= version.
func TestReaderNeverLatchesStaleSettings(t *testing.T) {
	st := New(&Settings{Timeout: 0})

	stop := make(chan struct{})
	var writer sync.WaitGroup
	writer.Go(func() {
		for i := 1; ; i++ {
			select {
			case <-stop:
				return
			default:
				publishSeq(st, i)
			}
		}
	})

	var readers sync.WaitGroup
	bad := make(chan string, 1)
	for range 8 {
		readers.Go(func() {
			for range 50000 {
				s, v := st.Read()
				if uint64(s.Timeout) < v {
					select {
					case bad <- fmt.Sprintf(
						"Read returned settings from publication %d "+
							"stamped with version %d", s.Timeout, v):
					default:
					}
					return
				}
			}
		})
	}
	readers.Wait()
	close(stop)
	writer.Wait()

	select {
	case msg := <-bad:
		t.Fatalf("%s\n\n"+
			"The settings are older than the version handed out with "+
			"them. A caller that remembers that version and calls "+
			"Changed() will be told it is up to date while holding "+
			"stale settings -- permanently.\n\n"+
			"Read loads the settings before the version, the same "+
			"order Publish writes them in. The reader has to be the "+
			"writer's mirror. See the callout in the chapter.", msg)
	default:
	}
}

// A reader that is merely BEHIND is fine -- Changed() tells it so and
// it refreshes. This test exists to stop the obvious cheat of making
// Read always report version 0, which would satisfy the invariant
// above by never claiming to be current.
func TestVersionStillTracksPublications(t *testing.T) {
	st := New(&Settings{Timeout: 0})
	for i := 1; i <= 50; i++ {
		publishSeq(st, i)
		s, v := st.Read()
		if v != uint64(i) {
			t.Fatalf("after %d publications Read reported version %d; "+
				"want %d", i, v, i)
		}
		if s.Timeout != i {
			t.Fatalf("after %d publications Read reported Timeout %d",
				i, s.Timeout)
		}
	}
}

Run it:

Terminal
$ go test -race ./ch11

It fails:

Terminal
--- FAIL: TestReaderNeverLatchesStaleSettings (0.00s)
    settings_test.go:92: Read returned settings from publication 45
        stamped with version 46
        The settings are older than the version handed out with
        them. A caller that remembers that version and calls
        Changed() will be told it is up to date while holding
        stale settings -- permanently.
        Read loads the settings before the version, the same
        order Publish writes them in. The reader has to be the
        writer's mirror. See the callout in the chapter.
FAIL
FAIL corebackend.dev/go-concurrency/ch11 0.581s

Two lines move, or one design changes. Both are correct answers, and the solution file argues for the second.

Done when: go test -race ./... in code/ch11/ reports ok for all three tests, and keeps reporting it under -count=5. TestVersionStillTracksPublications is there to block the obvious cheat — always reporting version 0 satisfies the invariant by never claiming to be current, and breaks Changed() entirely.
Two traps: you may not put a mutex in Read; the entire point of the type is that readers never block, and §11.2.7 measures what that is worth. And fixing Publish instead of Read fixes nothing — the writer’s order is already right, which is exactly why the bug is hard to see.
Where the files are: labs/go-concurrency/code/ch11/. A worked answer sits in solution/settings.go.txt, including the variant that removes the ordering rule altogether by collapsing the two atomics into one.

Further Reading

Next

You can now say what an atomic operation guarantees, what each one costs, and why a store is not a cheap load. You can order a pair of atomics so that a reader can trust the pair and not just each half. And—more useful than any of it—you can recognize the three shapes no atomic will ever express: an invariant across two values, operations that have to compose, and a state change that carries a side effect. Every primitive so far has been one you assemble yourself. Chapter 12 hands you the assembled ones: sync.Once for exactly-once initialization, sync.Map for the access patterns a mutex-guarded map handles badly, sync.Pool for object reuse, and sync.Cond for the waiting this chapter kept telling you to do with a channel.