Understand context in Go with examples

Understand context in Go with examples

In Go, context is a package that allows you to pass request-scoped values, cancel signals, and deadlines across API boundaries to all the goroutines involved in handling a request.

It's primarily used in servers to manage the lifecycle of a request, especially to control the cancellation of goroutines that are no longer needed, which can help in saving resources and managing server load more efficiently.

A context.Context is an interface with several methods, but the most commonly used are Done(), Err(), Deadline(), and Value(key interface{}).

When a context is canceled or times out, the Done() channel is closed, signaling all goroutines watching this channel to stop their work and terminate. This mechanism is crucial for writing reliable, scalable, and concurrent code in Go.

Exercise 1: Basic Usage of Context for Cancellation

Objective: Learn to create a context and use it to cancel a goroutine.

  1. Create a context with cancellation.

  2. Start a goroutine that does some work (e.g., a loop) and listens to the context's Done() channel.

  3. Cancel the context after a short delay and observe the goroutine terminating.

Solution:

package main

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

func doWork(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Goroutine exiting...")
            return
        default:
            // Simulate work
            fmt.Println("Working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go doWork(ctx)

    // Cancel the context after 2 seconds
    time.Sleep(2 * time.Second)
    cancel()

    // Wait a bit for the goroutine to finish
    time.Sleep(1 * time.Second)
}

Exercise 2: Context with Timeout

Objective: Understand how to use context with timeouts to automatically cancel goroutines.

  1. Create a context with a timeout.

  2. Launch a goroutine that attempts to perform a task longer than the timeout duration.

  3. See how the goroutine gets canceled automatically when the timeout expires.

Solution:

package main

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

func doWorkWithTimeout(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Goroutine canceled due to timeout")
            return
        default:
            // Simulating work that takes longer than the context's deadline
            fmt.Println("Working...")
            time.Sleep(1 * time.Second) // This sleep represents work being done
        }
    }
}

func main() {
    // Create a context that is canceled automatically after 3 seconds
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel() // It's good practice to call cancel even if the context expires

    go doWorkWithTimeout(ctx)

    // Wait enough time to observe the automatic cancellation
    time.Sleep(5 * time.Second)
}

Exercise 3: Passing Context to Goroutines

Objective: Learn to pass a context to multiple goroutines and manage their lifecycle.

  1. Create a parent context with cancellation.

  2. Start multiple goroutines, each performing different tasks, and pass the same context to all.

  3. Cancel the context and observe how all goroutines terminate.

Solution:

package main

import (
    "context"
    "fmt"
    "math/rand"
    "time"
)

// simulateWork simulates work by sleeping for a random amount of time
func simulateWork(ctx context.Context, workerID int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: Stopping due to cancellation\n", workerID)
            return
        default:
            // Simulate doing some work
            workTime := time.Duration(rand.Intn(1000)) * time.Millisecond
            fmt.Printf("Worker %d: Working for %v\n", workerID, workTime)
            time.Sleep(workTime)
        }
    }
}

func main() {
    // Create a parent context with cancellation
    ctx, cancel := context.WithCancel(context.Background())

    // Start multiple goroutines, passing the same context to all
    for i := 1; i <= 5; i++ {
        go simulateWork(ctx, i)
    }

    // Wait for a short time before cancelling all goroutines
    time.Sleep(3 * time.Second)
    fmt.Println("Main: Cancelling context")
    cancel()

    // Wait a bit for all goroutines to receive the cancellation signal
    time.Sleep(1 * time.Second)
}

Exercise 4: Cancelling Context Based on OS Signals

Objective: Learn how to use context cancellation in response to operating system signals such as SIGINT (Ctrl+C) and SIGTERM. This is particularly useful for gracefully shutting down applications or cleaning up resources before exiting in response to termination requests from the user or the system.

  1. Create a context with cancellation. This context will be used to propagate the cancellation signal to your goroutines.

  2. Set up signal handling for SIGINT and SIGTERM. Use the os/signal package to listen for these signals in your main function. When either signal is received, cancel the context.

  3. Launch a goroutine that performs a task indefinitely or for a long duration. Pass the context created in step 1 to this goroutine.

  4. Observe the goroutine's behavior in response to the signal. When you send a SIGINT (Ctrl+C) or SIGTERM to the process, the context should be canceled, and the goroutine should stop its execution gracefully.

Solution:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"
)

// worker simulates a task that runs until the context is cancelled
func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: Stopping\n", id)
            return
        default:
            fmt.Printf("Worker %d: Running...\n", id)
            // Simulate work
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    // Create a context that can be cancelled
    ctx, cancel := context.WithCancel(context.Background())

    // Setup signal handling to catch SIGTERM and SIGINT (Ctrl+C)
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
    go worker(ctx, 1)

    <-sigChan // Wait for signal
    fmt.Println("Signal received, canceling context...")
    cancel()

    // Wait for the worker to finish
    <-ctx.Done()
    fmt.Println("Main: Exiting")
}

Exercise 5: Combining Context and WaitGroup

Objective: Understand how to use context for cancellation and WaitGroup for synchronization together in a single application. This is useful for situations where you need to manage the lifecycle of multiple concurrent operations and also ensure that all operations have completed before proceeding.

  1. Create a context with cancellation. This context will be used to signal goroutines when they should stop their work.

  2. Initialize a WaitGroup. The WaitGroup will be used to wait for all goroutines to finish their work before the main function exits.

  3. Launch multiple goroutines, each performing some work. Pass the same context to all goroutines for cancellation and use the WaitGroup to wait for all of them to complete. Each goroutine should:

    • Increment the WaitGroup counter at the start.

    • Listen for the cancellation signal from the context to exit early if needed.

    • Decrement the WaitGroup counter upon completing its work or when exiting due to cancellation.

  4. Cancel the context after a delay. This simulates a scenario where an operation needs to be canceled due to external conditions (like a timeout or user intervention).

  5. Wait for all goroutines to finish using the WaitGroup. This ensures that the main function only exits after all goroutines have cleaned up properly.

Solution:

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

func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
    defer wg.Done() // Decrement the counter when the goroutine completes

    for {
        select {
        case <-ctx.Done():
            // Context was cancelled, exit the goroutine
            fmt.Printf("Worker %d: Stopping due to cancellation\n", id)
            return
        default:
            // Simulate doing some work
            fmt.Printf("Worker %d: Working...\n", id)
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    var wg sync.WaitGroup

    // Start multiple goroutines
    for i := 1; i <= 3; i++ {
        wg.Add(1) // Increment the WaitGroup counter
        go worker(ctx, i, &wg)
    }

    // Cancel the context after a delay to simulate a cancellation scenario
    time.Sleep(3 * time.Second)
    cancel()

    // Wait for all goroutines to finish
    wg.Wait()
    fmt.Println("Main: All workers have stopped")
}

How It Works:

  • The context provides a mechanism to cancel all goroutines at once, simulating a scenario where an operation is no longer needed or a timeout occurs.

  • The WaitGroup ensures that the main function waits for all goroutines to finish their work or acknowledge the cancellation before exiting. This is crucial for avoiding premature termination of the program, ensuring that resources are properly released, and any necessary cleanup is performed.

Exercise 6: Detecting Leaks with Context and Without WaitGroup

Objective: Illustrate how the absence of WaitGroup can lead to goroutines not completing their work as expected, potentially causing resource leaks or tasks to be left in an indeterminate state when the main program exits prematurely.

Scenario:

We'll simulate a scenario where several long-running tasks are initiated, but due to improper synchronization (i.e., not using a WaitGroup), the main program exits before these tasks have a chance to complete. This will be contrasted against proper usage, where tasks are allowed to complete or are gracefully canceled.

Steps without Proper Synchronization:

  1. Create a context with cancellation, to simulate a controlled shutdown mechanism for your goroutines.

  2. Launch several goroutines to perform long-running tasks without using a WaitGroup to synchronize their completion.

  3. Cancel the context after a brief delay, simulating a shutdown command that attempts to terminate all running tasks.

  4. Immediately exit the main program, without waiting for the goroutines to acknowledge the cancellation or complete their work.

Problematic code:

package main

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

func task(ctx context.Context, id int) {
    fmt.Printf("Task %d started\n", id)
    select {
    case <-time.After(2 * time.Second): // Simulates a long-running operation
        fmt.Printf("Task %d completed\n", id)
    case <-ctx.Done():
        fmt.Printf("Task %d canceled\n", id)
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i := 1; i <= 5; i++ {
        go task(ctx, i)
    }

    // Simulate an early shutdown scenario
    time.Sleep(1 * time.Second)
    cancel()

    // Main program exits immediately, potentially before all tasks can respond to the cancel signal
    fmt.Println("Main program exiting")
}

Observe the logs, not all goroutines could even respond to the cancellation signal:

$ go run main.go
Task 2 started
Task 3 started
Task 1 started
Task 5 started
Task 4 started
Main program exiting
Task 5 canceled

$ go run main.go
Task 2 started
Task 3 started
Task 5 started
Task 1 started
Task 4 started
Main program exiting

Correct code:

package main

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

func task(ctx context.Context, id int, wg *sync.WaitGroup) {
    defer wg.Done() // Ensure this is called to signal task completion

    fmt.Printf("Task %d started\n", id)
    select {
    case <-time.After(2 * time.Second): // Simulates a long-running operation
        fmt.Printf("Task %d completed\n", id)
    case <-ctx.Done():
        fmt.Printf("Task %d canceled due to context cancellation\n", id)
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // Signal that a new task is starting
        go task(ctx, i, &wg)
    }

    // Simulate a scenario where the program needs to shut down early
    time.Sleep(1 * time.Second)
    fmt.Println("Main program initiating shutdown...")
    cancel()

    wg.Wait() // Wait for all tasks to either complete or handle the cancellation
    fmt.Println("Main program exited gracefully")
}

All goroutines exited gracefully:

$ go run main.go
Task 1 started
Task 3 started
Task 4 started
Task 2 started
Task 5 started
Main program initiating shutdown...
Task 1 canceled due to context cancellation
Task 5 canceled due to context cancellation
Task 2 canceled due to context cancellation
Task 4 canceled due to context cancellation
Task 3 canceled due to context cancellation
Main program exited gracefully

And that’s all !