17. The sync package: mutexes, WaitGroup, Once, atomics
17.1 sync.Mutex and sync.RWMutex
type SafeCounter struct {
mu sync.Mutex
n map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.n[key]++
}
sync.RWMutex allows any number of concurrent readers OR one writer
(never both) — use it when reads vastly outnumber writes, exactly as Day
14's per-endpoint request counter does.
17.2 sync.WaitGroup
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
check(u)
}(url)
}
wg.Wait() // blocks until all Done() calls have happened
Common bug: calling wg.Add(1) inside the goroutine instead of
before go starts it — this creates a race where Wait() might return
before all goroutines have even registered themselves.
17.3 sync.Once
var once sync.Once
var instance *Config
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig() // guaranteed to run exactly once, ever
})
return instance
}
17.4 The sync/atomic package
For simple counters, atomics avoid mutex overhead entirely:
var requests atomic.Int64
requests.Add(1)
current := requests.Load()
17.5 Run with -race regularly
go test -race ./...
go run -race main.go
The race detector instruments memory accesses and catches races that actually execute during that run. It cannot prove a program is race-free, but running it in CI and on representative workloads finds many bugs that otherwise show up only under load.
17.6 Real-world example
Day 14's concurrent request counter, hammered by 100 goroutines and run
under -race, is a tiny reproduction of the bug class behind "works on my
machine, corrupts data under load in prod" — and it's why many Go teams
run at least part of their test suite with -race before merging.
Applied in the series: the TCP/IP guide's lbdrain and the HTTPS guide's resilience labs use atomic.Bool, atomic.Uint64, and mutex-guarded maps in real servers.