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:

GO
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 .
GO
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")
}
Output (stack traces omitted)
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:

GO
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"))
}
Output
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:

GO
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"))
}
Output
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:

GO
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())
}
Output
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:

GO
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"])
}
Output
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 anotherProtect a cache, counter or map that several goroutines update
Signal events: done, cancel, readyGuard a struct's internal state
Build pipelines and worker poolsKeep 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 ..

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
balance: 750
Next: worker pools, pipelines and other concurrency patterns.