Dev Skills

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 vet catches 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.
  • RWMutex only wins when there are far more reads than writes and the critical section is non-trivial. Otherwise a plain Mutex is 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 laptop
go func() {
work()
done = true
}()
for !done { } // may loop forever — the compiler can hoist the read

Run the race detector

terminal
go test -race ./...
go run -race ./cmd/api
Run your whole test suite with -race in CI. It only reports races that happen during the run, so your tests must exercise the concurrent paths. Expect the run to be several times slower and use more memory.
Note: 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 main
import (
"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())
}
}
A counter guarded by a mutex, and a context deadline. Delete the `Lock`/`Unlock` lines and run it with `-race` locally to see the report.
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
  1. 1Fix the counter with a mutex, then again with atomic.Int64, and confirm both are clean.
  2. 2Copy a struct containing a mutex and see what go vet says.
  3. 3Use RWMutex and measure whether it actually helps your read/write mix.
Check yourselfTake the Concurrency test