Mutexes and the race detector
Sharing state between goroutines without corrupting it.
Topic 41 of 98 · 5 min to read · 45 min to build
After this topic you can
- Write a struct that guards a map with a
sync.Mutex. - Explain why a data race is undefined behaviour, not just a wrong number.
- Fix a racy counter and prove it with
go test -race.
Use a mutex when you are guarding state
"Share memory by communicating" is Go’s slogan, but do not force it. A mutex around a cache is simpler, faster, and more obvious than a goroutine owning a map behind a channel. Channels are for transferring ownership of a value; a mutex is for protecting a field.
cache.go
type Cache struct { mu sync.RWMutex // put the lock directly above what it guards m map[string]string}func (c *Cache) Get(k string) (string, bool) { c.mu.RLock() // many readers at once defer c.mu.RUnlock() v, ok := c.m[k] return v, ok}func (c *Cache) Set(k, v string) { c.mu.Lock() // one writer, no readers defer c.mu.Unlock() c.m[k] = v}Mutex rules
- Never copy a struct containing a mutex. Use pointer receivers.
go vetcatches most copies for you. - Never make a network or database call while holding a lock. Lock, take what you need, unlock, then do the slow thing.
- A mutex is not reentrant. Locking it twice in the same goroutine blocks that goroutine forever.
RWMutexonly wins when there are far more reads than writes and the critical section is non-trivial. Otherwise a plainMutexis faster.
A data race is undefined behaviour, not a rare bug
If two goroutines touch the same memory, at least one writes, and there is no synchronisation between them, you have a data race. The Go memory model gives you no guarantee about what the program then does. The compiler and the CPU are both free to reorder, and one goroutine may never see the other’s write at all.
race.go
done := false // BROKEN, even though it may work on your laptopgo func() { work() done = true}()for !done { } // may loop forever — the compiler can hoist the readRun the race detector
terminal
go test -race ./...go run -race ./cmd/apiNote: Maps get extra help. The runtime checks for concurrent map access on a best-effort basis, and when it catches one it crashes the process with
fatal error: concurrent map writes (or concurrent map read and map write). It is a fatal error, so recover cannot catch it. The check does not catch every race, so still run -race.Try it yourself
press Run — this executes on the Go Playground
package mainimport ( "context" "fmt" "sync" "time")type Counter struct { mu sync.Mutex n int}func (c *Counter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.n++}func main() { c := &Counter{} var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done(); c.Inc() }() } wg.Wait() fmt.Println("with a mutex, always 1000:", c.n) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() select { case <-time.After(time.Second): fmt.Println("work finished") case <-ctx.Done(): fmt.Println("cancelled:", ctx.Err()) }}The short version
- Mutex for guarding state, channels for transferring ownership. Do not force channels.
- Never copy a struct containing a mutex, and never hold a lock across a network call.
- A data race is undefined behaviour, not a rare bug.
go test -race ./...in CI. It finds races that code review and ordinary tests miss.
Remember: Without synchronisation, one goroutine has no guarantee of ever seeing another’s writes.
Build this before moving on
Increment a counter from 1,000 goroutines with no mutex, run with -race, read the report, then fix it.Coding challenge · checked by tests
Write
CountWords(words, work): start one goroutine per word. Each goroutine first calls work(word) (a slow step, like a network call), then adds 1 to that word's entry in a shared map. Protect the map with a sync.Mutex and wait with a sync.WaitGroup. The hidden tests pass a work that sleeps 30ms, so doing the words one after another is too slow. Example: CountWords([go is go], work) returns map[go:2 is:1].Tab indents. Press Esc, then Tab, to leave the editor.
Then try these in your own editor
- 1Fix the counter with a mutex, then again with
atomic.Int64, and confirm both are clean. - 2Copy a struct containing a mutex and see what
go vetsays. - 3Use
RWMutexand measure whether it actually helps your read/write mix.
Official documentation