sync, Atomics & the Memory Model
Irbisa · cheatsheetSeptember 19, 2026

sync, Atomics & the Memory Model

Mutexes, sync.Once, atomics, happens-before, the race detector

Middle Developer16 itemscompressed for a skim
  1. 01

    A fan-out over 200 ids returns in microseconds with an empty result slice, even though every goroutine plainly runs wg.Done(). The only wg.Add(1) is the first line inside the goroutine. What is wrong?

    Easy

    Wait returns the instant the counter is zero, and when the loop finishes nobody has incremented it yetAdd has to run on the goroutine that starts the work, before the go statement.

    // ❌ 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()
    …
  2. 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?

    Easy

    It returns while still holding the mutex — the Unlock sits below the if, so a cache hit never releases the lock and every later Lock blocks 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
        }
    …
  3. 03

    go vet prints Inc passes lock by value: main.Counter contains sync.Mutex, and after 100 increments the counter really does read 0. What does the value receiver actually do?

    Easy

    A 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.
    …
  4. 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?

    Easy

    sync.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 {
    …
  5. 05

    go test -race has 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?

    Medium

    A race condition, not a data race — -race only 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
    …
  6. 06

    In review someone defends an unsynchronised bool flag: worst case a goroutine reads a stale value for one more iteration, and a single byte cannot tear anyway. Why is that argument wrong?

    Medium

    Because 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()
    …
  7. 07

    A read-heavy lookup table was moved from sync.Mutex to sync.RWMutex and 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

    **RLock is 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) {
    …
  8. 08

    A high-water-mark gauge is written as if g.max.Load() < n { g.max.Store(n) }. Every access is an atomic, -race is 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 {
    …
  9. 09

    Someone swapped a map[int64]*Session behind an RWMutex for a sync.Map in a session store with heavy create-and-expire traffic. It got slower and the size metric disappeared. Which two workloads is sync.Map actually 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. 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?

    Medium

    It 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. 11

    A sync.Pool of 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 about Pool?

    Medium

    **A Pool is a GC-scoped free list, not a cache: the runtime empties every Pool at 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. 12

    A bounded queue built on sync.Cond now needs per-caller timeouts and a clean shutdown, and the team's first idea is a background goroutine calling Broadcast on a ticker. What is the real fix?

    Medium

    **Replace the Cond with a channel — a Cond cannot be selected on, so a timeout or a cancellation can never be expressed against it, and that is the main reason sync.Cond is 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. 13

    A worker reads cfg after a loader goroutine sets it. It is correct on your laptop and nil-panics once a week on the arm64 fleet. Would close(ch), mu.Unlock(), once.Do or atomic.Store have 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. 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() }. -race flags 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. 15

    Two of four hundred goroutines are wedged forever, the process keeps serving traffic, and fatal error: all goroutines are asleep never fires. The stacks show transfer holding 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 while transfer(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. 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?

    Hard

    False 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 }
    …