Day 25 — Race detector, leaks & deadlocks

Updated

July 30, 2026

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.

Important

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:

  1. Two goroutines access the same memory location concurrently
  2. At least one access is a write
  3. 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/write

Theory 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/riscv64 race 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:

  1. Read at stack A
  2. Previous write at stack B
  3. Goroutine creation stacks
  4. Your code lines highlighted

Method:

  1. Identify the shared variable
  2. Identify the missing synchronization
  3. Choose fix: mutex, atomic, channel ownership, or redesign
  4. Re-run until clean under load (-count=10 or 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
  • select without exit case
  • Condition wait

Go 1.26 experimental profile:

GOEXPERIMENT=goroutineleakprofile go test -race ./...
# pprof: goroutineleak profile / debug endpoint when enabled

Theory (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:

  1. Concurrent map write (map[string]int without lock)
  2. Concurrent slice append from two goroutines
  3. Check-then-act without lock (if !exists { m[k]=v })
  4. Publishing a struct field without sync
  5. WaitGroup-add race (optional)

For each:

go test -race ./racy -run TestName

Save 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:

  1. Double-lock same mutex in one goroutine (self-deadlock)
  2. Lock order inversion between two goroutines (use timeouts in a supervisor test so the test suite doesn’t hang forever—or run manually)
  3. Unbuffered channel self-send in one goroutine

Document stack traces from runtime where applicable.


Lab 4 — Leak the pool, then plug it

  1. Start workers that send on unbuffered results
  2. Main returns early on first error without draining or cancelling
  3. Observe NumGoroutine growth

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=20 clean on labs
  • Documented one deadlock and one leak scenario
  • Knows Go 1.26 goroutine leak profile exists
  • Added -race to 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.