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:
// 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
// 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()
}
// 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.
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.
- The
sync/atomicpackage: the typed atomics (Go 1.19+) and the seven operations they support - What
Load,Store,Add,Swap,CompareAndSwap,AndandOrguarantee, 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
- 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 12context.Contextfor cancellation — Chapter 13, and usually the right answer where §11.2.1 reaches for a flag- The
unsafepackage — custom atomics overunsafe.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?”
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. AndandOr(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 thewg.Add(1)/defer wg.Done()pair.go fixlearned to rewrite the legacy function API into typed atomics (Go 1.27) — theatomictypesmodernizer; §11.5 shows the mapping it applies.
Everything else here — the memory model, what CAS guarantees, why atomics do not compose — is version-independent.
-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.
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:
- ✓ Two goroutines access the same memory location
- ✓ At least one access is a write
- ✗ 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.
// 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.
// Illustrative snippet — not a complete program
var counter atomic.Int64
counter.Add(1) // Increment
value := counter.Load() // Read
counter.Store(0) // Write
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.
The typed API (Go 1.19+) is preferred:
- Type-safe: compiler catches mismatched types
- Self-documenting:
atomic.Int64declares 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:
atomic.Boolboolfalseatomic.Int32int320atomic.Int64int640atomic.Uint32uint320atomic.Uint64uint640atomic.Uintptruintptr0atomic.Pointer[T]*Tnilatomic.ValueanynilZero value is ready to use—no initialization required:
// 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
}
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.
// 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.
LoadStoreSwapCompareAndSwapAddAndOrNote 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.
// 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.
// 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
// Illustrative snippet — not a complete program
// ✓ CORRECT — both sides go through the package
var counter int64
atomic.AddInt64(&counter, 1)
value := atomic.LoadInt64(&counter)
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.
// 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.
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 LoadLoad 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.
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.
// 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.
// 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...
}
}
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?
// 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
}
Update, and what does the fix look like?
Data race. Atomic Load/Store protects the pointer, not the pointed-to data.
// 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):
// 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.
// 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)
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 OneC 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.
// 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:
// 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?
// 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()
}
Race between RecordError and Stats:
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:
// 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:
// 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 producederrors > 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.
// 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.
// 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
}
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:
- Compares the current value to an expected
oldvalue - If they match, replaces with
newand returnstrue - If they don’t match, leaves unchanged and returns
false
// 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
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.
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:
// 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
// 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 loops guarantee system-wide progress (lock-free property):
- If this goroutine’s CAS succeeds → we exit
- If this goroutine’s CAS fails → another goroutine succeeded
- 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.
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.
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.
// 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
}
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.
// 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.
// Illustrative snippet — not a complete program
old := counter.Load()
counter.CompareAndSwap(old, old+1)
Even better for simple increments:
// 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?
// Illustrative snippet — not a complete program
var counter atomic.Int64
// Attempt to increment
counter.CompareAndSwap(counter.Load(), counter.Load()+1)
Problem: The two Load() calls are separate operations:
// 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:
Fix: Store the loaded value in a variable:
// Illustrative snippet — not a complete program
for {
old := counter.Load()
if counter.CompareAndSwap(old, old+1) {
break
}
}
Better fix: Use Add for simple increments:
// 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:
// 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:
// 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.
// 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 ValueAdd returns the new value; And, Or and Swap return the old one.
// 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.
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
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
Load()Load() TStore(v)Store(val T)Add(d)Add(delta T) TSwap(v)Swap(new T) TCompareAndSwap(o,n)CompareAndSwap(old, new T) boolMethod 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.
// 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]atomic.Valueatomic.Pointer[T]
atomic.Value
atomic.Pointer[T]
atomic.Value
atomic.Pointer[T]
atomic.Value
atomic.Pointer[T]
atomic.Value
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.
// 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:
// Illustrative snippet — not a complete program
for {
old := config.Load() // the pointer actually stored
next := &Config{...}
if config.CompareAndSwap(old, next) {
break
}
}
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.
// 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:
// 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.
// 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:
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.
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:
// Illustrative snippet — not a complete program
func GetConfig() *Config {
v := configValue.Load()
if v == nil {
return nil // Not yet initialized
}
return v.(*Config)
}
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:
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.
// 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"
}
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.
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.
// 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:
// 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:
// 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
// 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:
// 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
Data race on the non-atomic access
Use typed atomics; all access via methods
Copy loses atomic semantics
Pass by pointer; never copy
Data race on pointed-to fields
Copy-on-write: create new, then Store
Fields updated separately; inconsistent view
Use mutex or pointer to immutable struct
Lost updates, incorrect state
Always check result; use retry loop
atomic.Value with varying typesPanic at runtime
All Stores must use same concrete type
atomic.ValuePanic
Store typed nil: (*T)(nil), or use atomic.Pointer[T]
Crash from misalignment
Use typed atomics
Stale expected value
Store Load result in variable
Never Copy Atomic Values
// 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
// 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
atomic.Int32/64atomic.Uint32/64atomic.Boolatomic.Pointer[T]atomic.Value- 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?
// 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
}
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:
// Illustrative snippet — not a complete program
func (m *Metrics) Get() int64 {
return atomic.LoadInt64(&m.requests)
}
Better: Use typed atomics:
// 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?
// Illustrative snippet — not a complete program
func Increment() int64 {
counter.Add(1)
return counter.Load()
}
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:
// Illustrative snippet — not a complete program
func Increment() int64 {
return counter.Add(1) // Returns the new value atomically
}
3. Why does this panic?
// Illustrative snippet — not a complete program
var cache atomic.Value
func Init() {
cache.Store(map[string]int{})
}
func Reset() {
cache.Store([]string{})
}
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?
// Illustrative snippet — not a complete program
var flag atomic.Bool
result := flag.Swap(true)
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:
// Illustrative snippet — not a complete program
if !flag.Swap(true) {
// First caller wins—perform initialization
}
5. Why does this CAS always fail?
// 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)
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:
// 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]. LoadandStoreare the foundation—use them for all reads and writes to atomic variables.Addreturns the NEW value, not the old. This differs from C/C++ fetch_add.Swapreturns the OLD value after storing the new one—useful for read-and-reset patterns.CompareAndSwapenables 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.Valuestores arbitrary types but requires type assertions and has stricter rules.AndandOr(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 vetcatches the copy; nothing catches the mixed access but-race. - Memory ordering guarantee: a
Storehappens-before anyLoadthat 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:
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.
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.
When in doubt, use a mutex. Atomics are an optimization—reach for them when:
- You have a proven performance bottleneck (profiling shows it)
- The state is genuinely simple (single value, no relationships)
- 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
// 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)
}
hchan)Load)select+default)Store)close, then freeStore(false))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.
- 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.
// 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:
// 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
}
}
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.
// 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:
- Independence: Each counter is modified independently—no invariants span multiple fields
- Simplicity: Just incrementing or adding—no complex logic
- Frequency: Metrics are updated on every request (potentially millions per second)
- Read pattern: Metrics are written frequently, read occasionally (for dashboards)
The same method written both ways:
// 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()
}
// Illustrative snippet — not a complete program
// Atomic: one instruction, and it never blocks.
func (m *Metrics) Inc() {
m.count.Add(1)
}
sync.Mutexatomic.Int64sync.Mutex
atomic.Int64
sync.Mutex
atomic.Int64
sync.Mutex
atomic.Int64
sync.Mutex
atomic.Int64
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:
// 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
}
- Independent counters → Use atomics
- Counters with invariants across them → Use mutex or
atomic.Pointerto 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:
// 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:
// 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:
// 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
// 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:
// 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
}
§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.
sync.OnceValueSince Go 1.21 the whole listing above is one line:
// 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.
// 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.
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.
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.
- Use
atomic.Pointer[T](Go 1.19+) for type-safe pointer atomics - Use
atomic.Valuewhen: - 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:
// 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.
// 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
// 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)
}
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.
// 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:
// 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
}
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.
If you need a consistent view of BOTH the map and the counters:
// 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 isb.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.
atomic.Int64.Loadatomic.Pointer[T].Loadatomic.Int64.Addatomic.Int64.Storeatomic.Int64.CompareAndSwapsync.RWMutex RLock+RUnlocksync.Mutex Lock+Unlockb.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.
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 LoadLoad 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.
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
// 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.
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:
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
Summary: When Atomics Fit
Use atomics when ALL of these apply:
- ✓ Operating on a single value
- ✓ Using simple operations (Load, Store, Add, Swap, CAS)
- ✓ High frequency (thousands+ per second) OR read-heavy config
- ✓ No invariants with other values
- ✓ Approximate consistency is acceptable (for multi-field reads)
atomic.Int64atomic.Boolatomic.Boolatomic.Int32atomic.Int64atomic.Uint64atomic.Pointer[T]atomic.Int64atomic.Int64atomic.Int64The 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:
- Write the correct version with mutexes.
- Measure, with
pprofand the mutex profile (§9.4). - Find the contention. If there isn’t any, stop here.
- Replace the single-value fields the profile pointed at — and only those.
- 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
This reasoning is flawed for several reasons:
- 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. - 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.
- 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.
- 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?
- A. Tracking the number of active goroutines in a worker pool
- B. A user session with fields:
LastAccess,RequestCount,IsAuthenticated - C. A cache with both a
map[string]Dataand ahitCount int64
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
IsAuthenticatedchanges, likely need to updateLastAccess - 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
// 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?
// 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
}
This code has a data race.
The config map is not protected. A reader calling GetConfig() might:
- See
enabled == true - Read
configwhileEnable()is still writing to it
The atomic bool only protects the bool itself, not the map.
Fix using atomic.Pointer:
// 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?
// 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
}
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:
// 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?
// Illustrative snippet — not a complete program
type Logger struct {
mu sync.Mutex
file *os.File
lineCount int64 // Should this be atomic?
}
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:
// 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—
Addis 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.
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:
- Multiple values to be consistent with each other
- Multiple operations to happen as a unit
- Complex logic between read and write
...atomics become dangerous. They give you the illusion of safety while hiding subtle bugs.
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
// 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.
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
// 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:
// 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.
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.
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.
// 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:
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
// 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:
// 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
// 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:
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
// 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)
// 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):
// 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):
// 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):
// 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):
// Illustrative snippet — not a complete program
// ✓ CORRECT: CAS guarantees only one succeeds
func (w *Worker) TryClaim() bool {
return w.claimed.CompareAndSwap(false, true)
}
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.
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
// 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:
Send()can pass the state check, then fail because socket is being closed- Handlers see
StateClosingbut resources aren’t fully cleaned up - No transactional guarantee: partial cleanup if error occurs
The Fix: Bundle the State With Its Side Effects
// 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
}
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
// 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
}
// 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:
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:
- What does “last error” mean with concurrent workers?
- If two workers error simultaneously, which one is “last”?
- Is the caller using this for debugging (approximate is fine) or for recovery logic (approximate is not)?
Semantic ambiguity in action:
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?
// 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
}
When the race detector complains, resist the urge to “just make it atomic.” Instead:
- Define the invariant: What consistency guarantee do callers need?
- Consider the semantics: What does this value mean under concurrency?
- 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.
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
// 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
// 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
// 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:
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:
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.
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
// 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 butAdd(1).
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
// 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:
- High contention: Multiple cores updating counters simultaneously
- High frequency: Millions of updates per second
- Performance-critical: Measured throughput problems
For most applications, the simpler struct is fine. Only add padding after profiling proves false sharing is the bottleneck.
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.
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
// 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
- All or nothing: If a variable needs synchronization, ALL access must be synchronized
- Consistent mechanism: Use the same synchronization for all access (all atomic, or all mutex-protected)
- No mixing: Don’t mix atomic and non-atomic access to the same memory location
// 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
}
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:
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
// 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
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
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:
// 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:
Decision Matrix
atomic.Int64atomic.Bool or a mutexatomic.Pointeratomic.Pointersync.CondThe 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
atomic.Pointer to structsync.CondAtomics 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?
// 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
}
Allow method?
Multiple race conditions:
- 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. - Check-then-act race: Between
r.requests.Load() < r.limitandr.requests.Add(1), other goroutines can increment, exceeding the limit. - Multi-value inconsistency:
windowandrequestscan be in inconsistent states. A goroutine might see the new window but old request count.
Fix with mutex:
// 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?
// 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]
}
Multiple bugs:
- Race on nil check: Two goroutines can both see
m == nil, both create new maps, one’s map gets lost. - Modifying through atomic pointer:
(*m)[key] = valuemodifies the map that other goroutines are reading. This is a data race on the map itself. - Map is not thread-safe: Even with atomic pointer, concurrent map read/write is a race.
Fix with copy-on-write:
// 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:
// 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
}
}
The bug is in worker startup timing:
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:
// 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:
// 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)
}
The atomic only tracks count, not actual connections. There’s no way to:
- Reuse connections: Released connections are closed, not returned to a pool
- Enforce consistency: Count can drift from actual connections if errors occur
- 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:
// 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.
Use atomics when ALL of these conditions are met:
- 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
- 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
- Lock-free progress is required (optional) - Real-time systems where blocking is unacceptable - Signal handlers (can’t use mutexes) - Priority inversion concerns
- 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.Pointerprotects 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:
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
// 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.
// 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())
}
}
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.
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
// 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
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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.
// 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
}
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.
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:
// 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
}
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:
// 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:
// 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.
// 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:
// 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.
// 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:
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:
// 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)
// 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:
// 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:
// 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:
// 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
}
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:
// 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:
// 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.
// 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:
// 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:
- Read/write ratio > 100:1
- Collection is small (copying is O(n))
- Reads must never block
- Writes can tolerate retry overhead
11.4.9 Generation Counter
Generation counters detect changes without comparing entire data structures—useful for cache invalidation and consistency checks.
// 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:
// 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:
// 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.
// 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.
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.
// 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
}
// 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.
-race — and Know What It Cannot Tell YouThe 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.
Call Counter
// 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
// 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
// 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:
Decision 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:
Self-Check Questions: Atomic Patterns
Each question links back to the section it comes from. Work out your answer before opening the panel.
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:
- Handler reads
shutdown→ false Shutdownsets the flag and callsWait()- The counter is zero, so
Wait()returns immediately - The caller logs “shutdown complete”
- 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.
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)
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.Oncedoesn’t retry even if the function fails) - Reset/reinitialize the resource (
sync.Oncecan’t be reset)
Tradeoffs: More complex code, but more flexibility. For simple one-time init that can’t fail, sync.Once is cleaner.
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:
With Swap(0), only one thread gets the accumulated value—it’s a single atomic operation that reads and clears simultaneously.
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:
But with the writer fixed and the reader still reading data first, the same outcome arrives by the other road:
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—
Addfor 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
Type-Method Matrix
atomic.Boolatomic.Int32atomic.Int64atomic.Uint32atomic.Uint64atomic.Uintptratomic.Pointer[T]atomic.ValueOperation Semantics
// 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
// 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:
// 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
// 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:
- First
Storesets the concrete type forever - All subsequent
Storecalls must use the same concrete type Loadbefore anyStorereturnsnil- Cannot store
nildirectly
atomic.Pointer[T] Quick Reference
// 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:
- Storing pointer types (avoids type assertion)
- Type safety at compile time is desired
- Using Go 1.19+
Memory Ordering Guarantees
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:
- A
Loadthat observes aStoreestablishes happens-before - All memory writes before
Storeare visible after the correspondingLoad - Every atomic operation in the program falls into one sequentially consistent order—not just the ones touching a single variable. That is what makes the ordering rule below work
- Non-atomic accesses are NOT automatically synchronized
Critical ordering rule for version/generation patterns. Both sides are part of the rule; fixing only the writer leaves the bug intact.
// 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()
// 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
atomic.BoolSwap(true), Load()atomic.Uint64Add(1)atomic.Int64Add(1), Add(-1)atomic.Uint64Add(1) returns new IDatomic.Int64Swap(0) to read-and-resetatomic.Pointer[T]Store(new), Load()atomic.Uint32 + CASCompareAndSwap(from, to)atomic.Int64 + CASatomic.Uint32 + atomic.Pointeratomic.Pointer[[]T] + CASatomic.Uint64Add(1) after data storeatomic.Uint64CompareAndSwap(0, id)Decision Flowchart
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
// 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
// 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
// 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
// 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).
// 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)
v.Load()atomic.LoadInt64(&v)v.Store(x)atomic.StoreInt64(&v, x)v.Add(x)atomic.AddInt64(&v, x)v.Swap(x)atomic.SwapInt64(&v, x)v.CompareAndSwap(old, new)atomic.CompareAndSwapInt64(&v, 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
Reader latches stale data under the newest version, permanently
Writer ascending (data, then version); reader descending (version, then data)
Lost updates under contention
Wrap CAS in for loop
Data races on underlying struct
Always create new struct, store pointer
A core burned doing nothing
Block on a channel. Gosched() yields the P but leaves the goroutine runnable, so the CPU is still spent (§11.3.9)
Lost increments in interval metrics
Use Swap(0) to read-and-reset
Inconsistent state between fields
Use mutex instead
Performance Characteristics
atomic.Int64.LoadMOVQatomic.Pointer[T].LoadMOVQatomic.Int64.StoreXCHGQatomic.Int64.AddLOCK XADDQatomic.Int64.CompareAndSwapLOCK CMPXCHGsync.RWMutex RLock+RUnlocksync.Mutex Lock+Unlockselect with defaultLoadis cheap and it scales; every other atomic costs about the same as every other atomic.Storeincluded — it isXCHGQ, notMOVQ.- Uncontended, a mutex is about 2x an atomic read-modify-write, not 10x, widening to about 3.4x under contention. Against a read the atomic is in a different category: ~9x cheaper, and the only operation here that gets more throughput when you add cores.
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
Test pattern for concurrent correctness:
// 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
// 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
atomic.Int64atomic.Boolatomic.Pointer[T]atomic.Valueatomic.Uint32Swap(0)0→10→idChapter 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:
Load/Store– Read and writeAdd– Increment/decrement (returns NEW value)Swap– Exchange (returns OLD value)CompareAndSwap– Conditional update
Use atomics when:
- Operating on a single value
- Using simple operations (Load, Store, Add, Swap, CAS)
- Access is high frequency (thousands+ per second) or read-heavy
- No invariants with other values
- Approximate consistency is acceptable
Don’t use atomics when:
- Multiple values must be consistent together
- Operations need to compose into larger atomic units
- State changes have side effects (cleanup, notifications)
- You need check-then-act without CAS
- There’s no proven performance need
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:
Loadis 1.17 ns and is the one operation that scales with cores; a read-modify-write is 5.34 ns and does not- An uncontended mutex is 10.70 ns — about 2x an atomic
Add, not 10x - For reads the gap is a category difference (~9x, and it scales); for writes it only matters at millions of operations per second
Memory ordering: Store establishes happens-before with Load that observes the value. All writes before Store are visible after the corresponding Load.
Chapter Connections
sync.Once, sync.Map and sync.Pool are built on the primitives in this chaptercontext.Context is the right answer wherever §11.2.1 reaches for a flag and the worker can blockFinal Checklist
Before moving to Chapter 12, ensure you can:
- Name the seven operations and say which return the old value and which the new
- Explain why
Storecosts whatAddcosts andLoaddoes not - State the happens-before guarantee an atomic gives you, and what it does not cover
- Write a CAS loop that re-loads on failure, and say why CAS never fails spuriously
- Recognise the three shapes atomics cannot express: multi-value invariants, composed operations, and state changes with side effects
- Order a pair of atomics correctly in both directions — writer ascending, reader descending — and say what goes wrong if you only fix one side
- Decide between an atomic, a mutex and a channel from the access pattern rather than from instinct
- Read a benchmark that uses
RunParallelwithout mistaking aggregate throughput for per-operation cost - Fix the exercise: make
Readreturn a pair a caller can trust, without adding a lock
Exercise 11.1 — Give the Version Something to Promise
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.
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:
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:
It fails:
Two lines move, or one design changes. Both are correct answers, and the solution file argues for the second.
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.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.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
- The sync/atomic package documentation — short enough to read start to finish in one sitting, and the source for two things this chapter leans on: that
CompareAndSwapis defined as exactlyif *addr == old, with no spurious failure, and thatAndandOrreturn the value from before the operation whileAddreturns the value after. - The Go Memory Model — sync/atomic — two sentences long, and §11.1.8 is a gloss on them. The second one, that all atomic operations in a program fall into one sequentially consistent order, is what makes the reader-descending rule in §11.4.4 sound rather than merely plausible.
- Go 1.19 release notes — atomic types — where
atomic.Int64,atomic.Boolandatomic.Pointer[T]came from, and the reasoning for adding them over the function API they replaced. - Go 1.24 release notes — sync.Mutex — the mutex fast path was rewritten here. Any atomics-versus-mutex benchmark published before this overstates the mutex by roughly a factor of two, which is most of the comparisons you will find. Check the date before you believe the number.
go doc sync.WaitGroup.Add— one paragraph, and it is the paragraph §11.4.1 turns on: a positiveAddon a zero counter must happen before aWait. Worth reading on your own toolchain rather than online, since Go 1.25 addedWaitGroup.Goand now recommends it overAddoutright.- Rob Pike, *Go Proverbs* — for “Clear is better than clever”, which is the whole of §11.3 compressed into five words. Almost every anti-pattern in that section was somebody choosing a lock-free construction over a legible one.
- Ulrich Drepper, What Every Programmer Should Know About Memory (2007), sections 3.3 and 6.4 — where §11.3.7's cache-line story comes from. Old, and the numbers have all moved, but the mechanism has not: false sharing is still two cores fighting over a line neither of them is really contending for.
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.