In Go, the context
package provides a mechanism to propagate cancellation signals, deadlines, and request-scoped values across API boundaries and goroutines. It enables graceful termination of operations by allowing parent operations to cancel child operations. This prevents goroutine leaks and ensures resources are cleaned up efficiently when work is no longer needed.
To shut down a chain of goroutines using context
in Go, you create a cancelable context that propagates cancellation signals through the chain. Each goroutine monitors ctx.Done()
to exit cleanly when cancellation is requested.
Each caller must be able to cancel its callee, use context derivation and hierarchical cancellation. Each function creates a cancellable context for its callee, enabling independent cancellation at any level.
func A(ctx context.Context) {
// Create child context for B (cancels when A exits or manually cancelled)
ctxA, cancelA := context.WithCancel(ctx)
defer cancelA() // Cancel B when A exits
...
func B(ctx context.Context) {
// Create child context for C
ctxB, cancelB := context.WithCancel(ctx)
defer cancelB() // Cancel C when B exits
Hierarchy Creation:
-
main
→A
withctx
-
A
→B
withctxA
(child ofctx
) -
B
→C
withctxB
(child ofctxA
)
The code below demonstrates hierarchical cancellation of goroutines using Go's context package.
package main
import (
"context"
"fmt"
"time"
)
func A(ctx context.Context) {
// Create child context for B (cancels when A exits or manually canceled)
ctxA, cancelA := context.WithCancel(ctx)
defer cancelA() // Cancel B when A exits
go B(ctxA) // Start B (no need to track separately)
// A's work
for i := 0; ; i++ {
select {
case <-ctx.Done(): // Main cancelled us
fmt.Println("A: shutdown signal received")
return
case <-time.After(500 * time.Millisecond):
fmt.Println("A: working", i)
// Example: A decides to cancel B after 3 iterations
if i == 2 {
fmt.Println("A: manually cancelling B")
cancelA() // Cancel only B (not A itself)
}
}
}
}
func B(ctx context.Context) {
// Create child context for C
ctxB, cancelB := context.WithCancel(ctx)
defer cancelB() // Cancel C when B exits
go C(ctxB) // Start C
// B's work
for i := 0; ; i++ {
select {
case <-ctx.Done(): // A cancelled us
fmt.Println("B: shutdown signal received")
return
case <-time.After(500 * time.Millisecond):
fmt.Println("B: working", i)
}
}
}
func C(ctx context.Context) {
// C's work
for i := 0; ; i++ {
select {
case <-ctx.Done(): // B cancelled us
fmt.Println("C: shutdown signal received")
return
case <-time.After(500 * time.Millisecond):
fmt.Println("C: working", i)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go A(ctx) // Start chain
// Let run for 5 seconds
time.Sleep(5 * time.Second)
fmt.Println("\nmain: cancelling entire chain")
cancel() // Cancel everything
// Allow time for shutdown messages
time.Sleep(500 * time.Millisecond)
}
A
explicitly cancels B
after 3 iterations using its cancel function (cancelA
), which automatically propagates to C
since C
's context is derived from B
's. Meanwhile, the main
function cancels the entire chain after 5 seconds by calling its root cancel function, which propagates downward through all derived contexts.
Each function monitors its own context's Done()
channel, allowing immediate termination when cancellation occurs. The defer cancel()
statements ensure child contexts are always properly cancelled when parents exit, preventing goroutine leaks. The output shows work progress interleaved with shutdown messages, demonstrating both targeted cancellation (A
→B
) and full-chain cancellation (main
→A
→B
→C)
.
The article is well articulated. I love this