Sync primitives
Guard shared state with Mutex and RWMutex, coordinate goroutines with WaitGroup, run setup once with sync.Once, and detect races with -race.
- Protect shared state with sync.Mutex and sync.RWMutex
- Wait for a group of goroutines with sync.WaitGroup
- Guarantee one-time initialisation with sync.Once
- Run the race detector and interpret its output
- Choose between channels and mutexes for a given problem
Channels are Go's first tool for safe concurrency. But not every problem fits the channel model — sometimes multiple goroutines legitimately need read or write access to a shared data structure. The sync package provides lower-level primitives for those cases.
sync.Mutex
A Mutex (mutual exclusion lock) ensures only one goroutine accesses a critical section at a time:
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}defer c.mu.Unlock() is idiomatic — it guarantees the unlock happens even if the method panics. Always defer the unlock immediately after the lock.
Never copy a sync.Mutex by value. If you embed a mutex in a struct, always pass the struct by pointer. Copying a locked mutex creates two independent copies — each thinks it is the lock — which breaks the invariant entirely.
sync.RWMutex
When reads are far more frequent than writes, RWMutex allows many concurrent readers but only one writer:
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // multiple goroutines can hold RLock simultaneously
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock() // exclusive write lock
defer c.mu.Unlock()
c.items[key] = value
}RLock/RUnlock for reads; Lock/Unlock for writes. Use RWMutex when profiling shows read contention on a standard Mutex.
sync.WaitGroup
WaitGroup lets one goroutine wait for a collection of goroutines to finish:
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("worker", n)
}(i)
}
wg.Wait() // blocks until all five goroutines call Done()
fmt.Println("all workers finished")wg.Add(n)— increment the counter by n before launching the goroutine.wg.Done()— decrement the counter by 1 (equivalent towg.Add(-1)).wg.Wait()— block until the counter reaches zero.
Call wg.Add(1) before go func(), not inside it. If the goroutine scheduler runs wg.Wait() before the goroutine calls Add, the wait returns immediately. The pattern is: Add, then go, then Wait.
sync.Once
sync.Once guarantees that a function runs exactly once, regardless of how many goroutines call it:
var once sync.Once
var config *Config
func getConfig() *Config {
once.Do(func() {
config = loadConfig() // called exactly once
})
return config
}This is the idiomatic pattern for lazy, thread-safe singleton initialisation. Once once.Do has run, subsequent calls return immediately without calling the function again.
The race detector
Go ships a built-in race detector. Enable it with the -race flag:
go test -race ./...
go run -race main.goThe race detector instruments memory accesses at compile time and reports concurrent accesses to the same memory without synchronisation:
WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
main.main.func1()
main.go:12 +0x3c
Previous read at 0x00c000018090 by goroutine 6:
main.main.func2()
main.go:18 +0x2cRun -race in your test suite always. It adds latency and memory overhead, so don't use it in production builds, but every CI pipeline should run it.
Channels vs mutexes
Both solve concurrency safety. Choose based on the problem:
| Use channels when... | Use mutexes when... |
|---|---|
| Passing ownership of data between goroutines | Protecting shared state that multiple goroutines read/write |
| Coordinating goroutine lifecycle (done signals, pipelines) | Simple counter or cache operations |
| Fan-out / fan-in patterns | Low-contention, fine-grained locking |
When in doubt, start with channels. Reach for sync.Mutex when you're guarding a data structure that doesn't fit the ownership-transfer model.
Check your understanding
Knowledge check
- 1.When should you call wg.Add(1) relative to launching the goroutine?
- 2.It is safe to copy a sync.Mutex by value when passing a struct to a function.
- 3.What does the -race flag do when running go test?
Do it yourself
Build a thread-safe SafeMap[K, V] using sync.RWMutex. Implement Get(key K) (V, bool) and Set(key K, val V). Then run a test with -race:
go test -race ./...Where to go next
With sync primitives in hand, the next lesson introduces context — Go's standard way to propagate cancellation and deadlines through an entire call chain.