Cancellation with Context Package

Part of Golang Mastery course

~15 min read
Interactive
Hands-on
Beginner-friendly

Cancellation with Context Package

The context package in Go provides a way to carry deadlines, cancellation signals, and other request-scoped values across API boundaries and between goroutines. It's particularly important for controlling the lifetime of concurrent operations.

What is Context?#

A context is an immutable structure that provides:

  • Cancellation signals to goroutines
  • Deadlines for completing operations
  • Key-value pairs for request-scoped data

The context package was introduced to solve problems with managing concurrency, especially in network servers where operations might need to be cancelled due to client disconnections, timeouts, or user cancellations.

Why Use Context?#

Without context, it can be difficult to cleanly cancel goroutines or pass request-specific information through your program. The context package provides a standard way to handle:

  • Cancellation: Stopping goroutines that are no longer needed
  • Deadlines: Ensuring operations complete within a time limit
  • Request values: Passing request-scoped values between functions and goroutines

The Context Interface#

The Context interface in Go is defined as:

example.go
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
 

Let's understand each method:

  1. Deadline(): Returns the time when the context will be cancelled (if a deadline is set)
  2. Done(): Returns a channel that's closed when the context is cancelled
  3. Err(): Returns the error why the context was cancelled (if it was)
  4. Value(key): Returns the value associated with a key in the context

Creating Contexts#

The context package provides several functions to create contexts:

context.Background()#

Creates an empty, non-cancellable context. This is typically used as the root of a context tree:

example.go
ctx := context.Background()
 

context.TODO()#

Similar to Background(), but indicates that we're not sure which context to use yet:

example.go
ctx := context.TODO() string">"comment">// For places where you're not sure which context to use
 

context.WithCancel(parent)#

Creates a new context that can be cancelled:

example.go
ctx, cancel := context.WithCancel(context.Background())
string">"comment">// Use ctx somewhere
string">"comment">// ...
string">"comment">// When done or when you need to cancel
cancel()
 

context.WithTimeout(parent, timeout)#

Creates a context that will be cancelled after a specified duration:

example.go
string">"comment">// Create a context that will cancel after 100ms
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() string">"comment">// Always call cancel to release resources
 

context.WithDeadline(parent, time)#

Creates a context that will be cancelled at a specific time:

example.go
string">"comment">// Create a context that will cancel at a specific time
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel() string">"comment">// Always call cancel to release resources
 

context.WithValue(parent, key, value)#

Creates a context with a key-value pair:

example.go
ctx := context.WithValue(context.Background(), string">"requestID", string">"123")
string">"comment">// Later, retrieve the value
requestID := ctx.Value(string">"requestID").(string)
 

Basic Cancellation Example#

Let's create a simple example showing how to cancel a goroutine:

example.go
package main
 
import (
string">"context"
string">"fmt"
string">"time"
)
 
func doWork(ctx context.Context) {
for {
select {
case <-ctx.Done():
string">"comment">// Context was cancelled
fmt.Println(string">"Work cancelled:", ctx.Err())
return
default:
string">"comment">// Do some work
fmt.Println(string">"Working...")
time.Sleep(500 * time.Millisecond)
}
}
}
 
func main() {
string">"comment">// Create a context with cancellation
ctx, cancel := context.WithCancel(context.Background())
string">"comment">// Start a goroutine with the context
go doWork(ctx)
string">"comment">// Let it work for 2 seconds
time.Sleep(2 * time.Second)
string">"comment">// Cancel the context
cancel()
string">"comment">// Give the goroutine time to process the cancellation
time.Sleep(500 * time.Millisecond)
fmt.Println(string">"Main: exiting")
}
 

When you run this program, you'll see:

Working... Working... Working... Working... Work cancelled: context canceled Main: exiting

Cancellation with Timeout#

Here's an example using a timeout:

example.go
package main
 
import (
string">"context"
string">"fmt"
string">"time"
)
 
func slowOperation(ctx context.Context) {
select {
case <-time.After(5 * time.Second):
string">"comment">// This would complete if given enough time
fmt.Println(string">"Slow operation completed")
case <-ctx.Done():
string">"comment">// But we might be cancelled
fmt.Println(string">"Slow operation cancelled:", ctx.Err())
}
}
 
func main() {
string">"comment">// Create a context with a 2 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() string">"comment">// Always call cancel to release resources
fmt.Println(string">"Starting slow operation...")
slowOperation(ctx)
fmt.Println(string">"Main: exiting")
}
 

When you run this program, you'll see:

Starting slow operation... Slow operation cancelled: context deadline exceeded Main: exiting

Context Propagation#

One of the most important aspects of contexts is that they're designed to be propagated through your program. This allows cancellation signals to flow across API boundaries and between goroutines:

example.go
package main
 
import (
string">"context"
string">"fmt"
string">"time"
)
 
string">"comment">// worker is the leaf function that actually does work
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf(string">"Worker %d: stopping due to %v\n", id, ctx.Err())
return
default:
fmt.Printf(string">"Worker %d: working\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
 
string">"comment">// manager starts and manages workers
func manager(ctx context.Context) {
string">"comment">// Create a child context
ctx, cancel := context.WithCancel(ctx)
defer cancel() string">"comment">// Ensure all workers are cancelled when we return
string">"comment">// Start some workers
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
string">"comment">// Let workers run for a bit
select {
case <-ctx.Done():
string">"comment">// If parent context is cancelled, we'll exit here
fmt.Println(string">"Manager: stopping due to", ctx.Err())
case <-time.After(3 * time.Second):
string">"comment">// Otherwise cancel after 3 seconds
fmt.Println(string">"Manager: timeout reached, cancelling workers")
}
}
 
func main() {
string">"comment">// Create a root context with cancellation
ctx, cancel := context.WithCancel(context.Background())
string">"comment">// Start a goroutine for the manager
go manager(ctx)
string">"comment">// Let it run for 2 seconds, then cancel everything
time.Sleep(2 * time.Second)
cancel()
string">"comment">// Give things time to clean up
time.Sleep(1 * time.Second)
fmt.Println(string">"Main: exiting")
}
 

This example shows how cancellation flows from parent to child contexts. Cancelling the parent causes all children to be cancelled as well.

Using Context Values#

Context values are useful for carrying request-scoped data:

example.go
package main
 
import (
string">"context"
string">"fmt"
)
 
string">"comment">// Define key types to avoid collision
type key string
 
const (
requestIDKey key = string">"requestID"
userIDKey key = string">"userID"
)
 
string">"comment">// Process a request with user ID and request ID
func processRequest(ctx context.Context) {
string">"comment">// Extract values from context
requestID, ok := ctx.Value(requestIDKey).(string)
if !ok {
requestID = string">"unknown"
}
userID, ok := ctx.Value(userIDKey).(string)
if !ok {
userID = string">"anonymous"
}
fmt.Printf(string">"Processing request %s for user %s\n", requestID, userID)
string">"comment">// Pass context to another function
validateUser(ctx)
}
 
func validateUser(ctx context.Context) {
string">"comment">// Extract user ID from context
userID, ok := ctx.Value(userIDKey).(string)
if !ok {
userID = string">"anonymous"
}
fmt.Printf(string">"Validating user: %s\n", userID)
}
 
func main() {
string">"comment">// Create context with request and user IDs
ctx := context.Background()
ctx = context.WithValue(ctx, requestIDKey, string">"REQ-123")
ctx = context.WithValue(ctx, userIDKey, string">"USER-456")
string">"comment">// Process the request
processRequest(ctx)
}
 

When you run this program, you'll see:

Processing request REQ-123 for user USER-456 Validating user: USER-456

Best Practices for Using Context#

Here are some best practices for using the context package:

1. Always Pass Context as the First Parameter#

By convention, context should be the first parameter in functions that use it:

example.go
string">"comment">// Good
func ProcessRequest(ctx context.Context, req *Request) (*Response, error)
 
string">"comment">// Not as good
func ProcessRequest(req *Request, ctx context.Context) (*Response, error)
 

2. Don't Store Context in Structs#

Context should be passed explicitly, not stored in structs:

example.go
string">"comment">// Good
type Service struct {
string">"comment">// ...
}
 
func (s *Service) Process(ctx context.Context) {
string">"comment">// ...
}
 
string">"comment">// Bad
type Service struct {
ctx context.Context
string">"comment">// ...
}
 

3. Use Context Values Sparingly#

Context values should only be used for request-scoped data that crosses API boundaries, not for passing optional parameters:

example.go
string">"comment">// Good: Pass request ID in context
ctx = context.WithValue(ctx, string">"requestID", requestID)
 
string">"comment">// Bad: Pass configuration in context
ctx = context.WithValue(ctx, string">"timeout", timeout)
 

4. Always Call Cancel Functions#

When using WithCancel, WithTimeout, or WithDeadline, always call the returned cancel function, typically with defer:

example.go
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() string">"comment">// Always call cancel, even if the operation completes
 

5. Don't Pass nil Context#

If you're not sure which context to use, use context.TODO() instead of nil:

example.go
string">"comment">// Good
func doSomething() {
ctx := context.TODO()
string">"comment">// ...
}
 
string">"comment">// Bad
func doSomething() {
var ctx context.Context string">"comment">// nil context
string">"comment">// ...
}
 

6. Only Cancel a Context Once#

Calling cancel() multiple times is safe, but it's best practice to ensure it's called exactly once:

example.go
string">"comment">// Good
func processRequest(ctx context.Context) {
childCtx, cancel := context.WithCancel(ctx)
defer cancel() string">"comment">// Ensures we only cancel once
string">"comment">// ...
}
 

Real-World Example: HTTP Server#

Here's a real-world example of using context in an HTTP server:

example.go
package main
 
import (
string">"context"
string">"fmt"
string">"log"
string">"net/http"
string">"time"
)
 
string">"comment">// ProcessRequest simulates a long-running process
func ProcessRequest(ctx context.Context) (string, error) {
string">"comment">// Create a channel to signal completion
done := make(chan struct{})
string">"comment">// Result and error
var result string
var err error
string">"comment">// Start processing in a goroutine
go func() {
string">"comment">// Simulate work
time.Sleep(500 * time.Millisecond)
string">"comment">// Check if context was cancelled before completing
select {
case <-ctx.Done():
string">"comment">// Do nothing, we'll handle this outside
default:
result = string">"Processing complete!"
}
string">"comment">// Signal completion
close(done)
}()
string">"comment">// Wait for completion or cancellation
select {
case <-done:
string">"comment">// Processing completed
return result, err
case <-ctx.Done():
string">"comment">// Context was cancelled
return string">"", ctx.Err()
}
}
 
func handleRequest(w http.ResponseWriter, r *http.Request) {
string">"comment">// Create a timeout context for this request
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
string">"comment">// Process the request
result, err := ProcessRequest(ctx)
if err != nil {
http.Error(w, fmt.Sprintf(string">"Processing failed: %v", err), http.StatusInternalServerError)
return
}
string">"comment">// Return the result
fmt.Fprintln(w, result)
}
 
func main() {
string">"comment">// Register handler
http.HandleFunc(string">"/process", handleRequest)
string">"comment">// Start server
log.Println(string">"Starting server on :8080")
log.Fatal(http.ListenAndServe(string">":8080", nil))
}
 

In this example:

  1. Each HTTP request has a context that's automatically cancelled if the client disconnects
  2. We add a timeout to the context, ensuring the operation takes no more than 1 second
  3. The timeout is propagated to the ProcessRequest function, which honors the cancellation

Cancelling Multiple Goroutines#

Context is great for cancelling multiple goroutines at once:

example.go
package main
 
import (
string">"context"
string">"fmt"
string">"sync"
string">"time"
)
 
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf(string">"Worker %d: starting\n", id)
for {
select {
case <-ctx.Done():
fmt.Printf(string">"Worker %d: stopping due to %v\n", id, ctx.Err())
return
case <-time.After(time.Second):
fmt.Printf(string">"Worker %d: doing work\n", id)
}
}
}
 
func main() {
string">"comment">// Create a context with cancellation
ctx, cancel := context.WithCancel(context.Background())
string">"comment">// Start a group of workers
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(ctx, i, &wg)
}
string">"comment">// Let workers run for 3 seconds
time.Sleep(3 * time.Second)
string">"comment">// Cancel all workers
fmt.Println(string">"Main: cancelling workers")
cancel()
string">"comment">// Wait for all workers to finish
wg.Wait()
fmt.Println(string">"Main: all workers done")
}
 

This example shows how a single cancel() call can stop multiple goroutines at once.

Context in API Design#

When designing APIs, you should consider how context flows through your system:

example.go
package main
 
import (
string">"context"
string">"fmt"
string">"time"
)
 
string">"comment">// Database represents a database connection
type Database struct {
string">"comment">// ...
}
 
string">"comment">// QueryContext executes a query with a context
func (db *Database) QueryContext(ctx context.Context, query string) ([]string, error) {
string">"comment">// Check if context is already cancelled
if ctx.Err() != nil {
return nil, ctx.Err()
}
string">"comment">// Create a result channel
resultCh := make(chan []string)
errCh := make(chan error)
string">"comment">// Execute query in a goroutine
go func() {
string">"comment">// Simulate a database query
time.Sleep(200 * time.Millisecond)
string">"comment">// Return results
resultCh <- []string{string">"result1", string">"result2"}
}()
string">"comment">// Wait for result or cancellation
select {
case result := <-resultCh:
return result, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
string">"comment">// Ideally we would also cancel the database operation here
return nil, ctx.Err()
}
}
 
string">"comment">// Service uses the database
type Service struct {
DB *Database
}
 
string">"comment">// ProcessRequest handles a request
func (s *Service) ProcessRequest(ctx context.Context, request string) ([]string, error) {
string">"comment">// We might add a timeout for this specific operation
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
string">"comment">// Execute query
return s.DB.QueryContext(ctx, string">"SELECT * FROM "+request)
}
 
func main() {
string">"comment">// Setup
db := &Database{}
service := &Service{DB: db}
string">"comment">// Create a context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
string">"comment">// Process a request
result, err := service.ProcessRequest(ctx, string">"users")
if err != nil {
fmt.Printf(string">"Error: %v\n", err)
return
}
fmt.Printf(string">"Results: %v\n", result)
}
 

This example shows:

  1. How context flows from client code to database operations
  2. How timeouts can be added at different levels
  3. How cancellation propagates through the layers

Summary#

The context package is a powerful tool for managing goroutine lifecycles in Go. It provides a standard way to:

  • Cancel operations that are no longer needed
  • Set deadlines and timeouts for operations
  • Propagate cancellation through call chains
  • Carry request-scoped values between functions and goroutines

By following best practices with the context package, you can build more robust and responsive concurrent programs that properly handle cancellation and timeouts.

Remember:

  1. Always pass context as the first parameter
  2. Don't store context in structs
  3. Use context values sparingly
  4. Always call cancel functions with defer
  5. Don't pass nil contexts
  6. Cancel a context only once
  7. Check for cancellation regularly in long-running operations

With these guidelines, you'll be able to write Go applications that gracefully handle cancellation signals and deadlines in complex concurrent scenarios.

Your Progress

19 of 19 modules
100%
Started100% Complete
Previous
SpaceComplete
Next