WaitGroups, Mutexes and Data Races
When several goroutines read and write the same variable at the same time, the result is a data race: lost updates, corrupted maps, bugs that appear once in a thousand runs. Channels avoid sharing; when sharing is simpler, the sync package protects it. This lesson shows a real race, finds it with the race detector, and fixes it with sync.Mutex, sync.RWMutex, atomics and sync.Once.
A data race
This program starts 1,000 goroutines that each add 1 to a counter. The answer should be 1,000, but counter++ is really three steps — read, add, write — and goroutines running in parallel overwrite each other's work:
package main import ( "fmt" "sync" ) func main() { counter := 0 var wg sync.WaitGroup for range 1000 { wg.Go(func() { counter++ // unsynchronised read-modify-write }) } wg.Wait() fmt.Println(counter) }
Run it a few times and you'll typically see numbers a little below 1,000 that change every run — or, worse, the right answer most of the time, which is how races slip into production.
The race detector
Go has a built-in race detector. Add -race to go run, go build or go test, and the program reports any unsynchronised access it observes, with the stack traces of both goroutines involved:
go run -race .
package main import ( "fmt" "sync" ) func main() { counter := 0 var wg sync.WaitGroup for range 2 { wg.Go(func() { counter++ }) } wg.Wait() fmt.Println("done") }
done WARNING: DATA RACE Found 1 data race(s) exit status 66
A race-enabled program runs several times slower, so it's for development and tests rather than production. Run your tests with go test -race ./... regularly; it's one of the most valuable checks Go offers.
sync.Mutex
A mutex ("mutual exclusion" lock) lets only one goroutine at a time run the code between Lock and Unlock. Keep the protected data and its mutex together in a struct, and always unlock — defer makes that automatic:
package main import ( "fmt" "sync" ) type Counter struct { mu sync.Mutex count map[string]int } func (c *Counter) Inc(key string) { c.mu.Lock() defer c.mu.Unlock() c.count[key]++ } func (c *Counter) Get(key string) int { c.mu.Lock() defer c.mu.Unlock() return c.count[key] } func main() { c := Counter{count: map[string]int{}} var wg sync.WaitGroup for i := range 1000 { wg.Go(func() { if i%2 == 0 { c.Inc("even") } else { c.Inc("odd") } }) } wg.Wait() fmt.Println(c.Get("even"), c.Get("odd")) }
500 500
Without the mutex, concurrent writes to the map wouldn't just give wrong answers: the runtime detects them and crashes the program with fatal error: concurrent map writes. A Counter must not be copied after use (copying would copy the lock), which is why its methods have pointer receivers.
sync.RWMutex
When data is read far more often than written, an RWMutex lets any number of readers in at once with RLock, while Lock still gives a writer exclusive access:
package main import ( "fmt" "sync" ) type Config struct { mu sync.RWMutex settings map[string]string } func (c *Config) Get(key string) string { c.mu.RLock() // many readers may hold this at once defer c.mu.RUnlock() return c.settings[key] } func (c *Config) Set(key, value string) { c.mu.Lock() // writers wait for all readers to leave defer c.mu.Unlock() c.settings[key] = value } func main() { cfg := &Config{settings: map[string]string{"mode": "safe"}} var wg sync.WaitGroup for range 10 { wg.Go(func() { _ = cfg.Get("mode") }) } wg.Go(func() { cfg.Set("mode", "fast") }) wg.Wait() fmt.Println(cfg.Get("mode")) }
fast
Measure before switching: for short critical sections a plain Mutex is often just as fast.
Atomic values
For a single number or flag, the types in sync/atomic — atomic.Int64, atomic.Bool, atomic.Pointer[T] — are simpler and faster than a mutex:
package main import ( "fmt" "sync" "sync/atomic" ) func main() { var requests atomic.Int64 var ready atomic.Bool var wg sync.WaitGroup for range 1000 { wg.Go(func() { requests.Add(1) }) } wg.Wait() ready.Store(true) fmt.Println(requests.Load(), ready.Load()) }
1000 true
Atomics protect one value at a time. As soon as you need to keep two values consistent with each other, use a mutex.
Running something once: sync.Once
sync.OnceValue wraps a function so it runs only the first time it's called — by whichever goroutine gets there first — and every caller gets the same result. It's the safe way to set something up lazily:
package main import ( "fmt" "sync" ) var loadConfig = sync.OnceValue(func() map[string]string { fmt.Println("loading config (only once)") return map[string]string{"region": "eu-west"} }) func main() { var wg sync.WaitGroup for range 5 { wg.Go(func() { _ = loadConfig()["region"] }) } wg.Wait() fmt.Println(loadConfig()["region"]) }
loading config (only once) eu-west
sync.Once (with a Do method) and sync.OnceValues (for a value and an error) cover the other shapes.
Channels or mutexes?
| Use channels to… | Use a mutex to… |
|---|---|
| Hand data from one goroutine to another | Protect a cache, counter or map that several goroutines update |
| Signal events: done, cancel, ready | Guard a struct's internal state |
| Build pipelines and worker pools | Keep critical sections short and simple |
Neither is "more Go". Pick whichever makes the code easiest to understand.
Try it yourself
A bank account receives 100 deposits and 50 withdrawals concurrently. Run it, then remove the locking and run it with go run -race ..
balance: 750