Step 1 of 4 · Reading · ~4 min
Learn
Concurrency at Scale
sync Primitives in Depth: Atomics, Cond, Pool
You will update shared numbers without a lock, name the events that make one goroutine's write visible to another, and choose between sync.Cond, sync.Pool and sync.Map.
In Go Intermediate you guarded state with Mutex, RWMutex and Once. This lesson is what sits underneath them.
Atomics: one word, no lock
sync/atomic works on one int32, int64 or uint32 through a pointer: AddInt64(&x, d), LoadInt64(&x), StoreInt64(&x, v), SwapInt64(&x, v) (stores v, returns the old value) and CompareAndSwapInt64(&x, old, new) (CAS), which stores new only if x still equals old and reports whether it did. Once a variable is touched atomically, every access must be atomic: a plain x++ beside an AddInt64 is still a race. Declare 64-bit variables at package level so they are 8-byte aligned. (atomic.Int64 and friends arrived in Go 1.19; here use the functions.)
The CAS retry loop
CAS builds any read-modify-write on one word: read, compute, swap only if nobody changed it, otherwise go round again. Five goroutines compete for three tickets and the stock must never go below zero:
taken: 3 stock: 0
The tempting version checks and then acts:
Each call is atomic, the pair is not. It can print taken: 5 stock: -2, differing from run to run. The same flaw sinks if v > max { max = v } on a shared maximum: two goroutines read 5, one stores 9, the other stores 7, and the 9 is lost.
An atomic protects one word
If lo <= hi must hold, two atomic variables cannot keep it: a reader can see the new lo with the old hi. Publish an immutable snapshot instead with atomic.Value:
5 50
Load returns nil before the first Store, and every Store must use one concrete type. Never modify a stored value.
Happens-before
The Go memory model says a read is guaranteed to see a write only when a chain of happens-before edges links them. Inside one goroutine, program order is the chain. Between goroutines the edges are: Unlock before the next Lock; a send before its matching receive completes; close(ch) before a receive that returns because of the close; Once.Do's function returning before any Do returns; wg.Done before the Wait it releases; and atomic operations on the same variable.
No edge joins the goroutine to the loop. The compiler may read ready once, so the loop can spin forever, or exit and print an empty line. -race reports it. Having the goroutine close a channel that main receives from creates the edge, and the program prints hi.
Cond, Pool, Map
| Need | Use | Rule |
|---|---|---|
| Sleep until mutex-guarded state changes | sync.Cond | Wait unlocks, sleeps, re-locks. Signal wakes one waiter, Broadcast all |
| Reuse scratch objects | sync.Pool | Get may call New; any GC may empty the pool; reset what you Get |
| Write-once cache, or goroutines on disjoint keys | sync.Map | Load, Store, LoadOrStore, Range; otherwise a map plus RWMutex is typed and faster |
The producer appends under the lock, then calls ready.Signal(). The loop is mandatory: between the wake-up and the re-lock another consumer may have taken the item.
Common mistakes
- Check then act on shared state, such as
if v > max { max = v }: the printed maximum is lower than the real one. Use a CAS loop. - Mixing plain and atomic access to one variable: the plain read can see a stale value.
- A once-only flag taken with
AddInt32: every caller succeeds and you count many winners. UseCompareAndSwapInt32(&flag, 0, 1); only one caller seestrue. - Starting a running maximum at 0: all-negative input prints
max=0. Start at the smallestint64.
Your exercise
The starter reads the input and starts the goroutines; its plain observe loses updates and lets several goroutines take the claim. Make it safe using only sync/atomic.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…