Skip to content
Lesson 2 of 7

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:

go
taken: 3 stock: 0

The tempting version checks and then acts:

go

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:

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

go

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

NeedUseRule
Sleep until mutex-guarded state changessync.CondWait unlocks, sleeps, re-locks. Signal wakes one waiter, Broadcast all
Reuse scratch objectssync.PoolGet may call New; any GC may empty the pool; reset what you Get
Write-once cache, or goroutines on disjoint keyssync.MapLoad, Store, LoadOrStore, Range; otherwise a map plus RWMutex is typed and faster
go

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. Use CompareAndSwapInt32(&flag, 0, 1); only one caller sees true.
  • Starting a running maximum at 0: all-negative input prints max=0. Start at the smallest int64.

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.

Up nextGenericsGenerics and Reflection

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…