sync: WaitGroup, Mutex, Once

The sync package gives you the low-level tools to coordinate goroutines: WaitGroup to wait for them to finish, Mutex and RWMutex to protect shared state, and Once to run setup exactly one time.

Learn sync: WaitGroup, Mutex, Once in our free Go course — an interactive lesson with runnable examples, a practice exercise and a quick reference.

Part of the free Go course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ sync.WaitGroup

A WaitGroup is a counter you wait on. Call Add(1) before launching each goroutine, have each goroutine call Done() when it finishes (put it in a defer ), and call Wait() to block until the count returns to zero. Here each goroutine writes its own slice slot, so there's no shared write — and we sort only to print a stable result.

2️⃣ sync.Mutex and sync.RWMutex

When several goroutines update the same variable, you get a data race — c.counts[key]++ is a read-modify-write that can interleave and lose updates (and a concurrent map write actually panics). A Mutex serialises access: Lock admits one goroutine, Unlock releases it. When reads dominate, RWMutex lets many readers share an RLock while writers still get exclusive Lock .

3️⃣ sync.Once and atomic counters

sync.Once guarantees a function runs exactly once across all goroutines — perfect for lazy initialisation of a config, connection pool, or singleton. Every caller passes the same function to once.Do ; the first runs it, the rest wait for it to finish and then return.

For a single counter or flag you often don't need a lock at all. sync/atomic updates one value as a single indivisible CPU instruction — lock-free and fast. Use it for lone counters; switch back to a Mutex the moment two related fields must change together.

🎯 Your Turn

Wire up the WaitGroup yourself: Add before launching, Done when finishing, Wait at the end. The Mutex around done++ is already wired for you.

❌ wg.Add(1) inside the goroutine — Wait() can run before the Add and return too early.

✅ Call Add in the launching goroutine, before go ... .

❌ Forgot Done() on one path — Wait() hangs forever because the count never hits zero.

✅ Put defer wg.Done() as the first line of the goroutine.

❌ Locked and never unlocked (returned early before Unlock ) — deadlock.

✅ Use defer mu.Unlock() right after mu.Lock() .

❌ Copied a Mutex or WaitGroup by value — the copy has separate state and stops protecting anything.

✅ Pass them by pointer ( *sync.Mutex ) or embed in a struct used by pointer; run go vet .

hi prints once . After the first Do , the Once is "used up" and later calls skip f entirely.

The result is unpredictable (often less than 1000) — a data race. go run -race reports it; a Mutex or atomic.Int64 fixes it.

Count word occurrences across the slice using many goroutines. Protect a shared map[string]int with a Mutex and wait with a WaitGroup . Print the counts for go and rust .

Practice quiz

What is sync.WaitGroup used for?

  • Locking shared state
  • Running setup once
  • Waiting for a set of goroutines to finish
  • Counting atomically without goroutines

Answer: Waiting for a set of goroutines to finish. A WaitGroup is a counter you Wait on until all goroutines call Done.

Where should you call wg.Add(1)?

  • Before launching the goroutine (before the go statement)
  • Inside the goroutine
  • After wg.Wait()
  • It does not matter

Answer: Before launching the goroutine (before the go statement). Add before the go statement, or Wait can race the Add and return too early.

Where is it idiomatic to put wg.Done()?

  • At the end of main
  • Before Add
  • Inside Wait
  • In a defer as the first line of the goroutine

Answer: In a defer as the first line of the goroutine. defer wg.Done() guarantees the count drops even on early return or panic.

What does sync.Mutex provide?

  • Many concurrent writers
  • Mutual exclusion — one goroutine in the critical section at a time
  • Lock-free counting
  • One-time initialisation

Answer: Mutual exclusion — one goroutine in the critical section at a time. A Mutex serialises access so only one goroutine holds the lock at a time.

Why pair mu.Lock() with defer mu.Unlock()?

  • It guarantees the lock is released even on early return or panic, avoiding deadlock
  • It is faster
  • It allows two holders
  • It is required to compile

Answer: It guarantees the lock is released even on early return or panic, avoiding deadlock. defer Unlock ensures the lock is always released, preventing deadlock.

How does sync.RWMutex differ from sync.Mutex?

  • It is always faster
  • It never blocks
  • It allows many concurrent readers OR one exclusive writer
  • It only works on maps

Answer: It allows many concurrent readers OR one exclusive writer. RWMutex lets multiple RLock readers proceed together, or one Lock writer exclusively.

What does sync.Once guarantee?

  • A function runs on every goroutine
  • A function runs exactly once across all goroutines
  • A function never runs twice in a row
  • Locking for one goroutine

Answer: A function runs exactly once across all goroutines. once.Do(f) runs f exactly once; later callers wait then skip f.

When is sync/atomic (e.g. atomic.Int64) preferable to a Mutex?

  • For updating two related fields together
  • For waiting on goroutines
  • For one-time setup
  • For a single value updated with simple operations like add or load

Answer: For a single value updated with simple operations like add or load. Atomics are lock-free for a lone counter or flag; use a Mutex once fields must change together.

What does the -race flag do?

  • Speeds up the program
  • Builds with the race detector to report concurrent unsynchronised access
  • Disables goroutines
  • Forces single-threaded execution

Answer: Builds with the race detector to report concurrent unsynchronised access. go run -race instruments memory access and reports data races.

Can you copy a sync.Mutex or sync.WaitGroup after first use?

  • Yes, freely
  • Only WaitGroup
  • No — copying duplicates internal state and breaks it; pass by pointer
  • Only when unlocked

Answer: No — copying duplicates internal state and breaks it; pass by pointer. These sync types must not be copied; pass them by pointer. go vet flags copies.