JavaRush /Courses /Go SELF /sync.WaitGroup: Add, Done, Wait and common mistakes

sync.WaitGroup: Add, Done, Wait and common mistakes

Go SELF
Level 65, Lesson 2
Available

1. Why goroutines don’t “keep” a program alive

When you launch a goroutine for the first time, it feels like you hired a tiny assistant: “Do the work, and I’ll move on for now.” But Go has its own sense of humor: the assistant keeps working, while you can suddenly turn off the lights in the whole office because main finished. So we need a way to wait for background work to complete—not “by eye”, not “let’s sleep 100 milliseconds”, but via a clear contract.

Let’s start by demonstrating the problem. It seems perfectly logical: start printing in a goroutine—and move on.

package main

import "fmt"

func main() {
	go fmt.Println("hello from goroutine")
	fmt.Println("main finished")
}

In practice, you’ll often see only:

main finished

Because main finished, the process stopped, and the goroutine didn’t even get CPU time.

Sometimes a beginner adds Sleep and celebrates that it “fixed it”:

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("hello from goroutine")

	time.Sleep(10 * time.Millisecond) // hack for demonstration
	fmt.Println("main finished")
}

That may indeed “work”, but it’s like fixing a leaky faucet with duct tape: the water stops exactly until the moment you blink. In concurrent code, “it worked on my laptop” is not proof of correctness. Sleep is fine as a way to highlight the mechanics (show that goroutines do run), but not as a waiting mechanism.

2. What is sync.WaitGroup

When a program has several parallel (concurrent) tasks, your brain quickly starts getting tangled: “How many are still alive? Who is done? When can we print the result?”. sync.WaitGroup is Go’s simplest answer to this everyday pain: it stores a counter of “how many jobs haven’t completed yet” and can wait until that counter becomes zero.

The mental model is: WaitGroup is a scoreboard “Tasks remaining: N”. You increase N when you plan work, and you decrease N when a particular job completes. Then you say once: “I’ll wait until N becomes 0.”

In Go, WaitGroup lives in the sync package, so the import is always:

import "sync"

WaitGroup has three key operations. It’s convenient to remember them as a table:

Method What it does How to read it “in plain English”
Add(n)
increases/decreases the counter by n “add/subtract tasks”
Done()
decreases the counter by 1 “this task finished”
Wait()
blocks until the counter becomes 0 “wait for everyone”

An important nuance: the zero value of sync.WaitGroup is ready to use. That is, you can write var wg sync.WaitGroup—and that’s fine. No make, no new, no “initialization” needed.

3. The Add, Done, Wait contract

WaitGroup looks simple, and it really is… until you start placing Add, Done, and Wait in “interesting” places. Here it’s important not just to know the methods, but to keep the usage contract in mind. Otherwise you’ll get a deadlock (waiting forever) or, conversely, the program will exit too early, or you’ll even catch a panic from sync with text about misuse.

The core rule sounds a bit nerdy, but it saves hours of your life: we increase the counter via Add before launching the goroutine, and we decrease it via Done inside the goroutine, guaranteeing the decrease using defer.

Here is the “ideal” lifecycle of a single task:

flowchart TD
    A[main] --> B["wg.Add(1)"]
    B --> C["go worker()"]
    C --> D["worker: defer wg.Done()"]
    D --> E[worker: does the work]
    E --> F[worker finished]
    F --> G[wg counter decremented]
    G --> H["main: wg.Wait()"]
    H --> I[main continues]

Why must Add be before go? Because go launches work concurrently: a new goroutine can finish before you “add it to the list of things to wait for”, and then the waiting logic breaks. In modern Go, this often leads to an explicit “WaitGroup misuse” panic so you don’t live in illusions.

Why is it convenient to put Done via defer at the start of the goroutine? Because you may have early returns, errors, branching—and you don’t want one branch to forget to “check in on the scoreboard” while Wait() waits forever.

4. WaitGroup usage patterns

Minimal example: one goroutine

Now let’s assemble the “canonical” minimal example: one goroutine does the work, main waits.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("work done") // work done
	}()

	wg.Wait()
	fmt.Println("main exits") // main exits
}

Here you can clearly see the “ritual” (in a good sense): first we say “there will be 1 job” (Add(1)), then we launch the goroutine, inside we immediately put defer wg.Done(), and then in main we call wg.Wait().

If you mix up the order and do wg.Add(1) after go ..., you’ll at least get very fragile behavior, and at worst—a panic crash. And that’s correct: it’s better to fail fast than to sometimes print “work done”, sometimes not, and sometimes hang at an unexpected moment.

Several goroutines in a loop

In real programs, you more often want to launch a batch of similar jobs: process several items, prepare several report lines, compute several chunks of a result. This is where WaitGroup really shines: it can wait for an entire group.

Imagine we have a training “task tracker” app (a task manager). For now we’ll take the data directly in code—no files and no database—today the waiting mechanics are what matters.

Let’s describe a minimal task model:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Now we want to produce report lines like:

  • [ ] 1: Buy milk
  • [x] 2: Fix bug

Let’s assume formatting is “kind of” expensive (for demonstration we’ll add a small delay based on ID). Important: the delay is only so you can visually see concurrency, not to “synchronize” the program.

package main

import (
	"fmt"
	"time"
)

func formatTaskLine(t Task) string {
	time.Sleep(time.Duration(t.ID) * 10 * time.Millisecond)
	mark := " "
	if t.Done {
		mark = "x"
	}
	return fmt.Sprintf("[%s] %d: %s", mark, t.ID, t.Title)
}

Now we build the report concurrently: one goroutine per task, but we write the result into a preallocated slice at its index. This is an important discipline: each goroutine writes into its own “cell”, and we don’t do a shared append from different goroutines.

package main

import (
	"fmt"
	"sync"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: "Buy milk", Done: false},
		{ID: 2, Title: "Fix bug", Done: true},
		{ID: 3, Title: "Read Go docs", Done: false},
	}

	lines := make([]string, len(tasks))

	var wg sync.WaitGroup
	wg.Add(len(tasks))

	for i, t := range tasks {
		go func(i int, t Task) {
			defer wg.Done()
			lines[i] = formatTaskLine(t)
		}(i, t)
	}

	wg.Wait()

	for _, line := range lines {
		fmt.Println(line)
	}
}

Here you can see several strong ideas at once.

  • We do wg.Add(len(tasks)) once. This reduces the chance of making a mistake and forgetting Add(1) inside the loop.
  • We pass both the index and the task itself to the anonymous function as parameters. Yes, even if you “heard that in newer Go versions everything is fixed with loop variables”, this style is still very readable: it’s explicit that the goroutine works with specific values.
  • We don’t print from goroutines. We print after wg.Wait(), so the output will be stable in task order, even if the goroutines themselves completed “interleaved”.

Passing WaitGroup into functions

As the program grows, you’ll quickly want to move the goroutine logic into a separate function: it’s more readable, easier to test, and there’s less “mess” in main. And here a very common mistake appears: passing WaitGroup by value (i.e., copying it), and then wondering why Wait() hangs. WaitGroup is a struct with internal state, and you must not carelessly copy it after you start using it.

The correct style: pass a pointer.

package main

import (
	"fmt"
	"sync"
)

func renderTask(wg *sync.WaitGroup, t Task) {
	defer wg.Done()
	fmt.Println("render:", t.ID) // e.g.: render: 2
}

func main() {
	var wg sync.WaitGroup

	wg.Add(1)
	go renderTask(&wg, Task{ID: 1, Title: "Buy milk"})

	wg.Wait()
}

Why a pointer? Because wg must be the same counter object for all goroutines and for the code that calls Wait(). If you pass wg by value, you get a copy: the goroutine decrements the counter in the copy, while main waits on the original counter. It’s like “reporting a completed task to your neighbor instead of your manager.”

What WaitGroup does not do

It’s very easy to fall in love with WaitGroup and start expecting it to do everything: “let it ensure ordering, collect results, and defeat races.” But WaitGroup is a narrow tool: it only waits for completion.

It does not guarantee the execution order of goroutines. If you do:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		fmt.Println("A")
	}()

	go func() {
		defer wg.Done()
		fmt.Println("B")
	}()

	wg.Wait()
}

Printing might be A then B, or B then A. And this is not a “bug”; it’s normal nondeterminism of concurrent execution.

It also does not make access to shared memory safe. If multiple goroutines mutate one variable without access discipline, WaitGroup won’t save you. It will honestly wait until everyone finishes… and you’ll get an unpredictable result that sometimes “seems to work.” Today our goal is to learn to wait correctly; shared-state safety is a separate topic and a separate set of tools and mental models.

5. Common mistakes when working with sync.WaitGroup

Almost all problems with WaitGroup look the same: the program either hangs on Wait(), or suddenly crashes with a panic from sync, or “sometimes doesn’t print some lines.” The most frustrating part is that the mistakes are usually not in complex logic, but in a single line that is “in the wrong place.” So it’s useful to calmly walk through the most common rakes, so you can recognize them at a glance later.

Mistake #1: wg.Add(1) inside a goroutine.
It’s tempting to do this: “I’m launching work, let it register itself.” But this breaks the contract: Wait() may start before you increment the counter, and the runtime will rightly complain about misuse—or you’ll get an early exit. The correct discipline is Add in the launching code, before go.

var wg sync.WaitGroup

go func() {
	wg.Add(1)        // bad: Add inside goroutine
	defer wg.Done()
}()
wg.Wait()

Mistake #2: Add is placed after go.
This is almost the same issue, just reversed in form: you launch the goroutine and only then “add it to the list of things to wait for.” The goroutine may complete before Add, and waiting stops being guaranteed.

Mistake #3: you forgot Done(), or it’s not guaranteed (no defer).
This is the classic “wait forever”: the counter never returns to zero, and Wait() blocks forever. This often happens because of an early return inside a goroutine or because of multiple branches.

A good habit: inside a goroutine, make the first line defer wg.Done(), and only then write any logic you want.

Mistake #4: mismatch between the number of Add and Done calls (the counter goes negative).
If you call Done() more times than you “added”, WaitGroup will get a negative counter and crash with a panic. Usually this happens when you do wg.Add(1) in one place, but accidentally call wg.Done() twice: once directly and once via defer, or you run the same function as a worker but forget that Done is already inside.

Mistake #5: you passed WaitGroup by value and accidentally copied it.
This is that trap where “everything compiles, but Wait() hangs.” If you pass wg as a parameter of type sync.WaitGroup, you made a copy.

func worker(wg sync.WaitGroup) { // bad: copy
	defer wg.Done()
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go worker(wg)
	wg.Wait() // may hang
}

The correct option is to accept *sync.WaitGroup and pass &wg.

1
Task
Go SELF, level 65, lesson 2
Locked
Waiting for the worker
Waiting for the worker
1
Task
Go SELF, level 65, lesson 2
Locked
Squares in Order
Squares in Order
1
Task
Go SELF, level 65, lesson 2
Locked
Worker report
Worker report
1
Task
Go SELF, level 65, lesson 2
Locked
Parallel classification
Parallel classification
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION