19.1 Why context Exists: Propagating Cancellation Across Goroutines
Look, let’s be honest. You’ve been there. You fire off a handful of goroutines to fetch some data from a database, ping a microservice, and check a cache. Then you sit back and wait. And wait. And wait. One of those little buggers is stuck, maybe waiting on a network call that will never return, and now your entire request is hung. Your user is frantically hitting refresh, and your service’s memory footprint is slowly ballooning into a Michelin man because every abandoned request leaves its goroutines lying around like dirty socks.
This is the problem the context package was born to solve. Its primary, glorious purpose is to provide a sane, standard way to propagate cancellation signals across API boundaries and down through your stack of goroutines. Think of it as a universal “STOP” button you can press from anywhere, and that signal ripples through your entire operation, telling every involved goroutine to drop what it’s doing and clean up.
Before context, we were all out here jury-rigging our own solutions with chan struct{} and sync.Mutex and a prayer. It was messy, inconsistent, and a surefire way to introduce subtle concurrency bugs. The context package is the Go community’s way of saying, “We’ve suffered enough. Here’s the official tool for the job.”
The Mechanics of Cancellation
At its heart, a cancellable context is a fancy wrapper around two simple things:
- A
Done()channel that gets closed when the context is cancelled. - An
Err()method that tells you why it was cancelled once it’s done.
The magic is in that closed channel. Remember, reading from a closed channel returns the zero value immediately. So, instead of constantly polling a variable or trying to manage a complex state machine, a goroutine can just sit in a select statement, waiting either for its work to finish or for the Done() channel to close. It’s an incredibly elegant and efficient pattern.
Here’s the classic way you use it. You start a goroutine and give it a context. That goroutine’s job is to listen for cancellation.
func longRunningOperation(ctx context.Context, resultChan chan<- int) {
select {
case <-time.After(10 * time.Second): // Simulating some work
resultChan <- 42
case <-ctx.Done():
fmt.Printf("Operation cancelled: %v\n", ctx.Err())
// Clean up any resources here! Close files, network connections, etc.
return
}
}
func main() {
resultChan := make(chan int)
ctx, cancel := context.WithCancel(context.Background())
go longRunningOperation(ctx, resultChan)
// Let's say we change our mind after 2 seconds
time.Sleep(2 * time.Second)
cancel() // Press the STOP button!
result, ok := <-resultChan
if !ok {
fmt.Println("The channel was closed without a result.")
} else {
fmt.Println("Result:", result)
}
}
When you run this, you’ll see Operation cancelled: context canceled after two seconds. The longRunningOperation didn’t get to finish its 10-second nap; it got the signal and bailed out early. This is the foundation of everything we’re about to do.
The Critical Chain of Contexts
You don’t just pass the same context.Background() everywhere. That would be useless. The real power comes from deriving new contexts from a parent. When you derive a context—using WithCancel, WithTimeout, or WithDeadline—you create a parent-child link. Cancelling the parent cancels all of its children. This is how the signal propagates.
Imagine a request: main() -> handleRequest() -> callDatabase() -> executeQuery(). You create a context with a timeout in handleRequest and pass it down. If the timeout is reached, the context is cancelled in handleRequest. That cancellation automatically propagates down to callDatabase and executeQuery. The database driver, if it’s written well, will see ctx.Done() and cancel the underlying TCP call. No more orphaned queries!
func callDatabase(ctx context.Context, query string) (string, error) {
// Imagine this is a real DB call that honors context.
select {
case <-time.After(6 * time.Second): // A very slow query
return "database result", nil
case <-ctx.Done():
return "", fmt.Errorf("database call aborted: %w", ctx.Err())
}
}
func handleRequest() error {
// This request must complete within 5 seconds total.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // Seriously, ALWAYS call cancel() to release resources. Even on success.
result, err := callDatabase(ctx, "SELECT * FROM users")
if err != nil {
return err // This will be a "context deadline exceeded" error
}
fmt.Println(result)
return nil
}
func main() {
if err := handleRequest(); err != nil {
fmt.Println("Request failed:", err)
}
}
This will fail with Request failed: database call aborted: context deadline exceeded. The callDatabase function didn’t need to know the timeout was 5 seconds; it just knew it had to obey the context it was given. The parent (handleRequest) enforced the rule, and the child (callDatabase) complied. This separation of concerns is beautiful.
The Non-Negotiable Best Practice: defer cancel()
I will die on this hill. Whenever you call a function that returns a cancellation function (cancel), defer it immediately. No ifs, ands, or buts.
// DO THIS. ALWAYS.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel() // This runs when the function returns, no matter what.
// DO NOT, under any circumstances, do this:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
// ... 100 lines of code later ...
if someCondition {
cancel() // Did you remember every exit path? Probably not.
}
Why? Because contexts carry resources—the goroutines that are monitoring the timer and the channel for cancellation. If you don’t call cancel, those resources are only freed when the timeout naturally occurs or the parent is cancelled. If you create a context with a 10-minute timeout and return early without calling cancel, you’ve leaked those resources for the next ten minutes. In a high-throughput server, that’s a recipe for disaster. defer cancel() is your garbage collection for contexts. Use it.