Day 25 — Race detector, leaks & deadlocks
Day 25 — Race detector, leaks & deadlocks
Stage III · ~3h (theory-heavy)
Goal: Read and fix data races with the race detector, recognize deadlock classes, and develop a practical leak-hunting habit—including awareness of Go 1.26 goroutine leak profiles.
A program that “works” without -race can still be wrong. Data races are undefined behavior in Go’s memory model. Make -race non-optional for concurrent packages.
Why this day exists
Symptoms of concurrency bugs:
- Flaky tests
- Impossible counter values
- Heisenbugs under load
- Gradual memory growth (leaked goroutines)
- Full process freeze (deadlock)
Tools:
| Tool | Catches |
|---|---|
go test -race / go run -race |
Data races at runtime |
| Runtime deadlock detector | All goroutines blocked |
runtime.NumGoroutine |
Coarse leak signal |
Go 1.26 goroutineleak profile (experiment) |
Many blocked-forever Gs |
| Manual review | Logic races, lost wakeups |
Theory 1 — What is a data race?
A data race occurs when:
- Two goroutines access the same memory location concurrently
- At least one access is a write
- No happens-before synchronization orders them
Not every bug is a data race (wrong lock ordering can be race-free yet deadlocked). Not every race detector silence means correct (logical races, atomic misuse across fields).
Example
var n int
go func() { n = 1 }()
go func() { fmt.Println(n) }() // race: concurrent read/writeTheory 2 — Enabling the race detector
go run -race .
go test -race ./...
go build -race -o app .Properties:
- Instrumentation slows CPU-bound code significantly (often 2–20×)
- Uses more memory
- Supported on major platforms (Go 1.26:
linux/riscv64race support added)
- Not a substitute for production always-on race detection (too slow)—use in CI and local tests
CI recommendation for concurrent modules:
go test -race -count=1 ./...-count=1 disables test caching so flaky races reappear.
Theory 3 — Reading a race report
Typical report sections:
- Read at stack A
- Previous write at stack B
- Goroutine creation stacks
- Your code lines highlighted
Method:
- Identify the shared variable
- Identify the missing synchronization
- Choose fix: mutex, atomic, channel ownership, or redesign
- Re-run until clean under load (
-count=10or higher)
False confidence
Absence of a race report on one run does not prove freedom—only that that execution didn’t trip instrumentation. Still, -race finds the vast majority of classic races.
Theory 4 — Deadlocks
Global deadlock
All goroutines are asleep; runtime prints:
fatal error: all goroutines are asleep - deadlock!
Partial deadlock / livelock
Some Gs stuck; process still “alive.” Harder—needs timeouts, metrics, leak profiles.
Classic lock ordering
G1: lock A → lock B
G2: lock B → lock A
Fix: global lock order (always A then B).
Channel deadlock patterns
- Send without receiver
- WaitGroup mismatch
- Mutual wait on two channels
Theory 5 — Goroutine leaks
A leaked goroutine typically blocks forever on:
- Channel op
- Mutex
selectwithout exit case
- Condition wait
Go 1.26 experimental profile:
GOEXPERIMENT=goroutineleakprofile go test -race ./...
# pprof: goroutineleak profile / debug endpoint when enabledTheory (reachability): if a G blocks on primitive P and nothing reachable from runnable code can unblock P, the G is leaked.
You do not need production enablement today—know the tool exists and prefer designs that cancel and join.
Coarse check in tests
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
runScenario(t)
// allow scheduler to settle
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before+1 { // slack
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("goroutines before=%d after=%d", before, runtime.NumGoroutine())
}Fragile but educational; prefer structured cancel (errgroup) over heuristics.
Theory 6 — Fix strategies catalog
| Bug | Fix |
|---|---|
| Shared counter | atomic or mutex |
| Shared map | mutex / sync.Map (know tradeoffs) |
| Init race | sync.Once |
| Message ownership unclear | channel transfer exclusive ownership |
| Early return leaves workers blocked on send | buffered results, context cancel, or drain |
| Timer/ticker leak | Stop + drain pattern |
Worked example — race then fix
package bank
import "sync"
type Account struct {
mu sync.Mutex
balance int
}
func (a *Account) Deposit(v int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += v
}
func (a *Account) Balance() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}Test:
func TestAccountConcurrent(t *testing.T) {
var a Account
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
a.Deposit(1)
}()
}
wg.Wait()
if a.Balance() != 1000 {
t.Fatalf("got %d", a.Balance())
}
}go test -race ./...Remove the mutex temporarily and watch the race report—then restore.
Lab 1 — Break five programs
Workspace: ~/lab/90daysofx/01-go/day25
Create racy/ with intentional races:
- Concurrent map write (
map[string]intwithout lock)
- Concurrent slice append from two goroutines
- Check-then-act without lock (
if !exists { m[k]=v })
- Publishing a struct field without sync
- WaitGroup-add race (optional)
For each:
go test -race ./racy -run TestNameSave one full race report in notes/race-report.txt (or journal).
Lab 2 — Fix all five
Implement production-shaped fixes. Suite must pass:
go test -race -count=20 ./...Lab 3 — Deadlock museum
Write programs that:
- Double-lock same mutex in one goroutine (self-deadlock)
- Lock order inversion between two goroutines (use timeouts in a supervisor test so the test suite doesn’t hang forever—or run manually)
- Unbuffered channel self-send in one goroutine
Document stack traces from runtime where applicable.
Lab 4 — Leak the pool, then plug it
- Start workers that send on unbuffered
results
- Main returns early on first error without draining or cancelling
- Observe
NumGoroutinegrowth
Fix with either:
- Context cancel (Day 24), or
- Drain remaining results after error, or
- Buffered channel sized to in-flight
Re-check goroutine counts. Optionally experiment with Go 1.26 leak profile if available:
GOEXPERIMENT=goroutineleakprofile go test -race ./...Common gotchas
| Gotcha | Fix |
|---|---|
Only running -race once |
CI + -count |
| Ignoring races in benchmarks | Don’t ship racy code even if “fast” |
sync.Map as default |
Often plain map+mutex is clearer |
Fixing races with time.Sleep |
Never—use real synchronization |
| Assuming deadlock detector finds partial hangs | It won’t |
Checkpoint
- Can define data race in three conditions
- Read a real race report and fixed the root cause
go test -race -count=20clean on labs
- Documented one deadlock and one leak scenario
- Knows Go 1.26 goroutine leak profile exists
- Added
-raceto personal checklist for Stage III
Commit
git add .
git commit -m "day25: race detector labs — break, fix, leak plug"Write three personal gotchas before continuing.
Tomorrow
Day 26 — Concurrency patterns catalog: fan-out/fan-in, or-done, tee, bridge, and error propagation shapes you will reuse for years.