Thread Safety Is About Invariants, Not Locks
Thread-safe code keeps its invariants true under every interleaving. Before reaching for a mutex, climb the fix ladder: don't share, don't mutate, then atomics, then locks.
1000 goroutines, each increments once. What gets printed?
var n int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); n++ }()
}
wg.Wait()
fmt.Println(n) // 1000? 987? 941? — depends on the runWhat "thread-safe" actually means
A piece of code is thread-safe if it stays correct under any interleaving of goroutines, with no extra coordination from the caller.
"Correct" means its invariants hold. That's the whole game:
| Code | Invariant |
|---|---|
| counter | n == number of increments |
| bank transfer | a.balance + b.balance is constant |
| cache | at most one value per key, loaded once |
| range | min <= max |
Locks, atomics, and channels are just tools to protect invariants. Start from the invariant, then pick the tool — not the other way around.
The three races you'll actually hit
1. Lost update (read-modify-write)
n++ is three steps: load, add, store. Two goroutines can interleave:
G1: load n (5)
G2: load n (5)
G1: store 6
G2: store 6 ← one increment lost
2. Check-then-act
The check is stale by the time you act on it.
if _, ok := cache[key]; !ok { // G1 and G2 both see "missing"
cache[key] = expensiveLoad(key) // both load, both write
}With a plain Go map this is worse than a duplicate load — concurrent writes crash the process with fatal error: concurrent map writes, which recover() cannot catch.
3. Visibility
Without synchronization, one goroutine is not guaranteed to ever see another's write.
var done bool
go func() { time.Sleep(time.Second); done = true }()
for !done {} // compiler may hoist the read — loops foreverThe Go memory model only promises visibility across a happens-before edge: a channel op, a mutex unlock/lock, an atomic, sync.Once, WaitGroup.Wait.
Catch them with the race detector
go test -race ./...
go run -race main.goWARNING: DATA RACE
Write at 0x00c000014098 by goroutine 8:
main.main.func1() main.go:12
Previous read at 0x00c000014098 by goroutine 7:
main.main.func1() main.go:12
Run -race in CI. It only flags races that execute during the run, so it needs tests that actually exercise concurrent paths.
The fix ladder — cheapest first
Each rung is cheaper to reason about than the one below it. Stop at the first one that works.
Rung 1: Don't share
If only one goroutine touches the data, there's nothing to protect. Give each worker its own state and merge at the end.
results := make([]int, workers) // each goroutine owns one slot
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
results[w] = count(chunks[w]) // no sharing
}(w)
}
wg.Wait()
total := sum(results)Or hand off ownership through a channel — "share memory by communicating":
jobs := make(chan Job)
go func() {
for j := range jobs { process(j) } // only this goroutine touches j
}()Rung 2: Don't mutate
Immutable values are safe to share freely. To "update", build a new value and swap the pointer atomically (copy-on-write):
type Config struct{ Timeout time.Duration; Hosts []string }
var cfg atomic.Pointer[Config]
func Reload(c *Config) { cfg.Store(c) } // writers replace the whole thing
func Current() *Config { return cfg.Load() } // readers never blockGreat for read-heavy data (config, routing tables). Rule: never mutate a *Config after publishing it.
Rung 3: Atomics — one variable, one invariant
var n atomic.Int64
go func() { n.Add(1) }()
var done atomic.Bool
for !done.Load() {} // visible nowAtomics fix the counter and the flag. They do not compose — two atomics are not one atomic operation (see next section).
Rung 4: Mutex — invariants spanning multiple fields
type Account struct {
mu sync.Mutex
balance int64
history []Tx
}
func (a *Account) Withdraw(amt int64) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amt { // check
return ErrInsufficient
}
a.balance -= amt // act
a.history = append(a.history, Tx{-amt}) // keep in sync
return nil
}Check and act sit inside the same critical section, so nothing can slip between them. Use sync.RWMutex only when reads vastly outnumber writes and profiling shows contention — it's slower than Mutex under light load.
Rung 5: Ready-made primitives
Don't hand-roll what the stdlib already ships:
// load exactly once, every caller waits for the same result
var loadCfg = sync.OnceValue(func() *Config { return readConfig() })
// fan-out with first-error cancellation
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
g.Go(func() error { return fetch(ctx, u) })
}
err := g.Wait()Safe parts, unsafe whole
Composing thread-safe pieces does not give you thread-safe code. Each call is atomic; the sequence isn't.
var m sync.Map
// ❌ check-then-act across two safe calls
if _, ok := m.Load(key); !ok {
m.Store(key, newSession()) // another goroutine may have stored in between
}
// ✅ one operation
v, loaded := m.LoadOrStore(key, newSession())Same trap with atomics:
type Range struct{ min, max atomic.Int64 }
// ❌ each store is atomic; min <= max is not
func (r *Range) Set(lo, hi int64) {
r.min.Store(lo)
// reader here sees new min, old max → min > max possible
r.max.Store(hi)
}The invariant min <= max spans two fields, so it needs one lock around both:
type Range struct {
mu sync.Mutex
min, max int64
}
func (r *Range) Set(lo, hi int64) {
r.mu.Lock(); defer r.mu.Unlock()
r.min, r.max = lo, hi
}Lock the invariant, not the field. Ask "which values must change together?" — that set is the scope of your lock.
When to use which
| Situation | Tool | Cost |
|---|---|---|
| Work splits cleanly per goroutine | Confinement, merge at end | Free |
| Pipeline / ownership handoff | Channels | Low, adds coupling |
| Read-heavy, rarely replaced | atomic.Pointer + immutable value |
Very low reads |
| Single counter or flag | atomic.Int64 / atomic.Bool |
Very low |
| Invariant across ≥2 fields | sync.Mutex |
Low, watch hold time |
| Lazy init | sync.Once / sync.OnceValue |
Free after first call |
| Disjoint-key map, write-once keys | sync.Map |
OK; otherwise use map + Mutex |
The takeaway: write down the invariant first. If you can't name it, you can't protect it — and no amount of sync.Mutex sprinkled over individual fields will save you. Then climb the ladder, and run -race in CI to keep yourself honest.