Data Races, the Detector That Finds Them, and the Mutex That Fixes Them
What a data race is, why an unsynchronized counter comes out wrong and varies, reading a real WARNING: DATA RACE report from go run -race, and fixing it with sync.Mutex. Why the detector only sees code that runs. Compiled and run against Go 1.26.5.
Everything so far has been about goroutines communicating through channels, where ownership of a value passes cleanly from one goroutine to the next. But sometimes goroutines don’t pass a value along; they reach for the same piece of memory at the same time. One increments a counter while another reads it. That is a data race, and it is the defining hazard of concurrent programming: two goroutines accessing the same memory location concurrently, at least one of them writing, with nothing ordering the accesses. The result isn’t a crash you can catch. It’s a wrong answer, produced silently, that changes from run to run.
This chapter is the one the whole series is building toward. We’ll write a race, watch it produce wrong numbers, point Go’s race detector at it and read the report it prints, and then fix it with a mutex. Every number and every line of output below was run against Go 1.26.5.
A counter that loses count
Here’s a thousand goroutines each adding one to a shared counter. The final total should obviously be 1000.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
count := 0
for range 1000 {
wg.Go(func() {
count++ // read, add one, write back — no synchronization
})
}
wg.Wait()
fmt.Println("count:", count)
}
Run five times, it printed count: 974, then 963, 965, 973, and 963. Never 1000, and never the same wrong number twice. The reason hides inside count++, which looks atomic but isn’t. It’s three operations: read the current value, add one, write the result back. When two goroutines run those steps interleaved, both read the same starting value, both add one, and both write back the same result. Two increments, one net effect. The lost updates are exactly the gap between your total and 1000, and because the interleaving depends on scheduling, that gap wanders every run. A wrong answer that varies is the signature of a data race.
The race detector
You could stare at count++ and reason your way to the bug, but you won’t always be that lucky, and the race that bit you in production is rarely the one in a six-line main. So Go ships a race detector, built into the toolchain. Add -race to run, test, or build and the compiler instruments every memory access; at runtime it watches for two goroutines touching the same address with no happens-before relationship between them, and when it catches one it prints a report and fails.
Pointed at the counter above, go run -race . printed:
==================
WARNING: DATA RACE
Read at 0x00c00019e048 by goroutine 9:
main.main.func1()
.../main.go:14 +0x2e
Previous write at 0x00c00019e048 by goroutine 7:
main.main.func1()
.../main.go:14 +0x44
Goroutine 9 (running) created at:
sync.(*WaitGroup).Go()
.../src/sync/waitgroup.go:238 +0x72
Goroutine 7 (finished) created at:
sync.(*WaitGroup).Go()
.../src/sync/waitgroup.go:238 +0x72
==================
count: 903
Found 2 data race(s)
exit status 66
Read it top to bottom, because it tells you everything. WARNING: DATA RACE, then the two conflicting accesses: a read at address 0x...e048 by goroutine 9, and the previous write to that same address by goroutine 7. Both point at main.go:14, which is the count++ line, so the detector is telling you precisely which memory (one address) and which code (one line) collided, and which two goroutines were involved. Below that, where each goroutine was created, both traced back to WaitGroup.Go. At the bottom, Found 2 data race(s) and exit status 66. That exit code is deliberate: the detector forces a nonzero exit on any race it finds (66 is its default), so a race fails your build and your CI turns red instead of shrugging. The count: 903 still prints because the program ran to completion; the detector reports the race, it doesn’t stop the program.
Two things about the detector are worth stating plainly. It has essentially no false positives: if it reports a race, you have a race, full stop. And it is a runtime tool, not a static one, which is the catch we’ll come back to.
The fix: sync.Mutex
The bug is that the read-add-write of count++ can interleave. A mutex (mutual exclusion lock) fixes it by making that sequence indivisible: only one goroutine holds the lock at a time, so only one goroutine is inside the critical section, and the increment completes fully before the next goroutine can start its own.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex // zero value is a ready-to-use, unlocked mutex
count := 0
for range 1000 {
wg.Go(func() {
mu.Lock()
defer mu.Unlock()
count++
})
}
wg.Wait()
fmt.Println("count:", count)
}
Three runs printed count: 1000, 1000, 1000. Exact, every time. And go run -race on this version printed count: 1000 with a clean exit and no warning: the lock establishes the happens-before ordering the detector was looking for, so there’s no race left to find.
The idioms in those four lines are the ones you’ll use everywhere. The zero value of a sync.Mutex is an unlocked, ready mutex, so var mu sync.Mutex is all the setup there is. Lock blocks until the lock is free and then takes it; Unlock releases it. Pairing Unlock with defer immediately after Lock is the standard move, because it guarantees the lock is released however the function exits, and it keeps the lock-unlock pair visually together so you can’t lose track of which is missing. The stretch of code between them is the critical section, and the rule is simple: every access to the shared data, reads included, must hold the lock. A mutex only protects you if all parties agree to use it; one goroutine touching count without locking reintroduces the whole race.
Like WaitGroup, a Mutex must not be copied after use, and go vet flags a copy for the same reason and with the same noCopy mechanism. When a struct has a field that needs protecting, embed the mutex as a field beside it and pass the struct by pointer.
Why the detector belongs in your tests
Here’s the catch that makes the race detector a discipline rather than a one-time check. It only sees races on code paths that actually execute during the run. It instruments real memory accesses as they happen; it does not reason about code that didn’t run. If a racy branch is only reached under a config flag you didn’t set, or an error path your test never triggered, the detector is silent about it, not because the code is safe but because it never looked.
That’s why the guidance is to run your test suite under -race, not just a hand-written demo, and to make those tests exercise the concurrent paths that matter. go test -race ./... on a suite with good coverage is how real races get caught before they ship, because coverage is exactly what decides how much the detector can see. It costs some speed and memory, so most teams run the instrumented build in CI rather than on every local build. The trade is worth it: a race that the detector names in a red CI run is a race you fix in an afternoon, and a race that slips through is a corruption bug someone chases for a week.
The deadlock the race detector won’t catch
The race detector is so good that it’s easy to over-trust it. So here is a concurrency bug it will never flag, because it isn’t a data race at all: a lock-ordering deadlock. Two goroutines, two mutexes, and each goroutine grabs them in the opposite order. This is the classic AB-BA deadlock.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var a, b sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
a.Lock()
time.Sleep(10 * time.Millisecond) // give g2 time to grab b
b.Lock() // waits for g2 to release b — forever
fmt.Println("g1 got both")
b.Unlock()
a.Unlock()
}()
go func() {
defer wg.Done()
b.Lock()
time.Sleep(10 * time.Millisecond) // give g1 time to grab a
a.Lock() // waits for g1 to release a — forever
fmt.Println("g2 got both")
a.Unlock()
b.Unlock()
}()
wg.Wait() // blocks forever: neither goroutine ever calls Done
fmt.Println("main done")
}
Goroutine 1 locks a, then reaches for b. Goroutine 2 locks b, then reaches for a. The Sleep calls just make the timing reliable: by the time each goroutine asks for its second lock, the other goroutine is already holding it and will never let go, because it too is blocked waiting for the lock the first one holds. Both are stuck, main is stuck on Wait, and nothing will ever move.
Run this under the race detector and it says nothing: go run -race . finds zero races, because there is no unsynchronized memory access here. Every access to a and b is perfectly locked. The bug is not two goroutines touching memory at once; it’s two goroutines waiting on each other, which is a different category of failure the detector is not built to see. This is the catch worth internalizing: -race is a data-race tool, not a deadlock tool.
What does catch this one is the Go runtime itself. When every goroutine in the program is blocked with no possibility of waking, the runtime declares a deadlock and aborts:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
sync.(*WaitGroup).Wait(0x...)
.../src/sync/waitgroup.go:206 +0x85
main.main()
.../main.go:34 +0x10f
goroutine 7 [sync.Mutex.Lock]:
sync.(*Mutex).Lock(...)
.../main.go:18 +0x9b
goroutine 8 [sync.Mutex.Lock]:
sync.(*Mutex).Lock(...)
.../main.go:28 +0x9b
exit status 2
fatal error: all goroutines are asleep - deadlock!, then a dump showing exactly where each goroutine is wedged: goroutine 1 on WaitGroup.Wait, and the two workers each parked on sync.Mutex.Lock at the lines where they reach for their second lock. The program exits with status 2. But lean on this only lightly, because the runtime detector fires only when the entire program is stalled. If even one goroutine is still doing work, or the deadlocked goroutines are off to the side while main carries on, the runtime sees a live program and stays quiet; the deadlocked goroutines just leak, blocked forever, and you notice through a hung request or a climbing goroutine count instead of a clean crash.
The fix is not cleverness, it’s discipline: acquire locks in a consistent global order everywhere. If every goroutine that needs both a and b always takes a before b, the cycle is impossible, because no goroutine can be holding b while waiting for a. Make both workers lock a first:
go func() {
defer wg.Done()
a.Lock()
time.Sleep(10 * time.Millisecond)
b.Lock()
fmt.Println("g1 got both")
b.Unlock()
a.Unlock()
}()
go func() {
defer wg.Done()
a.Lock() // same order as g1: a before b
time.Sleep(10 * time.Millisecond)
b.Lock()
fmt.Println("g2 got both")
b.Unlock()
a.Unlock()
}()
That version prints both got both lines (in whichever order the goroutines happen to win a) and then main done, and exits cleanly. One goroutine simply waits its turn for a while the other holds both, then goes. Whenever your code holds more than one lock at a time, pick an order for them and hold to it in every path. It is the cheapest deadlock prevention there is, and the detector will not remind you to do it.
Final thoughts
A data race is two goroutines touching the same memory concurrently with at least one writing and no ordering between them, and its signature is a wrong answer that varies from run to run, like a 1000-increment counter landing on 963. Go’s race detector, switched on with -race, instruments memory accesses and prints a WARNING: DATA RACE naming the two conflicting accesses, the address, the line, and the goroutines, then forces a nonzero exit so your build fails. A sync.Mutex fixes the race by serializing the critical section: Lock, defer Unlock, and make sure every access to the shared data holds the lock. Zero value is ready, don’t copy it, and let vet check that. Above all, remember the detector only sees what runs, so it belongs in your test suite where coverage puts the racy paths in front of it. Next we look at the lighter-weight tools for the cases where a full mutex is more than you need.
Next: Once, atomics, and RWMutex — run-exactly-once, lock-free counters, and letting readers share.
Comments