46. The `sync` package: WaitGroup, Mutex and friends
Channels are for passing data between goroutines. When goroutines share
data instead — a counter, a cache, a config — you need locks. The sync
package is small, and you'll use two things from it constantly.
sync.WaitGroup — wait for a group to finish
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results[n] = n * 10
}(i)
}
wg.Wait()
fmt.Println(results)
}
Three methods, one counter:
Add(n)— increase the counter, before starting the goroutine.Done()— decrease it by one, always deferred inside the goroutine.Wait()— block until it reaches zero.
The two mistakes that matter: calling Add inside the goroutine (it may
not run before Wait, which then returns immediately), and forgetting
Done on some path (so Wait blocks forever). wg.Add(1) immediately before
go, defer wg.Done() as the first line inside — do it the same way every
time and neither happens.
A WaitGroup must not be copied. Pass it as *sync.WaitGroup, never as a
value.
wg.Go — the same thing in one line
Go 1.25 added a method that does the Add/go/defer Done dance for you:
var wg sync.WaitGroup
for _, url := range urls {
wg.Go(func() {
fetch(url)
})
}
wg.Wait()
wg.Go(f) increments the counter, starts f in a new goroutine, and calls
Done when it returns — so the two mistakes above become unspellable. Prefer
it in new code on Go 1.25 or later.
Learn Add/Done/Wait anyway, and in that order: wg.Go is exactly those
three with the bookkeeping hidden, you will read Add/Done in every existing
codebase, and every other example in this module spells it out.
The box above is reference-only, for a reason worth knowing. wg.Go takes a
func() with no parameters, so the goroutine can only reach url by closing
over it — and the in-browser interpreter still uses Go's pre-1.22 loop
variable rules, where every iteration shares one url rather than getting
its own. Run it here and all three goroutines would fetch the last URL. Real
Go 1.22 and later give each iteration its own copy and do the right thing.
That change is also why wg.Go is pleasant to use at all: before it, this loop
needed the value passed in as an argument. Everything else in this lesson runs.
The data race
Before the fix, see the problem:
counter := 0
for i := 0; i < 1000; i++ {
go func() { counter++ }() // RACE
}
counter++ is three operations — read, add, write. Two goroutines can read
the same value, both add one, and both write back: two increments, one
result. The final number is unpredictable and usually too low.
Go ships a detector for exactly this:
$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c000018098 by goroutine 7:
main.main.func1()
/tmp/main.go:10 +0x3c
...
Run your concurrent tests with -race. It finds real bugs that
otherwise appear once a month in production, and it's the single most
valuable tool in Go's kit.
sync.Mutex — one goroutine at a time
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
Exactly 1000, every run. Lock blocks until whoever holds the mutex releases
it, so only one goroutine is between Lock and Unlock at a time.
Wrap the mutex in a type
Loose mutexes next to loose variables invite mistakes. Put both in a struct and let methods enforce the discipline:
package main
import (
"fmt"
"sort"
"sync"
)
type Counters struct {
mu sync.Mutex
counts map[string]int
}
func NewCounters() *Counters {
return &Counters{counts: make(map[string]int)}
}
func (c *Counters) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[key]++
}
func (c *Counters) Get(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.counts[key]
}
func (c *Counters) Keys() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, 0, len(c.counts))
for k := range c.counts {
out = append(out, k)
}
sort.Strings(out)
return out
}
func main() {
c := NewCounters()
var wg sync.WaitGroup
for i := 0; i < 300; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
c.Inc([]string{"get", "post", "put"}[n%3])
}(i)
}
wg.Wait()
for _, k := range c.Keys() {
fmt.Printf("%-5s %d\n", k, c.Get(k))
}
}
The rules this encodes:
- The mutex sits next to the data it protects, as the first field.
- It's unexported, so callers can't lock it wrongly — the type is safe by construction.
- Every method that touches
countslocks. Reads too: a concurrent map read during a write is a race and will crash the program. defer mu.Unlock()immediately afterLock— so an early return or a panic can't leave it locked.- The zero
sync.Mutexis ready to use; no initialisation needed.
Because the struct holds a mutex, it must never be copied — hence
*Counters everywhere and a constructor that returns a pointer.
sync.RWMutex — many readers, one writer
When reads vastly outnumber writes, a read-write mutex lets readers run concurrently:
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func main() {
c := NewCache()
c.Set("lang", "go")
var wg sync.WaitGroup
hits := make([]bool, 100)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
_, ok := c.Get("lang")
hits[n] = ok
}(i)
}
wg.Wait()
all := true
for _, h := range hits {
if !h {
all = false
}
}
fmt.Println("all 100 concurrent reads succeeded:", all)
}
RLock/RUnlock for reads, Lock/Unlock for writes. Any number of
readers may hold the read lock simultaneously; a writer waits for all of them
and then excludes everyone.
Don't reach for it by default — RWMutex has more bookkeeping than Mutex,
so for short critical sections a plain Mutex is often faster. Use it when
you have measured a read-heavy contention problem.
sync.Once — exactly one initialisation
package main
import (
"fmt"
"sync"
)
type Config struct {
Loaded bool
}
var (
once sync.Once
config *Config
loads int
)
func GetConfig() *Config {
once.Do(func() {
loads++
config = &Config{Loaded: true}
})
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
GetConfig()
}()
}
wg.Wait()
fmt.Println("config loaded:", GetConfig().Loaded)
fmt.Println("times the loader ran:", loads)
}
Fifty goroutines, one initialisation — and every caller blocks until it's finished, so nobody sees a half-built value. That's lazy singleton initialisation done correctly, in three lines.
sync/atomic for simple counters
For a single number, an atomic operation beats a mutex:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println(atomic.LoadInt64(&counter))
}
The whole read-add-write happens as one uninterruptible CPU instruction —
no lock, no blocking. Note that you must use atomic.LoadInt64 to read it
too; a plain read alongside atomic writes is still a race.
Atomics are for single values only. The moment you need two fields to change together, you need a mutex.
Channels or mutexes?
Both are correct Go. The guideline:
| use channels when | use a mutex when |
|---|---|
| passing ownership of data along | protecting shared state in place |
| coordinating a pipeline of stages | a simple counter, cache or map |
| signalling events and shutdown | the critical section is short |
| distributing work to workers | a channel would just be a lock with extra steps |
Don't build a mutex out of a one-slot channel because you read that channels
are more idiomatic. A cache with a sync.RWMutex is clearer than the same
cache behind a goroutine and two channels.
Your turn
Make SafeSet concurrency-safe with a mutex, then add 100 values from 100
goroutines:
100
package main
import (
"fmt"
"sync"
)
type SafeSet struct {
// add a mutex and a map[int]struct{}
}
// add Add(n int) and Len() int
func main() {
s := &SafeSet{items: map[int]struct{}{}}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
s.Add(n)
}(i)
}
wg.Wait()
fmt.Println(s.Len())
}
package main
import (
"fmt"
"sync"
)
type SafeSet struct {
mu sync.Mutex
items map[int]struct{}
}
func (s *SafeSet) Add(n int) {
s.mu.Lock()
defer s.mu.Unlock()
s.items[n] = struct{}{}
}
func (s *SafeSet) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.items)
}
func main() {
s := &SafeSet{items: map[int]struct{}{}}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
s.Add(n)
}(i)
}
wg.Wait()
fmt.Println(s.Len())
}
Next: putting goroutines and channels together into the patterns you'll actually ship.