Skip to Content
Course content

33: The Context Package for Cancellation

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact scenario play out in production more times than I care to admit. A developer writes a feature that kicks off a few background tasks to fetch data from different APIs. It works great in testing. But in production, the memory usage slowly climbs over a week until the service crashes. Why? Because they were leaking goroutines.

Take a look at this snippet. It looks harmless—it's just a function that simulates a slow database query running in the background.

package main

import (
	"fmt"
	"time"
)

func slowQuery() {
	fmt.Println("Query started...")
	time.Sleep(10 * time.Second) // Simulate a heavy load
	fmt.Println("Query finished!")
}

func main() {
	go slowQuery()
	fmt.Println("Main function is done, moving on!")
	time.Sleep(1 * time.Second) 
}

The Ghost in the Machine: Goroutine Leaks

If you run this, you'll see "Query started..." and "Main function is done...", and then the program exits. But wait—in a real web server, main doesn't exit; it keeps running to handle other requests. If a user hits an endpoint that triggers slowQuery and then closes their browser tab, that goroutine is still sitting there, sleeping for 10 seconds, consuming memory and resources. Go has no way to "force kill" a goroutine from the outside. You can't just call goroutine.Stop().

This is where the context package comes in. The context.Context type is essentially a signal wire that you pass through your call stack. When the wire is "cut," every function holding a reference to that context knows it's time to pack up and go home.

Teaching Your Functions to Listen

To fix this, we need to change slowQuery so it doesn't just blindly sleep. It needs to listen for a cancellation signal. We do this by passing a context.Context and using a select block to wait for either the work to finish or the context to be cancelled.

package main

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

func slowQuery(ctx context.Context) {
	fmt.Println("Query started...")
	
	select {
	case <-time.After(10 * time.Second):
		fmt.Println("Query finished!")
	case <-ctx.Done():
		fmt.Println("Query cancelled! Cleaning up resources...")
		return
	}
}

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

	go slowQuery(ctx)

	time.Sleep(1 * time.Second)
	fmt.Println("User disconnected. Cancelling request...")
	cancel() // This sends the signal to ctx.Done()

	// Give it a moment to print the cleanup message
	time.Sleep(100 * time.Millisecond)
}

Here is what changed: I used context.WithCancel, which returns the context and a cancel function. When I call cancel(), it closes the channel returned by ctx.Done(). Inside slowQuery, the select statement is essentially a race. Whichever happens first—the 10-second timer or the cancellation signal—wins. Because we called cancel() after one second, the ctx.Done() case triggers, and the goroutine exits cleanly.

Passing the Baton Down the Chain

In a real-world app, you won't just have one function. You'll have a handler that calls a service, which calls a repository, which calls a database driver. The rule of thumb in Go is: always pass ctx as the first argument to any function that performs I/O or long-running work.

If the top-level request is cancelled, that signal propagates all the way down the chain. If you're using the database/sql package, you'll notice methods like QueryContext. Use those. They are designed to watch the context you pass in, so if the user cancels the request, the Go driver actually tells the database to kill the query on the server side, too. It's a massive win for performance.




📋 Practical Task

Exercise: Implementing a Cancellable Search Aggregator

You are building a search aggregator that fetches results from three different mock providers (e.g., "Google", "Bing", "DuckDuckGo"). Some providers are slower than others.

Your Task:

  • Create a function fetchResults(ctx context.Context, provider string, delay time.Duration) string. This function should simulate work using time.Sleep or time.After. If the context is cancelled before the delay finishes, it should return an empty string and print "Cancelled [provider]".
  • In your main function, create a context with a timeout of 2 seconds using context.WithTimeout.
  • Launch three goroutines calling fetchResults with different delays:
    • Provider A: 1 second (Should succeed)
    • Provider B: 2.5 seconds (Should be cancelled)
    • Provider C: 5 seconds (Should be cancelled)
  • Use a sync.WaitGroup to ensure your program waits for all goroutines to finish (either by completing or by being cancelled) before exiting.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.