Skip to content
Theory · Goroutines and channels · lesson 2 of 13

sync.WaitGroup

In the previous lesson main closed the kitchen without waiting for the cooks, and their work was lost. A real head chef does not do that. On the wall hangs a board with the number of orders still being cooked. An order comes in—add one; a cook hands over a dish—subtract one. The kitchen closes only when the board shows zero.

A counter of goroutines

In Go, that board is the sync.WaitGroup type from the sync package. It is a counter of goroutines that have not finished yet:

GO
package main

import "fmt"
import "sync"

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("hello") // hello
    }()
    wg.Wait()
    fmt.Println("main") // main
}

Let's walk through it step by step:

  1. wg.Add(1) writes one order on the board: the counter becomes 1.
  2. go func() { … }() starts a goroutine, and it prints hello.
  3. On its way out of the goroutine, defer wg.Done() subtracts one.
  4. Meanwhile wg.Wait() in main waits until the counter reaches zero.
  5. Only after that does main print main.

So the program prints both lines on every run, and hello always comes first.

The packages here are imported with two import lines: this is the same as one line with parentheses.

Add, Done and Wait

A WaitGroup has three methods. The zero value of a WaitGroup is ready to use: its counter is zero.

Add(n)

The Add(n) method raises the counter by n.

Interviewers like to ask this: where do you call Add? The answer: before the go statement, in the goroutine that later calls Wait. If Add moves into the new goroutine, Wait may run before it, see zero and return while the work is not done. The chef closes the kitchen because the order was not on the board yet.

Done()

The Done() method lowers the counter by one. It is called with defer on the first line of the goroutine, so that it runs however the function exits.

If Done is called more times than Add added, the program panics with sync: negative WaitGroup counter. There is no such thing as a negative number of orders.

Wait()

The Wait() method waits until the counter reaches zero.

This is where the board picture stops working: a chef's board can say who is cooking what, while a WaitGroup holds a single number. It does not know which goroutines are still running and cannot tell you whom Wait is waiting for.

Results in a shared slice

Goroutines often write their results into a shared slice, each into its own element. Then the result does not depend on the order in which they ran:

GO
var wg sync.WaitGroup
squares := make([]int, 3)
for i := range 3 {
    wg.Add(1)
    go func() {
        defer wg.Done()
        squares[i] = i * i
    }()
}
wg.Wait()
fmt.Println(squares) // [0 1 4]

Here is why the result is always the same:

  • each goroutine puts its dish on its own shelf: squares[0], squares[1] or squares[2];
  • no two goroutines ever write to the same element, so their order does not matter;
  • after Wait all three shelves are filled, and the program prints [0 1 4].

The Go method

Since Go 1.25, WaitGroup has a Go method. The call wg.Go(f) takes three steps:

  1. It raises the counter by one.
  2. It starts f in a new goroutine.
  3. It calls Done when f returns.

The loop above gets shorter:

GO
var wg sync.WaitGroup
squares := make([]int, 3)
for i := range 3 {
    wg.Go(func() {
        squares[i] = i * i
    })
}
wg.Wait()
fmt.Println(squares) // [0 1 4]

You cannot get the order of Add and go wrong here: the method does both itself.

Common mistake

A WaitGroup was passed to a function by value:

GO
func work(wg sync.WaitGroup) { // ✗
    defer wg.Done()
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go work(wg)
    wg.Wait()
}
TEXT
fatal error: all goroutines are asleep - deadlock!

Here is what happens:

  1. The function got a copy of the board.
  2. work lowers the counter of its own copy, and the counter in main stays at one.
  3. Wait waits forever, and when every goroutine is blocked, the runtime stops the program.

A WaitGroup is never copied: the function takes wg *sync.WaitGroup, and the call passes &wg.

Key points

  • A WaitGroup counts unfinished goroutines: Add(n) adds, Done() subtracts one, Wait() waits for zero.
  • Add is called before the go statement; wg.Go(f) does Add, go and Done itself.
  • Calling Done more often than Add panics with sync: negative WaitGroup counter.
  • A WaitGroup is not copied: a function gets the pointer &wg.

Subscribe to take the course

The course is free. Sign in with Telegram and subscribe to the channel to open every lesson.

Sign in another way