Two goroutines run `counter++` on the same shared variable with no lock — why is that a data race?
answer
- one source line, several machine steps
- both goroutines can load the same value
- one writer plus no ordering
- lost update is the mild outcome
- lock it, make it atomic, or own it
basics
~20 scounter++ is a load, an add and a store, not one step. Two goroutines can load the same old value and store the same result, losing an update. Unsynchronized concurrent access with at least one writer is a data race.
solid answer
~50 sA data race is two goroutines touching the same memory location at the same time, with at least one of them writing, and nothing ordering the two accesses. `counter++` looks atomic in the source but compiles to a read, an add and a write, so the runtime can switch goroutines between the read and the write and one increment silently overwrites the other. The damage is not limited to a wrong count: Go's memory model leaves a racy program's behaviour undefined, and the compiler is free to keep the variable in a register or reorder the accesses, so a racy flag can be missed entirely. The fixes are to guard the variable with a `sync.Mutex`, to make the whole update one atomic operation with `atomic.Int64.Add`, or to give one goroutine sole ownership of the counter and have the others send it deltas over a channel.
code
go · 13 linesvar counter int
done := make(chan struct{})
for i := 0; i < 100; i++ {
go func() {
counter++ // load, add, store: three steps, not one
done <- struct{}{}
}()
}
for i := 0; i < 100; i++ {
<-done
}
fmt.Println(counter) // usually less than 100, and varies per rungo deeper
Be ready to define a data race in one sentence and to say why counter++ is three steps rather than one. Naming a mutex, an atomic and single-goroutine ownership as the three fixes is enough at this level.
Explain the load-add-store interleaving concretely and say why the outcome is undefined rather than merely wrong by a few. Justify choosing an atomic for a single word versus a mutex when several fields share an invariant.
Show that you spot shared mutable state during review before it ships, and that you treat a passing test run as no evidence of safety. Be able to argue for redesigning toward ownership instead of scattering locks over a growing struct.
Own the discipline rather than the incident: which state is allowed to be shared at all, what the codebase's default concurrency pattern is, and what you require in review so the next such bug never reaches production.
## What a data race is A **data race** in Go is precisely defined: two goroutines access the same memory location concurrently, at least one of the accesses is a **write**, and nothing in the program orders one access before the other. All three conditions are required. Two goroutines reading the same variable is not a race. A goroutine writing a variable that no one else touches is not a race. A write plus any other access, unordered, is. ## Why `counter++` is not one operation The source line is one token, but the machine does three things: 1. **Load** the current value of `counter` from memory into a register. 2. **Add** one to the register. 3. **Store** the register back into `counter`. The Go scheduler can switch goroutines between any two of those steps, and on a multi-core machine two goroutines are genuinely running at the same instant. The classic interleaving: | step | goroutine A | goroutine B | counter | |---|---|---|---| | 1 | load 7 | | 7 | | 2 | | load 7 | 7 | | 3 | store 8 | | 8 | | 4 | | store 8 | 8 | Two increments happened and the value went up by one. That is a **lost update**. Run a hundred goroutines each incrementing a shared counter and the final value is reliably *less* than a hundred, and different on every run. ## It is worse than an off-by-a-few count The tempting response is "so the number is a bit low, who cares". That is the misconception this question exists to break. The Go memory model does not merely say the result is unpredictable; it says a program with a data race is **not defined** by the model at all, beyond the guarantee that a read of a location no larger than a machine word observes *some* value that was actually written there (Go rules out out-of-thin-air values, unlike C++). Everything else is on the table: - The compiler may prove, on the assumption that no other goroutine touches the variable, that a loop's reads can be **hoisted into a register**. A `for !done {}` loop reading a racily-written `bool` can then spin forever even after the flag is set. - The compiler and the CPU may **reorder** the racy accesses relative to surrounding ones, so a value published just before a flag may not be visible when the flag is. - For values wider than a machine word the read can observe a **mixture** of two different writes — a value nobody ever stored. So "it is just a counter" is not a defence: the surrounding code can behave in ways that no reading of the source explains. ## The three ways to fix it **A mutex.** Wrap the read-modify-write so only one goroutine is inside at a time: ```go var mu sync.Mutex mu.Lock() counter++ mu.Unlock() ``` Use this whenever more than one variable must change together — a counter and a timestamp, a map and its length — because a mutex can cover a whole invariant while an atomic covers exactly one word. **An atomic.** `sync/atomic`'s typed values (Go 1.19 and later) make the whole update one indivisible operation: ```go var counter atomic.Int64 counter.Add(1) later := counter.Load() ``` This is the right tool for a single independent number — a request counter, a sequence number. It does **not** help if two fields must stay consistent with each other. **Confinement.** Give the state one owner. One goroutine holds the counter as an ordinary local variable and everyone else sends it increments on a channel; the value is never shared, so there is nothing to race on. This is the design Go's "share memory by communicating" slogan is pointing at, and it scales better in review than sprinkling locks. ## The pattern generalises A counter is only the smallest example. Every shared mutable thing has the same rule: a struct field written by a handler and read by a metrics goroutine, a slice appended to from several workers, a map written by more than one goroutine, a package-level variable initialised lazily by whoever gets there first. If more than one goroutine can touch it and at least one writes, it needs a lock, an atomic, or an owner. One last calibration point: `GOMAXPROCS=1` does not make any of this safe. Goroutines are preemptible, so the switch can still land between the load and the store; a single P removes parallelism, not interleaving.
- Does setting GOMAXPROCS to 1, or using a narrower type like int32, make the increment safe?Neither. `GOMAXPROCS=1` removes parallelism but not interleaving — goroutines are preemptible, so a switch can still land between the load and the store. The width of the variable is irrelevant too: the problem is that a read-modify-write is three separate steps, not that the value is wide. Only synchronization — a mutex, an atomic operation, or single-goroutine ownership — removes the race.
- When would you reach for atomic.Int64 and when for a sync.Mutex?Use an atomic when the shared state is exactly one independent word and every update is a single indivisible operation: a hit counter, a sequence number, a swapped-in pointer. Use a mutex as soon as two or more pieces of state must agree with each other — a counter and the timestamp of the last update, a slice and its index — because a mutex can span the whole invariant while an atomic protects one word at a time.
- Both goroutines only ever write the same constant value to the variable. Is that still a data race?Yes. The definition is about unsynchronized concurrent access with at least one writer, not about whether the values differ. The compiler is entitled to optimize on the assumption that the program is race-free, so a race that looks harmless can still change how surrounding code is compiled and scheduled. Write it once before starting the goroutines, or guard it.
saying these in an interview costs you the question
- Increment is a single instruction, so it is atomic
- The worst that happens is a slightly low count
- Setting GOMAXPROCS to 1 makes shared counters safe
- Only writes need guarding, a read alongside a write is fine
- Adding a short sleep between goroutines fixes it
- It never reproduces on my laptop, so the code is correct