Concept · Go
Data races
Two goroutines, one variable, at least one write and no synchronization — and how the race detector finds it.
A data race happens when two goroutines access the same memory at the same time, at least one of them writes, and nothing orders the two accesses. The result isn't just a stale value. Under Go's memory model, a racy program can see torn writes or corrupted maps, or behave in ways that make no sense.
var hits int
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() { hits++ }) // read, add, write: three steps, interleaved
}
wg.Wait()
fmt.Println(hits) // often less than 1000Maps are worse. Concurrent writes to a built-in map crash the program with fatal error: concurrent map writes, and you can't recover from that.
Fixes
Pick one way to order the accesses:
// 1. A mutex around the shared state
var mu sync.Mutex
mu.Lock(); hits++; mu.Unlock()
// 2. An atomic for a single counter or flag
var hits atomic.Int64
hits.Add(1)
// 3. Don't share: give the state to one goroutine and send it messages
results := make(chan int)Go's proverb is "Don't communicate by sharing memory; share memory by communicating." Channels are a good fit for handing work or ownership from one goroutine to another. A mutex is simpler for protecting a cache or a counter. Both are fine; the race is what's wrong.
The race detector
$ go test -race ./...
WARNING: DATA RACE
Write at 0x00c000014108 by goroutine 8:
main.main.func1() main.go:12
Previous write at 0x00c000014108 by goroutine 7:
main.main.func1() main.go:12-race instruments every memory access and reports races that actually happen during the run. It has no false positives, but it misses code paths the run didn't reach. Run your tests with -race in CI, and use concurrent test cases (t.Parallel() and handlers hit from several goroutines) so the racy paths actually run.
Where races hide in servers
- Every HTTP request runs in its own goroutine. Any package-level variable, or any field on a shared handler struct that you write to, is shared state.
- A closure captures variables, not values. Since Go 1.22 each loop iteration gets a fresh variable, but a variable declared outside the loop and captured by a goroutine is still shared.
- Lazy initialization ("if cache == nil, build it") needs
sync.Once.
JavaScript avoids this whole class of bug by running your code on one thread. See the event loop for what it gives up instead.