17.3 sync.WaitGroup: Waiting for a Group of Goroutines
Right, so you’ve fired off a bunch of goroutines. They’re all off doing their own thing, which is great—that’s the whole point of Go. But now you have a problem: your main function is about to reach the end of its little life, and when it does, it’ll take the entire program down with it, ruthlessly terminating all your still-working goroutines. Rude. You need a way to wait for them to finish their work. You could use a channel to signal completion, but that gets clunky fast with more than one goroutine. Enter sync.WaitGroup. This is your straightforward, no-nonsense RSVP system for goroutine parties.
Think of a WaitGroup as a simple counter with three operations: Add, Done, and Wait. You Add to the counter when you queue up a new goroutine, the goroutine calls Done (which decrements the counter) when it finishes, and you Wait for the counter to hit zero. It’s brilliantly simple.
Here’s the canonical example. Let’s say we want to fetch a bunch of URLs concurrently.
package main
import (
"fmt"
"net/http"
"sync"
)
func main() {
var wg sync.WaitGroup
urls := []string{
"https://golang.org",
"https://github.com",
"https://httpbin.org/delay/2", // A URL that takes a couple seconds
}
for _, url := range urls {
// Increment the WaitGroup counter for each URL we're about to fetch.
wg.Add(1)
// Fire off a goroutine for each URL.
go func(u string) {
// Decrement the counter when this goroutine is done.
defer wg.Done()
// Fetch the URL. Ignoring errors for brevity (don't do this IRL).
resp, err := http.Get(u)
if err == nil {
resp.Body.Close() // Close the body, again ignoring errors
}
fmt.Printf("Fetched %s\n", u)
}(url) // Crucial: pass the URL as an argument to avoid loop variable capture gotcha!
}
// Wait blocks until the WaitGroup counter is zero.
wg.Wait()
fmt.Println("All goroutines finished.")
}
The Rules (They Are Few, But Absolute)
The sync.WaitGroup is not a complicated beast, but it has one major rule you must follow to avoid race conditions and panics: You must call Add before you launch the goroutine, not inside it.
Why? There’s a nightmarish race condition waiting for you if you get this wrong. Imagine this:
- You launch a goroutine.
- Inside the goroutine, you call
wg.Add(1). - The main thread immediately calls
wg.Wait(), sees the counter is still 0, and proceeds happily. - Your goroutine finally gets scheduled and gets around to calling
Add. It’s too late. Your program has already finished, and the fetch never happened.
Calling Add in the main goroutine, before the go statement, ensures the counter is incremented before the waiter (the Wait call) could possibly check it. This is non-negotiable.
defer wg.Done() is Your Friend
Notice I used defer wg.Done() in the example. This is a best practice. It guarantees that no matter how the goroutine exits—whether it runs to completion, hits a return early, or even panics—Done() will be called. Without defer, you have to remember to call it on every single exit path, which is a recipe for forgotten calls and deadlocks. Just defer it. Every time.
The Subtle Art of Passing Parameters
Look at that goroutine again: go func(u string) { ... }(url). We’re passing the current url from the loop as an argument to the anonymous function. This is vital. If you instead did this:
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
http.Get(url) // DON'T DO THIS!!
}()
}
…you’d be capturing the same loop variable url in every goroutine. By the time the goroutines actually run, the loop has likely finished, and url is set to its final value (“https://httpbin.org/delay/2") for all of them. You’d end up fetching the same URL multiple times. It’s a classic Go gotcha. Always pass loop variables as arguments to your goroutines.
What WaitGroup Is Not
It’s important to understand what WaitGroup doesn’t do. It provides no error propagation, no return values, and certainly no cancellation. It’s just a counter. If your goroutines can fail and you need to know about it, you’ll need to pair your WaitGroup with something else, like a channel to collect errors or results. It also doesn’t care which goroutine calls Done; it just needs the right number of calls. It’s a simple tool for a simple job: waiting. And for that job, it’s perfect.