The Bug You Cannot See: Data Races and the Go Memory Model

Two goroutines incrementing the same counter at once and losing an update

Here is a function that looks obviously correct and is obviously wrong:

var count int
func worker() {
    for i := 0; i < 1000; i++ {
        count++
    }
}
// launch 10 workers, wait, print count

Run it and you might get 10000. Run it again and you might get 8742. The reason is that count++ is not one operation. It is three: read count into a register, add one, write it back. When two goroutines do that at the same time, they can both read the same old value, both add one, and both write back the same new value. Two increments, one net effect. The lost updates are invisible because nothing crashes. The number is just quietly too small.

This is a data race: two goroutines touching the same memory at the same time, with at least one of them writing, and no synchronization between them. Go's memory model says the result of a data race is undefined. Not "the last writer wins," not "you lose a few updates," but undefined. The compiler and CPU are both allowed to reorder and cache memory in ways that make racy code do genuinely surprising things.

The detector that changes everything

The single most useful fact for a Go developer: the race detector.

go test -race ./...
go run -race .

It instruments memory accesses and screams, with both stack traces, the instant two goroutines touch the same location unsafely. It does not find races by reasoning about your code; it finds them by observing an actual racy access at runtime, so the race has to happen during the run. That is why you run your tests under -race in CI: you want the detector watching while your concurrent tests exercise the code. A race that "never reproduces locally" almost always reproduces under -race in a loop.

Two correct tools, two situations

Once you can see the race, fixing it is choosing the right primitive for the access pattern.

A single counter wants an atomic. For one machine word being bumped, an atomic is both correct and faster than a lock, because it is a single CPU instruction with no scheduler involvement:

type Counter struct{ n atomic.Int64 }
func (c *Counter) Inc()         { c.n.Add(1) }
func (c *Counter) Value() int64 { return c.n.Load() }

Add(1) performs the read-modify-write as one indivisible step, so no goroutine can wedge in between. No lost updates, no mutex.

A read-heavy map wants an RWMutex. When the shared thing is bigger than a word, like a registry of config values or routes that is read constantly and written occasionally, a read-write mutex lets all the readers proceed in parallel and only blocks them when a writer needs exclusive access:

type Registry struct {
    mu sync.RWMutex
    m  map[string]string
}
func (r *Registry) Get(k string) (string, bool) {
    r.mu.RLock(); defer r.mu.RUnlock()
    v, ok := r.m[k]
    return v, ok
}
func (r *Registry) Set(k, v string) {
    r.mu.Lock(); defer r.mu.Unlock()
    r.m[k] = v
}

Many concurrent Gets hold the read lock together. A Set waits for them to clear, then takes the write lock alone. For a 99%-reads workload that is a real throughput win over a plain Mutex, which would serialize the reads too.

The mindset, which outlasts the syntax

The increment that loses count is the smallest possible example of the biggest class of production bugs: the ones that are correct under casual testing and wrong under real load. Learning to see them, and to let -race see them for you, is the whole game.


Build it yourself. Solve the Data-Race Hunt challenge on barehands and get graded under the race detector. No libraries, just the standard toolchain.