Day 22 — Mutex, WaitGroup, atomic

Updated

July 30, 2026

Day 22 — Mutex, WaitGroup, atomic

Stage III · ~3h (theory-heavy)
Goal: Protect shared memory with sync and atomic, know WaitGroup rules cold, and choose mutex vs channel deliberately—with race-clean counters and a small benchmark.

Note

Channels move ownership; mutexes protect invariants on shared structures. Experts use both. Dogma is for tutorials; measurement is for production.

Why this day exists

You already can spawn goroutines and pass messages. Real maps, caches, connection pools, and metrics still need:

  • Mutual exclusion (Mutex)
  • Read-mostly sharing (RWMutex)
  • Cheap counters (atomic)
  • One-time init (Once)
  • Correct join (WaitGroup)

Incorrect locking is deadlock; no locking is data races. Both are production incidents.


Theory 1 — Critical sections and invariants

A mutex guards an invariant: “while I hold the lock, these fields are consistent.”

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

Rules:

  1. Same lock for all accesses to the protected fields.
  2. Prefer defer Unlock unless you need early unlock for performance.
  3. Do not copy a struct containing a mutex after first use (lock copies are broken).
  4. Keep critical sections small—no network I/O under lock if avoidable.

Copying hazard

c1 := Counter{}
c2 := c1 // copies mutex state — undefined / broken

Pass *Counter, store mutex by value inside the struct, never copy the outer value after use.


Theory 2 — RWMutex (read-mostly)

var (
    mu sync.RWMutex
    m  = map[string]int{}
)

func get(k string) (int, bool) {
    mu.RLock()
    defer mu.RUnlock()
    v, ok := m[k]
    return v, ok
}

func set(k string, v int) {
    mu.Lock()
    defer mu.Unlock()
    m[k] = v
}
  • Many concurrent RLock holders allowed
  • Lock is exclusive
  • Writers waiting can starve readers depending on usage—don’t assume magic fairness

If write rate is high, RWMutex may lose to a plain Mutex. Benchmark (Lab 3).


Theory 3 — atomic package

For independent scalars (counters, flags, pointers with care):

var hits atomic.Int64
hits.Add(1)
fmt.Println(hits.Load())
var flag atomic.Bool
flag.Store(true)
if flag.Load() { ... }
var p atomic.Pointer[Config]
p.Store(&Config{...})
cfg := p.Load()

atomics are not free composition

Two atomic fields do not update as one transaction:

// NOT atomic as a pair
a.Add(1)
b.Add(1)

For multi-field invariants, use a mutex (or immutable snapshot under atomic pointer).


Theory 4 — WaitGroup contracts

var wg sync.WaitGroup
wg.Add(1)       // before go, or under careful accounting
go func() {
    defer wg.Done()
    work()
}()
wg.Wait()
Rule Why
Add before the goroutine runs Else Wait may return early
Done exactly once per Add unit Too few → hang; too many → panic
Don’t use negative Add casually Easy to desync
Prefer Add(n) then loop start Clear accounting

WaitGroup must not be copied after first use.


Theory 5 — sync.Once and lazy init

var (
    once sync.Once
    client *Client
)

func getClient() *Client {
    once.Do(func() {
        client = dial() // runs once
    })
    return client
}
  • Concurrent callers block until Do finishes
  • If Do panics, future Do calls are skipped (document carefully)
  • Great for process-wide init; less great for per-request logic

Theory 6 — Mutex vs channel decision table

Situation Prefer
Shared map/cache with random access Mutex / RWMutex
Transfer ownership of a job/result Channel
Counting events Atomic or mutex
Pipeline stages Channels
Guarding struct invariants Mutex
Crossing package boundaries with lifecycle Often channel or callback + context

Anti-pattern: channel as a mutex

// works, but usually worse than sync.Mutex
token := make(chan struct{}, 1)
token <- struct{}{}
// critical section
<-token

Use when you already need select/timeout on lock acquisition; otherwise Mutex is clearer and faster.


Worked example — three counters compared

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

const workers = 8
const perWorker = 100_000

func mutexCount() int {
    var (
        mu sync.Mutex
        n  int
        wg sync.WaitGroup
    )
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < perWorker; j++ {
                mu.Lock()
                n++
                mu.Unlock()
            }
        }()
    }
    wg.Wait()
    return n
}

func atomicCount() int64 {
    var (
        n  atomic.Int64
        wg sync.WaitGroup
    )
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < perWorker; j++ {
                n.Add(1)
            }
        }()
    }
    wg.Wait()
    return n.Load()
}

func channelCount() int {
    ch := make(chan int, workers)
    var wg sync.WaitGroup
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            local := 0
            for j := 0; j < perWorker; j++ {
                local++
            }
            ch <- local
        }()
    }
    go func() {
        wg.Wait()
        close(ch)
    }()
    total := 0
    for v := range ch {
        total += v
    }
    return total
}

func main() {
    fmt.Println("mutex", mutexCount())
    fmt.Println("atomic", atomicCount())
    fmt.Println("channel-shard", channelCount())
}

All should equal workers * perWorker. The channel version avoids shared increments by sharding—often the best design.


Lab 1 — Thread-safe map wrapper

Workspace: ~/lab/90daysofx/01-go/day22

mkdir -p ~/lab/90daysofx/01-go/day22 && cd ~/lab/90daysofx/01-go/day22
go mod init example.com/day22

Implement:

type SafeMap struct {
    // mu + map[string]string
}

func (s *SafeMap) Set(k, v string)
func (s *SafeMap) Get(k string) (string, bool)
func (s *SafeMap) Len() int

Spawn many goroutines setting/getting keys. Run:

go test -race ./...
go run -race .

Lab 2 — Fix WaitGroup bugs

Write three broken snippets (or tests that recover?):

  1. Add inside goroutine without prior coordination → flaky Wait
  2. Missing Done → hang (use short timeout parent in a test helper)
  3. Extra Done → panic

Document symptoms. Ship only the fixed version in main.


Lab 3 — Benchmark mutex vs atomic vs sharded

go test -bench=. -benchmem -race
# note: -race slows benches; also run without -race for timing
go test -bench=. -benchmem

Table in journal:

Approach ns/op (approx) Notes
Mutex
Atomic
Channel shard

Do not over-generalize from one machine—learn the method.


Lab 4 — Once for expensive init

Simulate expensive dial with time.Sleep. Call getClient from 100 goroutines; assert dial runs once (atomic counter inside Do). Race-clean.


Common gotchas

Gotcha Fix
Forgetting Unlock defer Unlock
Lock ordering A→B vs B→A Consistent global order
Holding lock across slow I/O Copy data out; unlock; I/O
Copying structs with mutex Pointer receivers; no value copy
Using atomic for multi-field updates Mutex or immutable snapshot
RWMutex everywhere “for speed” Benchmark; simple Mutex often wins

Checkpoint

  • Implemented race-clean SafeMap
  • Can explain when atomic is insufficient
  • WaitGroup Add/Done rules written from memory
  • Compared three counter strategies
  • Used Once correctly
  • go test -race clean

Commit

git add .
git commit -m "day22: mutex, atomic, WaitGroup, Once; counters + SafeMap"

Write three personal gotchas before continuing.


Tomorrow

Day 23 — Worker pools & pipelines: bound concurrency, job queues, and a concurrent fetcher that does not melt the host.