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.