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.Sleep guess with something exact

WaitGroup is a counter main waits on Add raises the counter before each goroutine starts, Done lowers it when one finishes, Wait blocks until it is zero. main wg.Add(3) worker 1 worker 2 worker 3 defer wg.Done() counter 3 → 2 → 1 → 0 wg.Wait() returns Pass *sync.WaitGroup (a pointer): copying it would count on a copy. Call Add before `go`, never inside the goroutine.
Add raises the counter before each goroutine starts, Done lowers it when one finishes, Wait blocks until it is zero.

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 WaitGroup by value. The goroutine then decrements a copy and Wait never returns (a deadlock). Pass &wg.

  • Calling Add inside the goroutine. Wait might run first and see zero. Call Add before go.

  • Forgetting Done. Wait blocks forever. defer wg.Done() guarantees it even on early return or panic.

  • Too many Done calls. 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”.