sync.WaitGroup and Mutex

Go · Practical · 12 min

Not everything fits channels; WaitGroup and Mutex are the simplest correct tools for many jobs.

sync.WaitGroup waits for a set of goroutines to finish:

  • wg.Add(n) before starting the goroutines (in the launching goroutine, not inside the new one).
  • defer wg.Done() at the top of each goroutine.
  • wg.Wait() blocks until the counter reaches zero.

(Go 1.25 adds wg.Go(f), which does the Add/Done bookkeeping for you.)

sync.Mutex gives mutual exclusion around shared state: mu.Lock(), then defer mu.Unlock(). Embed the mutex next to the data it protects, in the same struct, and keep critical sections short — never hold a lock while doing I/O or waiting on a channel. sync.RWMutex allows many concurrent readers (RLock) or one writer (Lock); it only pays off when reads greatly outnumber writes.

Other useful pieces:

  • sync.Once (or sync.OnceValue, Go 1.21+) runs initialisation exactly once, even with concurrent callers.
  • sync/atomic types such as atomic.Int64 for simple counters and flags.
  • sync.Map for specialised caches; a plain map plus a mutex is usually clearer.

A mutex or WaitGroup must not be copied after first use. Pass structs containing them by pointer and use pointer receivers; go vet flags copies.

When should you use a mutex instead of a channel? Use channels to pass ownership of data or coordinate stages of work; use a mutex when goroutines simply need safe access to a shared structure such as a cache or counter.

Example

package main

import (
	"fmt"
	"sync"
)

type Stats struct {
	mu   sync.Mutex
	hits map[string]int
}

func (s *Stats) Record(page string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.hits[page]++
}

func main() {
	s := &Stats{hits: make(map[string]int)}
	var wg sync.WaitGroup
	for i := range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			s.Record([]string{"home", "about"}[i%2])
		}()
	}
	wg.Wait()
	fmt.Println(s.hits) // map[about:50 home:50]
}

Remember

  • WaitGroup: Add before go, defer Done() inside, Wait to join
  • Keep the Mutex next to the data it guards; lock briefly
  • Never copy a Mutex or WaitGroup — use pointers

Watch out for

  • Calling wg.Add(1) inside the goroutine, racing with wg.Wait().

Check yourself

  1. Why is `Record` declared with a pointer receiver?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pkg.go.dev/sync