Session 15 · WaitGroup: waiting for goroutines#
The big idea#
Starting goroutines is easy. Knowing when they are all done is the hard part. A
sync.WaitGroup is a counter built for that. You add one for every goroutine you start, each one
subtracts one when it finishes, and Wait() pauses until the counter hits zero.
Everyday example: a group trip. The guide counts 3 people onto the bus and will not drive off until all 3 have checked back in. No guessing “they’ve probably finished by now”.
In plain words
wg.Add(1) before go, defer wg.Done() first thing inside the goroutine, wg.Wait() where you
need all of them finished. Pass the group by pointer.
Reach for it when:
you start several goroutines and need all of them before continuing
you fetch several URLs or process several files in parallel
you replace a
time.Sleepguess with something exact
Three workers#
workers.go: the course’s three workers, with each finishing at a different time.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // tell the group "I'm finished", even on early return
time.Sleep(time.Duration(id) * 50 * time.Millisecond)
fmt.Println("worker", id, "finished")
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // add before starting the goroutine
go worker(i, &wg)
}
wg.Wait() // blocks until the counter is back to zero
fmt.Println("All workers completed")
}
worker 1 finished
worker 2 finished
worker 3 finished
All workers completed
main blocks at wg.Wait() until the counter is zero, and only then prints the last line. There is
no Sleep guess anywhere.
Collecting results safely#
results.go: each goroutine writes only its own slot of a pre-sized slice, so they never collide.
package main
import (
"fmt"
"strings"
"sync"
)
func main() {
words := []string{"go", "is", "fun", "and", "fast"}
results := make([]string, len(words)) // one slot per goroutine: no clash
var wg sync.WaitGroup
for i, w := range words {
wg.Add(1)
go func() { // Go 1.22+: i and w are new on every iteration
defer wg.Done()
results[i] = strings.ToUpper(w)
}()
}
wg.Wait()
fmt.Println(results)
}
[GO IS FUN AND FAST]
The order of the work is unpredictable, but the order of the results is fixed because each goroutine
owns one index. In Go 1.22 and newer, i and w are fresh on every loop turn, so the closure can use them directly.
Common mistakes#
Passing the
WaitGroupby value. The goroutine then decrements a copy andWaitnever returns (a deadlock). Pass&wg.Calling
Addinside the goroutine.Waitmight run first and see zero. CallAddbeforego.Forgetting
Done.Waitblocks forever.defer wg.Done()guarantees it even on early return or panic.Too many
Donecalls. The counter goes negative and the program panics.Appending to one shared slice from many goroutines. That is a race. Use indexed slots, a channel, or a mutex.
Hands-on exercises#
Exercise 1 · Fan out. Start 5 goroutines, each squaring its own number into its own slot of a result slice, then print the slice.
Solution
package main
import (
"fmt"
"sync"
)
func main() {
squares := make([]int, 5)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
squares[i] = i * i
}()
}
wg.Wait()
fmt.Println(squares)
}
[0 1 4 9 16]
Exercise 2 · Parallel “downloads”. Simulate three downloads of 100 ms each. Show that the total time is close to 100 ms, not 300 ms.
Solution
package main
import (
"fmt"
"sync"
"time"
)
func download(name string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(100 * time.Millisecond)
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for _, f := range []string{"a.zip", "b.zip", "c.zip"} {
wg.Add(1)
go download(f, &wg)
}
wg.Wait()
elapsed := time.Since(start)
fmt.Println("under 200ms:", elapsed < 200*time.Millisecond)
}
under 200ms: true
Exercise 3 · Find the bug. This program deadlocks (running it prints working and then fatal error: all goroutines are asleep - deadlock!). Explain why, then fix it.
package main
import (
"fmt"
"sync"
)
func work(wg sync.WaitGroup) {
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go work(wg)
wg.Wait()
}
Solution
work received a copy of the WaitGroup, so its Done() lowered the copy’s counter and the real counter stayed at 1: Wait() never returned. The fix is to pass a pointer:
package main
import (
"fmt"
"sync"
)
func work(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go work(&wg)
wg.Wait()
fmt.Println("done")
}
working
done
go vet reports the original mistake as “passes lock by value”.