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:

  1. If a receiver is waiting in recvq: copy v directly onto the receiver's stack, mark it runnable (into the sender P's runnext), done. The buffer is bypassed.
  2. Else if the buffer has room: copy v into buf[sendx], done.
  3. Else: enqueue a sudog (a record of "this G is waiting with this value") on sendq and 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: readers Load() with no locking; a writer builds a new value and Store()s it (copy-on-write). Configuration reloads and routing tables use it.

Choosing, as a checklist

  1. Passing a piece of work or a result from one goroutine to another, or broadcasting "stop"? → channel (or context cancellation).
  2. Several goroutines reading and writing shared state? → Mutex, held for short critical sections, never across I/O.
  3. A single word updated very frequently (counters, flags, a pointer to an immutable snapshot)? → atomics.
  4. Contended counter on the hot path of every request? → shard it.
  5. 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

  1. Walk through what happens inside ch <- v when a receiver is already waiting. Why is this faster than going through the buffer?
  2. When does sync.Mutex enter starvation mode, and what does it trade away?
  3. Why is a contended atomic increment 10x slower than an uncontended one, and how does sharding fix it?
  4. Which happens-before edge makes a buffered channel usable as a semaphore?
  5. Why can a data race on a string or []byte variable crash a Go program, while a race on an int "only" gives a stale value?