Skip to content
Francisco Cidade
6 min read

Concurrency and Parallelism in Go - Wait Groups

Summary

Wait Groups

alt text

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?

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

Exercises

  1. Launch a single goroutine that prints “Hello, WaitGroup!”.

    • Use sync.WaitGroup to control the execution.
    • Make sure to call wg.Done() at the end of the goroutine and wg.Wait() in main.
  2. Launch 5 goroutines using a for loop.

    • Each goroutine must print a message identifying its number (for example, “Goroutine 1”, “Goroutine 2”, etc.).
  3. Write a program with a function called longJob, that:

    • Prints “Starting long job”,
    • Does a time.Sleep to 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.
  4. 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.Sleep between iterations.

Common mistakes with WaitGroup

alt text

Forgetting to call wg.Add() before starting a goroutine

Not calling wg.Done() at the end of a goroutine

Passing the WaitGroup by value

Calling wg.Add() inside the goroutine

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!

References