Explanation
What just happened
count++ looks like one step, but it’s three: read count, add 1, write it back. g2 read count before g1 wrote its new value, so both started from 0 and both wrote 1. One update was lost.
Our facts test runs a million increments in each of two goroutines, and the total comes out short. The proof page shows by how much.
Try the what-if switch
- sync.Mutex:
mu.Lock()lets one goroutine at a time into the read-add-write. g2 waited inLock([sync.Mutex.Lock]in a dump), then read the fresh value. - atomic.Int64:
count.Add(1)reads, adds and writes as one indivisible step. No lock, no waiting in your code.
Why it’s hard to catch
Two increments rarely collide, so the program usually prints 2. That’s why the race detector matters: it reports the unsynchronized access itself, not a wrong result.
Rule of thumb
If two goroutines touch the same variable and one writes, synchronize. Run your tests with -race in CI.
Common questions
Is count++ atomic in Go?
No. count++ reads, adds and writes. Two goroutines can read the same old value and both write the same new one, so one increment is lost.
How do I find a data race in Go?
Run with the race detector: go run -race, go test -race. It prints WARNING: DATA RACE with both goroutines' stacks and exits with code 66, even on runs where the result happened to come out right.
Should I use sync.Mutex or atomic for a counter?
For one counter, atomic.Int64 is simplest: count.Add(1) is one indivisible step. Use a sync.Mutex when several values must change together.
Verified: this timeline matches a real program, checked in CI on Go 1.26 and 1.27. How we verify. Unclear or wrong? Tell us