sync.WaitGroup and Mutexes
Waiting for a group of goroutines to finish, and protecting shared state when channels aren't the right tool.
3 min read
Channels are Go's preferred way to coordinate goroutines, but not every problem is naturally shaped like passing values through a pipe. The sync package covers two situations channels handle awkwardly: waiting for a batch of goroutines to finish, and protecting a piece of shared state that multiple goroutines need to touch directly.
sync.WaitGroup: waiting for goroutines to finish
A WaitGroup is a counter that tracks how many goroutines are still running, and lets you block until it hits zero.
import "sync"
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d done\n", id)
}(i)
}
wg.Wait()
fmt.Println("all workers finished")
}The pattern is always the same three calls: Add(1) before starting each goroutine, Done() (almost always via defer, so it runs even if the goroutine panics) when that goroutine finishes, and Wait() in the calling goroutine to block until the count returns to zero. This is the correct replacement for the time.Sleep-as-a-guess approach from the goroutines lesson — it waits exactly as long as needed, no more and no less.
Why not just use a channel here?
You could coordinate this specific case with a channel too, but a WaitGroup is a better fit when you don't actually need any value back from the goroutines — you just need to know they're done. Reach for channels when data needs to flow between goroutines; reach for a WaitGroup when you only need a completion signal.
The problem mutexes solve
The moment multiple goroutines read and write the same variable without coordination, you have a data race — undefined, unpredictable behavior, not just a slow path:
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // DATA RACE: not atomic, unsafe from multiple goroutines
}()
}
wg.Wait()
fmt.Println(counter) // unpredictable -- often not 1000counter++ looks like one operation but is actually read-modify-write, and two goroutines can interleave those steps and lose an update. Go's race detector (go run -race main.go) will catch exactly this kind of bug, and it's worth running routinely on any concurrent code.
sync.Mutex: mutual exclusion
A Mutex (mutual exclusion lock) ensures only one goroutine at a time can execute the code between Lock() and Unlock():
var (
counter int
mu sync.Mutex
)
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
counter++
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println(counter) // reliably 1000
}defer mu.Unlock() right after Lock() is the standard idiom — it guarantees the lock releases even if the function returns early or panics partway through.
Choosing between channels and mutexes
Go gives you both tools deliberately, and the rule of thumb echoes the proverb from the goroutines lesson: prefer channels when goroutines need to communicate or hand off data, and reach for a mutex when you have a genuinely shared piece of state (a cache, a counter, an in-memory map) that several goroutines need to read and write directly. Neither is "more correct" than the other — using a mutex to protect a shared map is completely idiomatic Go, not a sign you're avoiding channels incorrectly.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.