Concurrency Bugs¶
Three families¶
Concurrency bugs in Go fall into three families, each with its own detection tool:
| Family | What goes wrong | Primary tool |
|---|---|---|
| Data races | Two goroutines access the same memory without synchronization, at least one writing | The race detector (-race) |
| Logic races | Every access is synchronized, but the interleaving still produces a wrong result (check-then-act, lost wakeups, wrong flush conditions) | Stress/load tests, model checks, careful review |
| Liveness bugs | Deadlocks, leaks, starvation: something never happens | Goroutine dumps, leak detectors, timeouts in tests |
The memory-model background is in Channels, Mutexes, and the Memory Model.
Outputs are real (Go 1.26.4, darwin/arm64), paths shortened.
The race detector¶
go test -race ./...
go run -race ./cmd/server
go build -race -o server-race ./cmd/server # a race-enabled binary for staging/canaries
The detector instruments every memory access and synchronization operation and tracks happens-before relations with vector clocks (ThreadSanitizer). It reports a race when two accesses to the same address, at least one a write, are not ordered by any happens-before edge. Cost: typically 5–10x slower and 5–10x more memory.
type Stats struct{ hits map[string]int }
func main() {
s := &Stats{hits: map[string]int{}}
var wg sync.WaitGroup
for _, p := range []string{"/a", "/b"} {
wg.Add(1)
go func() {
defer wg.Done()
s.hits[p]++ // two goroutines, one map, no lock
}()
}
wg.Wait()
}
==================
WARNING: DATA RACE
Read at 0x00c00009a090 by goroutine 8:
runtime.mapassign_fast64ptr()
internal/runtime/maps/runtime_fast64.go:374 +0x38c
main.main.func1()
race/main.go:19 +0x9c
Previous write at 0x00c00009a090 by goroutine 7:
runtime.mapaccess2_faststr()
internal/runtime/maps/runtime_faststr.go:161 +0x2ac
main.main.func1()
race/main.go:19 +0xc8
Goroutine 8 (running) created at:
main.main()
race/main.go:17 +0xe4
Goroutine 7 (finished) created at:
main.main()
race/main.go:17 +0xe4
==================
Found 1 data race(s)
exit status 66
Read it in four parts: the two accesses (which goroutine, read or
write, which line — main.go:19 both times), and where each
goroutine was created (main.go:17). The runtime frames
(mapassign...) only tell you it was a map. The process exits with
status 66 when races were found, which is what fails CI.
What the race detector cannot do:
- It only finds races that happen during the run. A race in a code
path that the tests don't execute concurrently is invisible. Tests
must actually run things in parallel (
t.Parallel(), many goroutines, realistic load). - It does not find logic races — the protocol server's flush deadlock had perfectly synchronized memory accesses and was invisible to it.
- It does not find deadlocks.
Run -race in CI on every test, and run a race-enabled build under
realistic load in staging occasionally: production traffic exercises
interleavings that tests never do.
Logic races: check-then-act¶
The most common logic race is a check and an action that are each atomic but not atomic together:
if _, ok := cache.Load(key); !ok { // check
v := compute(key) // other goroutines also get here
cache.Store(key, v) // act: compute ran N times
}
if balance.Load() >= amount { // check
balance.Add(-amount) // act: two withdrawals both pass the check → negative balance
}
No data race exists — sync.Map and atomics are synchronized — but the
invariant breaks. Fixes: put the check and the act in one critical
section (mu.Lock() around both), use an atomic primitive that does
both (LoadOrStore, CompareAndSwap in a retry loop), or deduplicate
with singleflight. In databases, the same bug is the lost update and
write skew (Iterators, Snapshots, and Transactions).
Deadlocks¶
The four conditions — mutual exclusion, hold and wait, no preemption, circular wait (Deadlocks) — all hold for Go mutexes. Go-specific shapes:
| Shape | Example |
|---|---|
| Lock ordering (AB-BA) | transfer(a, b) and transfer(b, a) concurrently — the dump in Panics, Stack Dumps, and Delve |
| Recursive locking | A method holding mu calls another method that also locks mu; Go mutexes are not reentrant |
RWMutex recursive read lock |
RLock held, a writer queues, the same goroutine RLocks again → blocked behind the writer, which waits for the first RLock |
| Channel cycles | Goroutine A sends to B and waits for B's reply; B is blocked sending to A |
| Unbuffered send with no receiver while holding a lock | The would-be receiver needs the same lock |
WaitGroup.Wait inside a goroutine counted by the same WaitGroup |
Waits for itself |
Prevention:
- Global lock order: always lock accounts by ascending ID; document the order for any code that holds two locks.
- Never hold a lock while calling out (callbacks, channel sends, I/O, other packages): copy what you need, unlock, then call.
- Timeouts on anything that waits for another party
(
context), so a deadlock becomes an error with a stack trace rather than a hang.
Detection: the runtime's all goroutines are asleep only fires when
every goroutine is blocked — rare in servers. In practice: goroutine
dumps (look for many goroutines in [sync.Mutex.Lock] for minutes),
the mutex profile, and tests with a timeout (go test -timeout=60s
prints all goroutine stacks when it fires).
Testing time-dependent code with synctest¶
Code with timers, tickers, timeouts, and background goroutines is hard
to test: real sleeps make tests slow and flaky. testing/synctest
(Go 1.25) runs a test inside a bubble with a fake clock: time only
advances when every goroutine in the bubble is blocked, and then jumps
straight to the next timer.
A TTL cache with a background janitor goroutine that deletes expired entries every minute:
func TestExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := New(time.Hour)
stop := make(chan struct{})
defer close(stop)
c.StartJanitor(time.Minute, stop)
c.Set("k", "v")
time.Sleep(59 * time.Minute) // fake clock: returns instantly
if _, ok := c.Get("k"); !ok {
t.Fatal("expired too early")
}
time.Sleep(2 * time.Minute)
synctest.Wait() // run every bubble goroutine until all are blocked again
if c.Len() != 0 {
t.Fatalf("janitor did not remove expired entry, len=%d", c.Len())
}
})
}
--- PASS: TestExpiry (0.00s)
61 minutes of simulated time in under a millisecond, deterministically.
synctest.Wait() is the key primitive: "let everything that can run,
run" — it replaces the time.Sleep(10 * time.Millisecond) "give the
goroutine a chance" that makes ordinary tests flaky. The bubble also
reports a deadlock if all its goroutines block with no timer pending,
and synctest.Test fails if goroutines started in the bubble are still
running when the test function returns — a built-in leak check.
A testing toolbox for concurrent code¶
| Tool | Catches |
|---|---|
go test -race |
Data races in executed interleavings |
go test -count=100 -race -run TestX |
Rare interleavings — run flaky-looking tests many times |
-cpu=1,2,8 |
Bugs that appear only with real parallelism (or only without it) |
testing/synctest |
Deterministic timers, timeouts, background loops |
go.uber.org/goleak |
Goroutines still running after a test |
| Parallel benchmarks / load tests | Logic races and deadlocks under contention |
go test -timeout |
Converts hangs into failures with full stack dumps |
| Model-based tests | Results checked against a sequential model (Testing and Benchmarking) |
Common pitfall¶
"Fixing" a race by adding time.Sleep. A test is flaky because it
checks a value before a background goroutine has updated it; someone
adds time.Sleep(100 * time.Millisecond) before the assertion, and the
test passes — locally. On a loaded CI machine the goroutine sometimes
takes 150 ms, and the test is flaky again; and the sleep has hidden the
real problem, which is that the code offers no way to know when the
update happened. Make the synchronization explicit: the code under
test returns a channel or accepts a callback when done, the test waits
on that (with a timeout), or the test runs inside synctest and uses
synctest.Wait(). A sleep in a test is a guess about scheduling, and
scheduling guesses are what concurrency bugs are made of.
Review questions¶
- What exactly does the race detector report, and why can it miss real races?
- Give an example of a logic race with no data race. How do you fix it?
- Why are Go mutexes' non-reentrancy and
RWMutex's writer preference both sources of deadlocks? - How does
synctestmake a test that waits 61 minutes run instantly, and what doessynctest.Wait()guarantee? - Why is
time.Sleepin a test not a fix for a race?