Day 22 — Mutex, WaitGroup, atomic
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.
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:
- Same lock for all accesses to the protected fields.
- Prefer
defer Unlockunless you need early unlock for performance.
- Do not copy a struct containing a mutex after first use (lock copies are broken).
- Keep critical sections small—no network I/O under lock if avoidable.
Copying hazard
c1 := Counter{}
c2 := c1 // copies mutex state — undefined / brokenPass *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
RLockholders allowed
Lockis 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
Dofinishes
- If
Dopanics, futureDocalls 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
<-tokenUse 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/day22Implement:
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() intSpawn 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?):
Addinside goroutine without prior coordination → flaky Wait
- Missing
Done→ hang (use short timeout parent in a test helper)
- 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=. -benchmemTable 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
Oncecorrectly
go test -raceclean
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.