19.3 WithCancel: Manual Cancellation
Alright, let’s talk about pulling the plug. Sometimes, you start a task and, for a million different reasons, you need to tell it to stop. Right now. Maybe a user clicked a cancel button, a service you’re calling is taking an eon, or a parent process is shutting down. This is what context.WithCancel is for: it’s your manual override switch.
Think of it as creating a cancellation walkie-talkie. You get one channel (context.Context) for listening, and a separate function (context.CancelFunc) for talking—specifically, for shouting “ABORT!” into that channel. The real beauty is that you can hand the listening channel to as many goroutines as you want, and a single shout from the cancel function will reach them all. It’s a one-to-many broadcast system for termination.
Here’s the basic incantation. It’s simple, but the devil, as always, is in the details.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// Create a new context and its associated cancel function.
ctx, cancel := context.WithCancel(context.Background())
// Launch a goroutine that does some work.
go func() {
for {
select {
case <-ctx.Done(): // This channel is closed when cancel() is called.
fmt.Println("Goroutine: Heard the cancellation signal. Bailing out!")
return
default:
// Simulate some work.
fmt.Println("Goroutine: Still working...")
time.Sleep(500 * time.Millisecond)
}
}
}()
// Let the goroutine work for a bit.
time.Sleep(2 * time.Second)
// Okay, we're done. Cancel it.
fmt.Println("Main: Okay, that's enough. Cancelling!")
cancel() // This is the shout. It closes the Done() channel.
// Give the goroutine a moment to respond to the signal.
time.Sleep(1 * time.Second)
fmt.Println("Main: Exiting.")
}
Run that. You’ll see the goroutine chugging along until cancel() is called, at which point it immediately gets the signal and cleans itself up. Elegant, right?
The CancelFunc is a One-Shot Weapon
Here’s the first thing everyone trips over: you can only call cancel() once. No, seriously, try it. The Go designers, in their infinite wisdom (and it is wisdom here), made it so calling cancel() subsequent times does absolutely nothing. It doesn’t panic, it just… no-ops. This is actually a good thing. It prevents pesky race conditions where multiple parts of your code might try to cancel the same context, and you’d get a panic on the second call. It’s idempotent, which is a fancy word for “safe to call multiple times.” Consider it a protective measure, not a limitation.
You Absolutely Must Call Cancel
This is non-optional. The context package docs will tell you this, but I’m going to yell it because it’s the most common source of leaks: DEFER YOUR CANCEL CALLS.
When you call context.WithCancel, you are creating a new context that is a child of the one you passed in. The runtime sets up a reference from the parent to this new child. If you never call cancel, that reference lingers until the parent context is also cancelled. If the parent is context.Background() (which is never cancelled), you’ve just leaked a tiny bit of memory and, more importantly, any resources your goroutine was using.
It’s so easy to do right. Just use defer.
func responsibleFunction() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // This function will call cancel no matter how we return.
// Use ctx. Now we're safe from leaks.
doSomething(ctx)
}
The defer is your insurance policy. It guarantees that when this function returns, the cancellation signal will be sent, cleaning up any goroutines you passed this context to. Not doing this is like leaving the water running because you can’t be bothered to turn the knob.
Cancellation is a Signal, Not a Command
Here’s the philosophical bit: calling cancel() doesn’t force the goroutines to stop. It politely informs them that they should stop. It does this by closing the channel returned by ctx.Done(). A goroutine listening on that channel will get notified (the case <-ctx.Done(): will fire), but it’s still entirely responsible for actually stopping its work and returning.
This means your goroutines need to be well-behaved citizens. They need to periodically check ctx.Done(), usually in a select statement alongside their actual work. A goroutine that gets a context but then enters a long, tight loop with no cancellation checks is like a person with noise-cancelling headphones on—they’ll never hear you yell “fire!” Design your goroutines to fail fast and listen intently.
The Ripple Effect: Cancelling Children
The real power of WithCancel (and contexts in general) comes from building trees. When you cancel a context, you automatically cancel every single context derived from it. Every. Single. One.
func main() {
grandparentCtx, grandparentCancel := context.WithCancel(context.Background())
defer grandparentCancel()
childCtx, _ := context.WithCancel(grandparentCtx) // Note: we'd defer this cancel in real code
grandchildCtx, _ := context.WithCancel(childCtx) // And this one too
go func() {
<-grandchildCtx.Done()
fmt.Println("Grandchild context was cancelled!")
}()
// Cancelling the top-level parent...
grandparentCancel()
time.Sleep(time.Second)
// The goroutine listening on grandchildCtx will print its message.
// The cancellation cascaded down the entire tree.
}
This hierarchical cancellation is the backbone of managing complex processes. You cancel one high-level operation (e.g., an HTTP request), and it gracefully shuts down all the database queries, cache lookups, and API calls that were spawned as part of it. It’s incredibly effective for preventing those “I cancelled the request but my goroutines are still humming away in the background” leaks. Use it. Love it.