Web Analytics

Sync & Context

Advanced ~40 min read

Production Go code requires proper synchronization and cancellation. The sync package provides primitives for coordinating goroutines, while the context package enables cancellation, timeouts, and request-scoped values. Master these essential tools for building robust concurrent applications.

Sync Primitives and Context in Go

Figure: Sync Primitives (WaitGroup, Mutex, RWMutex, Once) and Context Package (Cancellation, Timeout, Values)

sync.WaitGroup

A WaitGroup waits for a collection of goroutines to finish. It's the most common way to coordinate multiple goroutines:

Output
Click Run to execute your code
WaitGroup Methods:
  • Add(n) - Increment the counter by n
  • Done() - Decrement the counter by 1 (same as Add(-1))
  • Wait() - Block until counter reaches 0
Best Practice: Always call wg.Add() before starting the goroutine, and use defer wg.Done() to ensure Done() is called even if the goroutine panics.

sync.Mutex

A Mutex (mutual exclusion lock) protects shared data from concurrent access:

Output
Click Run to execute your code
Critical Section: The code between Lock() and Unlock() is the critical section. Only one goroutine can execute it at a time.
Pro Tip: Always use defer mu.Unlock() immediately after mu.Lock() to ensure the lock is released even if the function panics.

sync.RWMutex

A RWMutex is a reader/writer mutual exclusion lock. Multiple readers can hold the lock simultaneously, but writers have exclusive access:

Output
Click Run to execute your code
Lock Type Methods Use Case
Read Lock RLock(), RUnlock() Multiple concurrent readers
Write Lock Lock(), Unlock() Exclusive write access

sync.Once

sync.Once ensures a function is executed only once, even when called from multiple goroutines. Perfect for initialization:

Output
Click Run to execute your code
Singleton Pattern: sync.Once is commonly used to implement thread-safe singletons in Go.

Context Package

The context package provides a way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines:

Context Types

// Root contexts
ctx := context.Background()  // Main context, never cancelled
ctx := context.TODO()        // Placeholder when context is unclear

// Derived contexts
ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, duration)
ctx, cancel := context.WithDeadline(parent, time)
ctx = context.WithValue(parent, key, value)

Context Cancellation

Use WithCancel to create a cancellable context:

Output
Click Run to execute your code
Best Practice: Always call cancel() when you're done with a context, even if it's not explicitly cancelled. Use defer cancel() to ensure cleanup.

Context Timeout & Deadline

Use WithTimeout or WithDeadline to automatically cancel operations after a duration:

Output
Click Run to execute your code

Deadline Example

Output
Click Run to execute your code

Context Values

Context can carry request-scoped values, but use this feature sparingly:

Output
Click Run to execute your code
Warning: Context values should only be used for request-scoped data that crosses API boundaries (like request IDs, auth tokens). Don't use them to pass optional parameters to functions!

Common Mistakes

1. Forgetting to call Done()

// ❌ Wrong - goroutine might panic before Done()
go func() {
    wg.Add(1)
    // ... work ...
    wg.Done()  // Might not be called!
}()

// ✅ Correct - defer ensures Done() is called
go func() {
    wg.Add(1)
    defer wg.Done()
    // ... work ...
}()

2. Not deferring Unlock()

// ❌ Wrong - might forget to unlock
func (c *Counter) Increment() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()  // Might be skipped if panic occurs
}

// ✅ Correct - defer ensures unlock
func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

3. Not calling cancel()

// ❌ Wrong - context leak
func doWork() {
    ctx, cancel := context.WithTimeout(parent, time.Second)
    // ... work ...
    // Forgot to call cancel()!
}

// ✅ Correct - always defer cancel
func doWork() {
    ctx, cancel := context.WithTimeout(parent, time.Second)
    defer cancel()
    // ... work ...
}

Exercise: Concurrent Task Processor

Task: Build a concurrent task processor with cancellation support.

Requirements:

  • Process 100 tasks using 5 workers
  • Use WaitGroup to coordinate workers
  • Use Context to support cancellation
  • Use Mutex to safely collect results
  • Cancel all workers after 3 seconds
Show Solution
package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

type Results struct {
    mu      sync.Mutex
    results []int
}

func (r *Results) Add(value int) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.results = append(r.results, value)
}

func worker(ctx context.Context, id int, tasks <-chan int, results *Results, wg *sync.WaitGroup) {
    defer wg.Done()
    
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d cancelled\n", id)
            return
        case task, ok := <-tasks:
            if !ok {
                return
            }
            // Process task
            result := task * 2
            results.Add(result)
            fmt.Printf("Worker %d processed task %d\n", id, task)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    
    tasks := make(chan int, 100)
    results := &Results{}
    var wg sync.WaitGroup
    
    // Start workers
    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go worker(ctx, i, tasks, results, &wg)
    }
    
    // Send tasks
    go func() {
        for i := 1; i <= 100; i++ {
            tasks <- i
        }
        close(tasks)
    }()
    
    // Wait for completion or timeout
    wg.Wait()
    
    fmt.Printf("\nProcessed %d tasks\n", len(results.results))
}

Summary

  • sync.WaitGroup coordinates multiple goroutines
  • sync.Mutex protects shared data with exclusive locks
  • sync.RWMutex allows multiple readers or one writer
  • sync.Once ensures initialization happens only once
  • context.Context carries cancellation signals and deadlines
  • WithCancel creates manually cancellable contexts
  • WithTimeout/WithDeadline creates auto-cancelling contexts
  • Context values should only be used for request-scoped data
  • Always defer Done(), Unlock(), and cancel()

What's Next?

Now that you understand sync primitives and context, you're ready to explore Packages & Modules. In the next lesson, you'll learn how to organize your code into reusable packages and manage dependencies with Go modules.