sync, Atomics & the Memory Model
Mutexes, sync.Once, atomics, happens-before, the race detector
- 01
A fan-out over 200 ids returns in microseconds with an empty result slice, even though every goroutine plainly runs
wg.Done(). The onlywg.Add(1)is the first line inside the goroutine. What is wrong?EasyWaitreturns the instant the counter is zero, and when the loop finishes nobody has incremented it yet —Addhas to run on the goroutine that starts the work, before thegostatement.// ❌ Wait can reach zero before any goroutine has run its Add. var wg sync.WaitGroup for _, id := range ids { go func() { wg.Add(1) // too late: the loop may already be done defer wg.Done() … - 02
After a deploy, every request that hits the cache wedges the service: the first one returns, all the rest hang inside
Get. The cache-miss path still passes its tests. What does the hit path do?EasyIt returns while still holding the mutex — the
Unlocksits below theif, so a cache hit never releases the lock and every laterLockblocks forever.// ❌ the cache-hit path returns with the mutex still held func (c *Cache) Get(k string) ([]byte, error) { c.mu.Lock() if v, ok := c.m[k]; ok { return v, nil // never unlocked: every later Get blocks forever } … - 03
go vetprintsInc passes lock by value: main.Counter contains sync.Mutex, and after 100 increments the counter really does read0. What does the value receiver actually do?EasyA value receiver copies the whole struct — mutex and counter together — so every call locks a private copy, increments it, and throws it away when the method returns.
type Counter struct { mu sync.Mutex n int } // ❌ value receiver copies the struct, and the Mutex with it. … - 04
Lazy client init is guarded by
if !initialized { client = newClient(); initialized = true }. Under load the service opens three connection pools, and one request gets a client that is still nil. What replaces the flag?Easysync.Once— or, since Go 1.21,sync.OnceValue, which owns the flag, the lock and the result in one declaration.// ❌ two goroutines can both see initialized == false var initialized bool var client *http.Client func Client() *http.Client { if !initialized { … - 05
go test -racehas been green for months, every field access goes through the mutex, and the ledger still goes negative in production. What class of bug is this, and why can the detector not see it?MediumA race condition, not a data race —
-raceonly proves that no two goroutines touched the same memory unsynchronised; it says nothing about whether your critical sections were the right size.// ❌ -race is clean: every access is locked. Still wrong. func (a *Account) Withdraw(n int64) error { if a.Balance() < n { // RLock ... RUnlock return ErrInsufficient } a.add(-n) // Lock ... Unlock — a *different* critical section … - 06
In review someone defends an unsynchronised
boolflag: worst case a goroutine reads a stale value for one more iteration, and a single byte cannot tear anyway. Why is that argument wrong?MediumBecause a data race is not "a stale read" — the compiler and the CPU are both allowed to produce behaviour that no sequence of stale values can explain.
// ❌ "it is just a bool" — there is no such thing as a benign race var ready bool var payload *Report func producer() { payload = build() … - 07
A read-heavy lookup table was moved from
sync.Mutextosync.RWMutexand p99 got worse on a 14-core box: the benchmark reads 55 ns/op before and 65 ns/op after. Why did the read lock cost more?Medium**
RLockis not free concurrency — it is an atomic read-modify-write on one shared counter, so every reader still bounces the same cache line, and for a critical section of a few…// Apple M4 Pro, GOMAXPROCS=14, one map lookup inside the lock: // BenchmarkMutexRead-14 55.45 ns/op // BenchmarkRWMutexRead-14 65.00 ns/op <- the "read lock" is slower func BenchmarkMutexRead(b *testing.B) { var mu sync.Mutex b.RunParallel(func(pb *testing.PB) { … - 08
A high-water-mark gauge is written as
if g.max.Load() < n { g.max.Store(n) }. Every access is an atomic,-raceis clean, and the reported maximum is still regularly lower than the real one. What is missing?Medium**Each operation is atomic; the pair is not.
type Gauge struct{ max atomic.Int64 } // Go 1.19 type: 64-bit aligned everywhere // ❌ two atomics are not one atomic: both goroutines pass the compare, // and the smaller value can land last. func (g *Gauge) Observe(n int64) { if g.max.Load() < n { … - 09
Someone swapped a
map[int64]*Sessionbehind anRWMutexfor async.Mapin a session store with heavy create-and-expire traffic. It got slower and the size metric disappeared. Which two workloads issync.Mapactually for?Medium**Exactly two: a key that is written once and read many times — a cache that only grows — and goroutines working on disjoint sets of keys.
// ✅ pattern 1: written once, read forever — a compiled-codec registry var codecs sync.Map // map[string]Codec func codecFor(name string) (Codec, error) { if v, ok := codecs.Load(name); ok { return v.(Codec), nil … - 10
CI has run
go test -race ./...on every PR for a year and stayed green, yet a data race in the rate limiter reached production. What can the detector not do, and what does turning it on cost?MediumIt is a dynamic detector: it reports races between memory accesses that actually happened in that run, and says nothing about code paths the test never executed.
// The detector only sees what this test executes — so make it execute the race. func TestLimiterConcurrent(t *testing.T) { l := NewLimiter(100) var wg sync.WaitGroup for i := 0; i < 64; i++ { // one goroutine would find nothing wg.Add(1) … - 11
A
sync.Poolof encode buffers hits 80% under load and near 0% on a service that gets a request every few seconds — and RSS still spikes for minutes after one huge upload. What are you misreading aboutPool?Medium**A
Poolis a GC-scoped free list, not a cache: the runtime empties everyPoolat the start of each garbage collection, so nothing survives an idle period, and anything you put…var bufs = sync.Pool{New: func() any { return new(bytes.Buffer) }} func render(w io.Writer, v any) error { b := bufs.Get().(*bytes.Buffer) b.Reset() // ✅ reset on the way IN: Get may return a used one … - 12
A bounded queue built on
sync.Condnow needs per-caller timeouts and a clean shutdown, and the team's first idea is a background goroutine callingBroadcaston a ticker. What is the real fix?Medium**Replace the
Condwith a channel — aCondcannot be selected on, so a timeout or a cancellation can never be expressed against it, and that is the main reasonsync.Condis r…// ❌ Cond cannot be selected on: no timeout, no cancellation, no shutdown wake func (q *CondQueue) Pop() Job { q.mu.Lock() defer q.mu.Unlock() for len(q.jobs) == 0 { // `for`, never `if`: Wait reacquires L and the q.cond.Wait() // predicate may be false again by then … - 13
A worker reads
cfgafter a loader goroutine sets it. It is correct on your laptop and nil-panics once a week on the arm64 fleet. Wouldclose(ch),mu.Unlock(),once.Dooratomic.Storehave fixed it, and what is the rule they share?Hard**All four — each is a documented "synchronised before" edge in the Go memory model, and putting one between the write and the read is the only way a goroutine's write is guarantee…
var cfg *Config // ✅ four edges the memory model actually defines var done = make(chan struct{}) func publishByChannel() { cfg = load(); close(done) } // close -> receive func readByChannel() { <-done; use(cfg) } … - 14
Hot-path config is read on every request and reloaded every 30 seconds, and the fast path is
if cfg == nil { mu.Lock(); if cfg == nil { cfg = load() }; mu.Unlock() }.-raceflags it. Why is classic double-checked locking broken in Go, and what replaces it?Hard**The outer check is an unsynchronised read of a word another goroutine writes — a data race — and a racy pointer read can hand you a non-nil pointer to a struct whose fields have…
// ❌ textbook double-checked locking — and a data race in Go. // The fast-path read is unsynchronised, so it can observe a non-nil pointer // to a Config whose fields are not written yet. var cfg *Config var mu sync.Mutex … - 15
Two of four hundred goroutines are wedged forever, the process keeps serving traffic, and
fatal error: all goroutines are asleepnever fires. The stacks showtransferholding one account lock and waiting on another. What is the bug, and how do you make it impossible?Hard**A lock-order inversion:
transfer(x, y)takes x then y whiletransfer(y, x)takes y then x.// ❌ two orders, one cycle. transfer(x,y) and transfer(y,x) wedge here // forever, and the process keeps serving every other request. func transfer(a, b *Account, n int64) { a.mu.Lock() b.mu.Lock() a.balance -= n … - 16
Per-shard counters live in a
[]struct{ n atomic.Int64 }indexed by shard, and the benchmark gets slower as cores are added even though no two goroutines ever touch the same element: 34.98 ns/op against 13.59 ns/op for the identical code with the elements spread apart. What is the hardware doing?HardFalse sharing: eight 8-byte counters fit in one cache line, and coherency works per line, so a write to element 0 invalidates the line holding elements 1 through 7 on every other core.
// Apple M4 Pro, GOMAXPROCS=14, one atomic Add per op, no shared element: // BenchmarkFalseSharing-14 34.98 ns/op <- 8 counters per 64-byte line // BenchmarkPadded-14 13.59 ns/op <- one counter per line // ❌ 8 bytes each: eight of these share one cache line type packed struct{ n atomic.Int64 } …