Cancelling Work with context.WithCancel
Stop goroutines early with context.WithCancel, why you must always call cancel, and how to make your own functions respect ctx.Done().
context.WithCancel gives you a context plus a cancel function. Calling cancel closes the context's Done channel, which tells every function watching that context to stop. It is the basic building block for stopping work early.
A Goroutine That Stops on Cancel
package main
import (
"context"
"fmt"
"time"
)
func poll(ctx context.Context) {
for i := 1; ; i++ {
select {
case <-ctx.Done():
fmt.Println("poll stopped:", ctx.Err())
return
case <-time.After(20 * time.Millisecond):
fmt.Println("poll", i)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
poll(ctx)
close(done)
}()
time.Sleep(70 * time.Millisecond)
cancel()
<-done
}
Output:
poll 1
poll 2
poll 3
poll stopped: context canceled
The goroutine checks ctx.Done() in its select. When main calls cancel(), that channel closes, the case runs, and the goroutine returns. Without the context, poll would loop forever.
Always Call cancel
Every With... function that returns a cancel function holds resources until cancel is called or the parent ends. The rule is: call it on every path, usually with defer right after creating the context.
ctx, cancel := context.WithCancel(parent)
defer cancel()
Calling cancel more than once is safe, so deferring it even when you also call it earlier causes no problem.
go vet catches a lost cancel:
If you ignore the cancel function with ctx, _ := context.WithCancel(parent), go vet reports the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leak. The leaked context stays registered with its parent until the parent is cancelled, which for Background is never.
Making Your Own Functions Cancellable
A function respects cancellation by checking the context between steps. For loops that do work in chunks, ctx.Err() is the simplest check:
package main
import (
"context"
"fmt"
)
func processAll(ctx context.Context, items []string) (int, error) {
done := 0
for _, it := range items {
if err := ctx.Err(); err != nil {
return done, fmt.Errorf("stopped after %d items: %w", done, err)
}
_ = it // real work goes here
done++
}
return done, nil
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
n, err := processAll(ctx, []string{"a", "b", "c"})
fmt.Println(n, err)
cancel()
n, err = processAll(ctx, []string{"a", "b", "c"})
fmt.Println(n, err)
}
Output:
3 <nil>
0 stopped after 0 items: context canceled
Checking once per item is enough. A context cannot interrupt a function in the middle of a calculation, so long steps should check it between smaller pieces.
Cancellation is a request, not a kill:
Cancelling a context does not stop goroutines by force. It only closes a channel. Code that never looks at ctx keeps running. Pass ctx into every call that accepts one, such as HTTP requests and database queries, so the signal reaches the slow parts.
Stopping Many Goroutines at Once
Since Done is a closed channel, one cancel call wakes every goroutine watching it:
package main
import (
"context"
"fmt"
"sync"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
fmt.Println("worker", i, "exiting")
}()
}
cancel()
wg.Wait()
fmt.Println("all workers stopped")
}
All three workers print their exit line, then all workers stopped.
A cancellable design:
Every goroutine you start takes a ctx, every blocking wait includes <-ctx.Done(), and every WithCancel is followed by defer cancel(). That combination prevents goroutine leaks when callers give up.
Summary
context.WithCancel returns a context and a cancel function that closes its Done channel for every child and every goroutine watching. Always defer cancel(). Make your own functions respect cancellation by selecting on ctx.Done() or checking ctx.Err() between steps, and pass ctx into every library call that accepts it, since cancellation only works where code checks for it.