Wait Groups

What is sync.WaitGroup?
WaitGroup is a struct from the “sync” package that lets you synchronize the execution of multiple goroutines. It works by counting how many goroutines have not finished yet, blocking main until the counter reaches zero.
How to use WaitGroup?
- Declare a variable of type
sync.WaitGroup. - Before starting a goroutine that must be synchronized, call
wg.Add(1)to increment the counter. - Inside the goroutine, when the work is done, call
wg.Done()to decrement the counter. - At the end of main (or wherever you need to wait), call
wg.Wait()to wait for all goroutines to finish.
Example:
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{}
wg.Add(1) // Tells it we will have one goroutine to synchronize
go func() {
defer wg.Done() // Makes sure wg.Done() is called when the goroutine ends
fmt.Println("Hello, WaitGroup!")
}()
// Waits for all goroutines to finish
wg.Wait()
}
Note that sync.WaitGroup has no constructor. That is because the WaitGroup struct is initialized with its zero values, which are already valid for use. So you can simply declare a variable of type sync.WaitGroup and start using it right away.
Using WaitGroup with multiple goroutines in a loop
Example with 5 goroutines:
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{}
// Adds 1 on each iteration, before starting the goroutine
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("Goroutine %d running\n", n)
}(i)
}
// Waits for all goroutines to finish
wg.Wait()
}
Using it with functions that simulate long tasks
You can create functions that do some work (like a time.Sleep) and synchronize them with the WaitGroup.
Example:
package main
import (
"fmt"
"sync"
"time"
)
// Function that simulates a long job
func longJob() {
fmt.Println("Starting long job")
time.Sleep(2 * time.Second)
fmt.Println("Long job finished")
}
func main() {
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
longJob()
}()
// main waits for longJob to finish
wg.Wait()
}
Things to watch out for
- Always call
wg.Add()before starting the goroutine you intend to synchronize. - Make sure every goroutine calls
wg.Done()to signal that it has finished. - Use
defer wg.Done()right at the start of the goroutine’s function so you don’t forget it, even if errors or early returns happen.
Exercises
-
Launch a single goroutine that prints “Hello, WaitGroup!”.
- Use
sync.WaitGroupto control the execution. - Make sure to call
wg.Done()at the end of the goroutine andwg.Wait()in main.
- Use
-
Launch 5 goroutines using a for loop.
- Each goroutine must print a message identifying its number (for example, “Goroutine 1”, “Goroutine 2”, etc.).
-
Write a program with a function called
longJob, that:- Prints “Starting long job”,
- Does a
time.Sleepto simulate an operation that takes 2 seconds, - Then prints “Long job finished”.
- Launch this function in a goroutine controlled by a WaitGroup and make sure main waits for it to finish before exiting.
-
Write a program that launches two parallel goroutines:
- The first goroutine must, in a loop from 1 to 5, print “Number: X” (where X is the current number) and do a short
time.Sleep(for example, 200ms) between iterations. - The second goroutine must, also in a loop from 1 to 5, print “Letter: A, B, C, …” (you can define a fixed sequence or use an array of letters) with a similar
time.Sleepbetween iterations.
- The first goroutine must, in a loop from 1 to 5, print “Number: X” (where X is the current number) and do a short
Common mistakes with WaitGroup
Forgetting to call wg.Add() before starting a goroutine
- Mistake: If you don’t tell the wait group that there will be a new goroutine, the
wg.Wait()call in main may return immediately, even with goroutines still running. - Tip: Always call
wg.Add(1)(or the needed amount) before starting the matching goroutine.
Not calling wg.Done() at the end of a goroutine
- Mistake: If a goroutine doesn’t signal that it finished by calling
wg.Done(), thewg.Wait()in main will wait forever, causing a deadlock. - Tip: Make sure to put
wg.Done()at the end of the function being run, or usedefer wg.Done()right at the start of the goroutine.
Passing the WaitGroup by value
- Mistake: In some cases, without passing the WaitGroup as a pointer, the goroutines may work with copies of the wait group, so the calls to
wg.Add(),wg.Done()andwg.Wait()don’t talk to each other correctly. - Tip: Always pass the wait group by reference (for example,
*sync.WaitGroup) if you are using functions that receive it as an argument.
Calling wg.Add() inside the goroutine
- Mistake: You may end up calling
wg.Add()or changing the wait group after the goroutines are already running, which can create unexpected conditions or make synchronization harder to control. - Tip: The call to
wg.Add()must happen before starting the goroutine that depends on that increment.
Exercise solutions
So I wouldn’t have to run each exercise by hand, I used tests and testable examples.
Exercise 1:
// main.go
func Ex1() {
// Creates a WaitGroup instance.
wg := &sync.WaitGroup{}
// Adds a counter to the WaitGroup.
wg.Add(1)
// Starts a new goroutine.
go func() {
// Makes sure Done is called when the goroutine ends.
defer wg.Done()
// Prints a message.
fmt.Println("Hello, WaitGroup!")
}()
// Waits until the WaitGroup counter is zero.
wg.Wait()
}
// main_test.go
func ExampleEx1() {
Ex1()
// output: Hello, WaitGroup!
}
Exercise 2:
// main.go
func Ex2() {
wg := &sync.WaitGroup{}
for i := range 5 {
// Careful! Don't forget to call wg.Add(1) on each iteration of the for
// OR use a wg.Add(5) before the for!
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println("Goroutine", i)
}(i)
wg.Wait()
}
}
// main_test.go
func TestEx2(t *testing.T) {
out := captureOutput(Ex2)
expectedOutputs := []string{
"Goroutine 0",
"Goroutine 1",
"Goroutine 2",
"Goroutine 3",
"Goroutine 4",
}
for _, expected := range expectedOutputs {
if !strings.Contains(out, expected) {
t.Fatal("Output not generated: ", expected, " got: ", out)
}
}
}
func captureOutput(f func()) string {
orig := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
f()
os.Stdout = orig
w.Close()
out, _ := io.ReadAll(r)
return string(out)
}
Exercise 3:
// main.go
func longJob(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Starting long job")
time.Sleep(2 * time.Second)
fmt.Println("Long job finished")
}
func Ex3() {
wg := &sync.WaitGroup{}
wg.Add(1)
go func () {
defer wg.Done()
longJob(&wg)
}()
wg.Wait()
}
// main_test.go
func ExampleEx3() {
Ex3()
// output:
// Starting long job
// Long job finished
}
Exercise 4:
// main.go
func Ex4() {
wg := &sync.WaitGroup{}
alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
wg.Add(1)
go func() {
defer wg.Done()
for i := range 5 {
fmt.Printf("Number: %d\n", i)
time.Sleep(200 * time.Millisecond)
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for i := range 5 {
fmt.Printf("Letter: %s\n", string(alphabet[i]))
time.Sleep(200 * time.Millisecond)
}
}()
wg.Wait()
}
// main_test.go
func TestEx4(t *testing.T) {
out := captureOutput(Ex4)
expectedOutputs := []string{
"Number: 0",
"Number: 1",
"Number: 2",
"Number: 3",
"Number: 4",
"Letter: A",
"Letter: B",
"Letter: C",
"Letter: D",
"Letter: E",
}
for _, expected := range expectedOutputs {
if !strings.Contains(out, expected) {
t.Fatal("Output not generated: ", expected, " got: ", out)
}
}
}
Next steps!
Next we will cover Channels! Click here for part 3!