go go/book/learninggo

Golang sync.WaitGroup: Powerful, but tricky - WunderGraph

WaitGroup

  • A WaitGroup is a synchronization mechanism provided by the sync package.
  • It is used to wait for a collection of goroutines to complete their execution.
  • The WaitGroup allows you to add a count representing the number of goroutines that need to complete, and it provides methods to signal when a goroutine finishes and to block execution until all the goroutines have completed

Key Methods of sync.WaitGroup

  1. Add(delta int): Increments the counter by the specified delta value, typically the number of goroutines you want to wait for.
  2. Done(): Decrements the counter by 1. This is usually called at the end of a goroutine to indicate its completion.
  3. Wait(): Blocks the execution of the calling goroutine until the counter becomes zero.

Using WaitGroup

// This is a common idiom in Go.
wg := &sync.WaitGroup{} 

You often need to pass the WaitGroup to goroutines, and passing a pointer ensures all goroutines share the same instance.

DeclarationDescription
var wg sync.WaitGroupA value type. Can be used directly.
wg := &sync.WaitGroup{}A pointer type. Used when shared between functions/goroutines.
wg := sync.WaitGroupequivalent to var wg sync.WaitGroup
func main() {
	wg := &sync.WaitGroup{} // pointer
 
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Println("Worker:", id)
		}(i)
	}
 
	wg.Wait()
}

If you accidentally pass a copy of a WaitGroup to a function or goroutine: It won’t work as expected — you’ll wait forever, because you’re not updating the original.

func doSomething(wg sync.WaitGroup) {
	wg.Add(1) // modifies a copy — doesn't affect the original
}

Common Pitfalls

func main() {
	wg := &sync.WaitGroup{}
	// Intend to wait for one goroutine`
	wg.Add(1)
 
	go func() {
		// defer wg.Done() // PROBLEM: Defer is missing!
 
		req, err := http.NewRequest("GET", "https://api.example.com/data", nil)
		if err != nil {
			fmt.Println("Error creating request:", err)
			// We return early, wg.Done() is never called.
			return
		}
 
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Println("Error sending request:", err)
			// We return early, wg.Done() is never called.
			return
		}
		defer resp.Body.Close()
		// Only call Done() on the happy path
		fmt.Println("Response Status:", resp.Status) // <<< If errors happen, this line is skipped!
	}()
 
	wg.Wait() // <<< This will wait FOREVER if an error occurs above.
}

Mutex

A Mutex is a mutual exclusion lock that prevents other processes from entering a critical section of data while a process occupies it to prevent race conditions from happening.

What's a critical section?

A critical section can be a piece of code that must not be run by multiple threads at once because the code contains shared resources.

When multiple goroutines access shared memory (like a variable, map, or struct) concurrently, it can lead to race conditions — unpredictable, buggy behavior.

A mutex serializes access to such shared data, allowing only one goroutine to access the critical section at a time.

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	wg := &sync.WaitGroup{} // pointer
 
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Println("Worker:", id)
		}(i)
	}
 
	wg.Wait()
}
func doSomething(wg sync.WaitGroup) { // Value type
	wg.Add(1) // modifies a copy — doesn't affect the original
}

Protecting shared map

The sync.Mutex type, like many types in Go, is safe to use as a zero value. we don’t want to explicitly initialize it.

type SafeMap struct {
	mu sync.Mutex
	m  map[string]int
}
 
func main() {
	sm := SafeMap{m: make(map[string]int)}
	// sm := &SafeMap{m: make(map[string]int)} <<< If we want pass the map to modify.
	wg := sync.WaitGroup{}
 
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			sm.mu.Lock()
			defer sm.mu.Unlock()
			sm.m[fmt.Sprintf("worker-%d", id)] = id
		}(i)
	}
 
	wg.Wait()
	fmt.Println(sm.m)
}
// Use unlock with defer
sm.mu.Lock()
defer sm.mu.Unlock()
func unsafeCopy(sm SafeMap) {
	sm.mu.Lock()       // <- this is a copy of the original mutex
	defer sm.mu.Unlock()
	// does not actually lock the original SafeMap!
}
 
----
 
func safeAccess(sm *SafeMap) {
	sm.mu.Lock()
	defer sm.mu.Unlock()
	// safely access sm.m
}