Channels, Mutexes, and the Memory Model¶
What this page is for¶
Go gives you two ways to coordinate goroutines — channels and the
sync/sync/atomic packages — and one set of rules that defines when
one goroutine is guaranteed to see another's writes: the Go memory
model. This page covers choosing between them on cost and semantics,
and why a racy program is broken even when it "works on my
machine". The hardware side (cache
coherence, reordering, fences) is in
Memory Model and Atomics;
this page is the Go side.
Measurements: Go 1.26.4, Apple M1 Pro, 8 Ps.
Inside a channel¶
make(chan T, n) allocates an hchan:
type hchan struct {
qcount uint // elements currently in the buffer
dataqsiz uint // n: buffer capacity
buf unsafe.Pointer // circular array of n elements
elemsize uint16
closed uint32
sendx uint // next slot to send into
recvx uint // next slot to receive from
recvq waitq // goroutines blocked in receive (sudog list)
sendq waitq // goroutines blocked in send
lock mutex // protects every field above
}
A send ch <- v takes lock and then, in order:
- If a receiver is waiting in
recvq: copyvdirectly onto the receiver's stack, mark it runnable (into the sender P'srunnext), done. The buffer is bypassed. - Else if the buffer has room: copy
vintobuf[sendx], done. - Else: enqueue a
sudog(a record of "this G is waiting with this value") onsendqand park the goroutine. It is woken by a receiver, which takes the value from the sudog.
Receive is symmetric. close wakes every waiter: receivers get the
zero value with ok == false, blocked senders panic. select locks
all involved channels in a fixed (address) order, checks each case,
and if none is ready enqueues a sudog on every channel, so a
select over many channels costs proportionally more.
So a channel is a mutex-protected queue plus goroutine parking. That determines its costs:
| Operation (measured) | ns/op |
|---|---|
| Buffered send + receive in the same goroutine (no parking) | 23 |
| Unbuffered round trip between two goroutines (two park/wake cycles) | 245 |
sync.Mutex Lock + Unlock, uncontended |
13.5 |
atomic.Int64.Add, uncontended |
6.9 |
Channels are the right tool for transferring ownership of data and for signalling events (done, cancellation, "work available"). They are the wrong tool for protecting a shared counter or map: that is a mutex's job, at a fraction of the cost and with clearer code.
Channel axioms worth memorizing¶
| nil channel | closed channel | |
|---|---|---|
| send | blocks forever | panic |
| receive | blocks forever | zero value immediately, ok == false |
| close | panic | panic |
A nil channel in a select is a disabled case — the standard trick
to stop selecting on a channel that is drained: set it to nil.
Only the sender side closes, and only when no other sender can
still send; with multiple senders, use a sync.WaitGroup and a
separate goroutine that closes after Wait().
sync.Mutex internals¶
sync.Mutex is a 32-bit state word plus a semaphore:
- Fast path: one CAS from unlocked to locked. No contention, no syscall — the 13.5 ns above.
- Spinning: a goroutine that fails the CAS may spin a few times (only on multicore, only if the lock holder is running) hoping the holder releases soon.
- Parking: otherwise it sleeps on the runtime semaphore (parked like a channel waiter — no thread blocked).
- Normal vs. starvation mode: in normal mode, a woken waiter competes with newly arriving goroutines, which usually win because they are already running on a CPU. That maximizes throughput but can starve a waiter. If a waiter fails to acquire the lock for more than 1 ms, the mutex switches to starvation mode: ownership is handed directly to the waiter at the head of the queue, and new arrivals queue at the tail. It switches back when the queue drains or a waiter waited less than 1 ms.
Contention is what costs¶
8 goroutines incrementing one shared counter as fast as possible:
| Strategy | ns/op |
|---|---|
sync.Mutex around n++ |
139 |
atomic.Int64.Add on one counter |
71 |
| Sharded: one cache-line-padded atomic per goroutine, summed on read | 2.0 |
The atomic is 10x slower contended than uncontended (71 vs. 6.9 ns):
every increment must take exclusive ownership of the cache line, which
ping-pongs between cores
(Memory Hierarchy and CPU Cache).
Sharding removes the shared line. The padding matters: without it,
neighbouring shards share a cache line (false sharing) — measured
at 45x slower in CPU and Cache Efficiency.
This is the design behind per-P structures in the runtime itself
(mcache, per-P run queues, sync.Pool's per-P lists).
The rest of sync¶
| Type | Use it for | Watch out |
|---|---|---|
RWMutex |
Read-mostly data where read critical sections are long enough to matter | A waiting writer blocks new readers, so a recursive RLock deadlocks if a writer arrives in between. Under heavy read load on many cores, readers still contend on the reader count; RWMutex is not automatically faster than Mutex — measure |
sync.Once / OnceValue / OnceFunc |
Lazy initialization exactly once | If the function panics, Once considers it done; OnceValue re-panics on every call |
WaitGroup |
Waiting for a set of goroutines | Add must happen before the goroutine starts (not inside it). Go 1.25's wg.Go(func(){...}) does Add(1) + go + Done correctly for you |
sync.Cond |
Waiting for a condition on mutex-protected state | Always Wait() in a for !condition loop; spurious and stolen wakeups happen. Usually a channel is simpler, but Cond.Broadcast is the efficient "wake all waiters" primitive (used by the storage engine's write stall in Architecture Overview) |
sync.Map |
Keys written once and read many times, or disjoint key sets per goroutine | Slower than map + Mutex for general read/write mixes; not typed |
sync.Pool |
Reusing temporary objects to reduce GC pressure | Contents may be dropped at any GC; never use it for anything that must persist (connections) |
errgroup.Group (x/sync) |
Fan-out with first-error cancellation and SetLimit(n) bounded concurrency |
The context from WithContext is cancelled when Wait returns |
The Go memory model¶
The memory model answers: if goroutine A writes x and goroutine B
reads x, is B guaranteed to see A's write? Only if the write
happens before the read. Without such an ordering, the two accesses
form a data race, and the program's behavior is not defined by the
language in any useful way.
Happens-before edges come from synchronization:
| Edge | Guarantee |
|---|---|
| Within a goroutine | Program order |
go f() |
Everything before the go statement happens before f starts |
| Channel send / receive | A send happens before the corresponding receive completes |
| Unbuffered channel | A receive happens before the corresponding send completes |
| Buffered channel of capacity C | The k-th receive happens before the (k+C)-th send completes (this is what makes a buffered channel a counting semaphore) |
close(ch) |
Happens before a receive that returns because the channel is closed |
Mutex.Unlock |
Happens before the next Lock returns |
Once.Do(f) |
Completion of f happens before any Do returns |
sync/atomic |
All atomic operations behave as if executed in a single, sequentially consistent order; an atomic store that is observed by an atomic load creates an edge |
The model's headline guarantee is DRF-SC: a program without data races behaves as if all goroutines were interleaved on a single core (sequential consistency). With a race, Go still limits the damage compared to languages where any race is undefined behavior (a racing read of a word-sized value sees some value that was written), but multi-word values — strings, slices, interfaces — can be torn: a reader sees the pointer from one write and the length from another, which is memory-unsafe and can crash or read out of bounds.
A broken pattern, and why¶
var config *Config
var loaded bool
func get() *Config {
if !loaded { // racy read
mu.Lock()
if !loaded {
config = load()
loaded = true // may become visible BEFORE config's fields
}
mu.Unlock()
}
return config // may be nil, or point to a half-initialized Config
}
The fast-path reader takes no lock and reads no atomic, so there is no
happens-before edge from the writer's config = load() to the
reader's use of it. The compiler may reorder or cache the plain loads;
the CPU (especially ARM, which reorders more freely than x86) may make
loaded = true visible before the Config fields. Fixes, in order of
preference: sync.OnceValue(load), or an atomic.Pointer[Config]
that is loaded on the fast path. The race detector
reports the original code immediately.
Rules that follow¶
- Any variable shared between goroutines, where at least one access is a write, must be protected by a mutex, a channel handoff, or atomics. "It's just a bool" or "it's just a counter for metrics" is not an exception.
- Don't mix atomic and non-atomic access to the same variable.
- Prefer the typed atomics (
atomic.Int64,atomic.Bool,atomic.Pointer[T]) — they make non-atomic access impossible to write by accident. - An
atomic.Pointer[T]to an immutable value is the idiomatic read-mostly pattern: readersLoad()with no locking; a writer builds a new value andStore()s it (copy-on-write). Configuration reloads and routing tables use it.
Choosing, as a checklist¶
- Passing a piece of work or a result from one goroutine to another,
or broadcasting "stop"? → channel (or
contextcancellation). - Several goroutines reading and writing shared state? → Mutex, held for short critical sections, never across I/O.
- A single word updated very frequently (counters, flags, a pointer to an immutable snapshot)? → atomics.
- Contended counter on the hot path of every request? → shard it.
- Waiting for N goroutines and collecting the first error? → errgroup.
Common pitfall¶
Holding a mutex while doing I/O or calling out to unknown code. A cache
does mu.Lock(); v, ok := m[k]; if !ok { v = fetchFromDB(k); m[k] = v };
mu.Unlock(). With a 5 ms database call inside the critical section,
every other request — including cache hits for unrelated keys — waits
behind it: throughput collapses to ~200 operations per second
regardless of core count, and the mutex profile
(pprof mutex/block profiles, see
Profiling with pprof) shows
all goroutines waiting at the same Lock. Release the lock around the
slow call, and deduplicate concurrent fetches of the same key with
golang.org/x/sync/singleflight so a miss does not trigger a
thundering herd of identical queries.
Review questions¶
- Walk through what happens inside
ch <- vwhen a receiver is already waiting. Why is this faster than going through the buffer? - When does
sync.Mutexenter starvation mode, and what does it trade away? - Why is a contended atomic increment 10x slower than an uncontended one, and how does sharding fix it?
- Which happens-before edge makes a buffered channel usable as a semaphore?
- Why can a data race on a
stringor[]bytevariable crash a Go program, while a race on anint"only" gives a stale value?