Skip to main content

Command Palette

Search for a command to run...

Golang Concurrency #9 - Context and Cancellations

Updated
3 min read
Golang Concurrency #9 - Context and Cancellations
T

Just a guy who loves to write code and watch anime.

Introduction

Context solves the problem: "How do I gracefully stop operations or set timeouts across multiple goroutines?"

Key Insight

Context provides cooperative cancellation -> you control when and how to check for cancellation, not abrupt force-killing.

Creating Contexts

1. WithTimeout -> Automatic cancellation after duration

// Cancel automatically after 3 seconds
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()  // Always call cancel to free resources

2. WithCancel -> Manual cancellation

// Cancel manually when you want
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Later...
cancel()  // Trigger cancellation

3. Background -> Root context

ctx := context.Background()  // Never cancelled, starting point

How Cancellation Works

The cooperative pattern

func worker(ctx context.Context, name string) {
    for {  // Infinite loop -> YOU control when to exit
        select {
        case <-ctx.Done():  // "Did someone cancel me?"
            fmt.Printf("%s: Got cancellation signal\n", name)
            cleanup()  // YOU decide what cleanup to do
            fmt.Printf("%s: Cleaned up, exiting gracefully\n", name)
            return  // YOU decide when to exit
        default:  // "No cancellation signal, keep working"
            fmt.Printf("%s: working...\n", name)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

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

    // Pass same context to multiple workers
    go worker(ctx, "Worker 1")
    go worker(ctx, "Worker 2")
    go worker(ctx, "Worker 3")

    time.Sleep(2 * time.Second)
    cancel()  // Signal ALL workers to cancel
    time.Sleep(1 * time.Second)  // Give them time to clean up
}

What happens step by step

  1. Workers start: Enter infinite for loop

  2. Select checks: "Is ctx.Done() ready to receive?"

  3. If NOT cancelled: Goes to default, does work, loops again

  4. When cancel() called: ctx.Done() channel gets closed

  5. Next time workers hit select: ctx.Done() case executes

  6. Workers clean up: Do whatever cleanup they want

  7. Workers exit: When THEY decide to return

Different checking patterns

Pattern 1 -> Check every iteration

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return  // Check every loop iteration
        default:
        }
        doWork()
    }
}

Pattern 2 -> Check periodically

func worker(ctx context.Context) {
    for i := 0; i < 1000000; i++ {
        processItem(i)

        // Only check cancellation every 1000 items
        if i%1000 == 0 {
            select {
            case <-ctx.Done():
                fmt.Printf("Cancelled at item %d\n", i)
                return
            default:
            }
        }
    }
}

Pattern 3 -> Racing work vs cancellation

func fetchData(ctx context.Context) (string, error) {
    result := make(chan string)

    go func() {
        time.Sleep(5 * time.Second)  // Simulate slow work
        result <- "data"
    }()

    select {
    case data := <-result:
        return data, nil  // Work finished first
    case <-ctx.Done():
        return "", ctx.Err()  // Cancellation won first
    }
}

Timeout Example

func main() {
    // Context automatically cancels after 2 seconds
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    result := make(chan string)
    go slowWork(ctx, result)

    select {
    case data := <-result:
        fmt.Println("Got result:", data)
    case <-ctx.Done():
        fmt.Println("Timeout! Work took too long")
        fmt.Println("Error:", ctx.Err())  // context.DeadlineExceeded
    }
}

func slowWork(ctx context.Context, result chan string) {
    select {
    case <-time.After(3 * time.Second):  // Work takes 3 seconds
        result <- "finished work"
    case <-ctx.Done():  // But context times out after 2 seconds
        fmt.Println("Work cancelled due to timeout")
        return
    }
}

Context Propagation

func handleRequest(ctx context.Context) {
    // Pass context down the call chain
    data := fetchFromAPI(ctx)
    processData(ctx, data)
    saveToDatabase(ctx, data)
}

func fetchFromAPI(ctx context.Context) string {
    // This function also respects the same cancellation
    select {
    case <-time.After(1 * time.Second):
        return "api data"
    case <-ctx.Done():
        return ""
    }
}

Key Points

1. Cooperative Nature

Context doesn't force-kill anything. Your goroutines choose when to check and how to respond.

2. Graceful Shutdown

You can clean up, save state, close files, etc. before exiting.

3. Propagation

One context can control an entire tree of operations.

4. Channel Syntax

<-ctx.Done() receives from the Done() channel, which becomes ready when context is cancelled.

5. Always call cancel()

Even with timeout contexts, call cancel() to free resources.