46. The `sync` package: WaitGroup, Mutex and friends

📖 Reading · 15 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

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 counts locks. Reads too: a concurrent map read during a write is a race and will crash the program.
  • defer mu.Unlock() immediately after Lock — so an early return or a panic can't leave it locked.
  • The zero sync.Mutex is 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.