Decision Tree — Middle¶
Table of Contents¶
- What this file assumes
- The tree, with the reasoning behind each branch
- Branch 1: single value → atomics
- Branch 2: shared structure → mutex
- Branch 3: ownership transfer → channel
- Branch 4: one-shot signal → close
- Branch 5: wait for a condition → Cond or channel
- Branch 6: bounded concurrency → semaphore
- Branch 7: run-once → sync.Once family
- Worked examples
- Common middle-level mistakes
- Cheat sheet
- Self-assessment checklist
- Summary
- Further reading
What this file assumes¶
You can use each primitive in isolation. What you may still lack is the reflex to pick the right one quickly. This file turns the junior-level tree into a reasoned framework: not just "which node", but "why that node and not its neighbor".
The tree, with the reasoning behind each branch¶
What are you synchronizing?
├─ One word (counter/flag/pointer)? ........... ATOMIC
├─ A multi-field structure (map/slice/struct)? MUTEX (RWMutex if read-dominated)
├─ Handing a value from one goroutine to one? CHANNEL
├─ Telling many goroutines "go/stop" once? ..... close(chan struct{}) / context
├─ Waiting for an arbitrary condition? ......... channel if value-based, else Cond
├─ Limiting concurrency to N? .................. semaphore (buffered chan / x/sync)
└─ Running init exactly once? .................. sync.Once / OnceFunc / OnceValue
First match wins. The branches are ordered by how cheap and how unambiguous the answer is.
Branch 1: single value → atomics¶
If every operation reads/writes exactly one word and there's no companion invariant, use sync/atomic. Counters, flags, and immutable-snapshot pointers live here. The moment a second field must change in lockstep, leave this branch for the mutex branch.
Branch 2: shared structure → mutex¶
A map, slice, or struct with several fields that must stay mutually consistent needs a sync.Mutex. Promote to sync.RWMutex only when reads dominate and the read critical section does real work (not a single load — see the mutex-vs-atomic page).
Branch 3: ownership transfer → channel¶
When a value moves from producer to consumer and the producer should forget about it afterward, a channel models the handoff and the synchronization in one construct.
The distinguishing question vs the mutex branch: are you sharing access to state, or transferring ownership of a value? Sharing → mutex. Transferring → channel.
Branch 4: one-shot signal → close¶
To tell many goroutines "stop now" or "you may start", close a chan struct{}. A closed channel makes every receive return immediately, so all waiters wake at once. For cancellation that propagates across API boundaries, use context.Context (a closed channel underneath).
A closed channel cannot be reopened — if you need a repeatable gate, that's the Cond branch.
Branch 5: wait for a condition → Cond or channel¶
"Block until X is true." If X is "a value is available," use a channel. If X is an arbitrary predicate over shared state with no value handoff (config reloaded, gate reopened), use sync.Cond with Broadcast. Default to the channel; justify the Cond.
Branch 6: bounded concurrency → semaphore¶
To cap concurrent work at N, use a counting semaphore: a buffered chan struct{} of capacity N, or golang.org/x/sync/semaphore when you need weighted acquisition or context-aware blocking.
sem := make(chan struct{}, N)
sem <- struct{}{} // acquire
go func() { defer func(){ <-sem }(); work() }()
errgroup.Group.SetLimit(N) (x/sync) combines this with error propagation and is often the cleanest choice for "run these tasks, at most N at a time, stop on first error."
Branch 7: run-once → sync.Once family¶
Lazy initialization that must happen exactly once: sync.Once historically, or the Go 1.21 helpers sync.OnceFunc, sync.OnceValue, sync.OnceValues, which are more direct and harder to misuse.
var cfg = sync.OnceValue(func() Config { return loadConfig() })
c := cfg() // loads once; subsequent calls return the cached value
Worked examples¶
"Count cache hits." One word, no companion invariant → atomic. atomic.Int64.
"A request-scoped cache shared by handlers." Multi-field map → mutex (map + sync.Mutex); RWMutex if reads vastly dominate and lookups are non-trivial.
"Fan out 10k URLs, max 50 in flight, stop on first error." Bounded concurrency + error propagation → errgroup with SetLimit(50).
"Load the GeoIP database once, lazily." Run-once with a value → sync.OnceValue.
"Broadcast shutdown to all workers." One-shot signal to many → close(done) or context cancellation.
"Pause/resume a worker pool repeatedly." Repeatable gate, many waiters, no value handoff → sync.Cond + Broadcast (wrapped).
Common middle-level mistakes¶
- Mutex where a channel fits (and vice versa) — confusing "share state" with "transfer ownership".
- Atomic where two fields must agree — the one-word rule violated.
- A fresh channel for a one-shot broadcast when
close(done)is simpler. - Hand-rolled semaphore + WaitGroup + error channel when
errgroup.SetLimitdoes all three. sync.Onceboilerplate wheresync.OnceValueis cleaner (Go 1.21+).RWMutexreflexively without confirming reads dominate and sections are non-trivial.
Cheat sheet¶
| Need | Primitive |
|---|---|
| Counter / flag / snapshot ptr | atomic.* |
| Shared map/slice/struct | sync.Mutex / RWMutex |
| Value handoff | channel |
| One-shot broadcast | close(chan struct{}) / context |
| Bounded concurrency | chan struct{} / errgroup.SetLimit |
| Run once | sync.Once / OnceFunc / OnceValue |
| Arbitrary predicate wait | channel, else sync.Cond |
Self-assessment checklist¶
- I can walk the tree from memory and justify each branch.
- I can distinguish "share state" (mutex) from "transfer ownership" (channel).
- I reach for
errgroup.SetLimitfor bounded fan-out with errors. - I use the
sync.OnceValuefamily over rawsync.Once. - I default to channels for condition waits and justify any
Cond. - I can map five real tasks to the correct primitive without hesitation.
Summary¶
The decision tree is a sequence of questions, ordered cheapest-and-clearest first: one word → atomic; multi-field state → mutex; value handoff → channel; one-shot broadcast → close/context; bounded concurrency → semaphore/errgroup; run-once → the OnceValue family; arbitrary wait → channel, else Cond. The two questions that resolve most ambiguity are "how many words must change together?" (atomic vs mutex) and "am I sharing access or transferring ownership?" (mutex vs channel).
In senior.md we apply the tree to whole-system design: composing primitives across subsystem boundaries, choosing under measured contention, and the API consequences of each choice.
Further reading¶
- "Go Concurrency Patterns" — https://go.dev/blog/pipelines
golang.org/x/sync/errgroup— https://pkg.go.dev/golang.org/x/sync/errgroupsyncpackage overview — https://pkg.go.dev/sync- Bryan C. Mills, "Rethinking Classical Concurrency Patterns"