
Advanced Go Concurrency Patterns: Context and Error Handling
Understanding Context in Depth
Context in Go serves as a mechanism for managing request-scoped values, cancellation signals, and deadlines across API boundaries. Unlike simple channel-based cancellation, context provides a structured approach to propagate cancellation signals through call stacks.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Start multiple goroutines that respect the context
results := make(chan string, 3)
go worker(ctx, "worker-1", 1*time.Second, results)
go worker(ctx, "worker-2", 3*time.Second, results)
go worker(ctx, "worker-3", 1*time.Second, results)
// Collect results or handle cancellation
for i := 0; i < 3; i++ {
select {
case result := <-results:
fmt.Println("Result:", result)
case <-ctx.Done():
fmt.Println("Context cancelled:", ctx.Err())
return
}
}
}
func worker(ctx context.Context, name string, duration time.Duration, results chan<- string) {
select {
case <-time.After(duration):
results <- fmt.Sprintf("%s completed", name)
case <-ctx.Done():
results <- fmt.Sprintf("%s cancelled: %v", name, ctx.Err())
}
}The key insight here is that context cancellation propagates automatically through the call stack. When cancel() is called, all goroutines that respect the context will receive the cancellation signal, preventing resource leaks and ensuring graceful shutdown.
Advanced Error Handling Patterns
Traditional error handling in Go often involves checking errors after each operation. However, in concurrent environments, we need more sophisticated approaches to aggregate and propagate errors effectively.
package main
import (
"context"
"fmt"
"sync"
"time"
)
// ErrorGroup provides a way to collect errors from concurrent operations
type ErrorGroup struct {
mu sync.Mutex
errs []error
wg sync.WaitGroup
}
func (g *ErrorGroup) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.mu.Lock()
g.errs = append(g.errs, err)
g.mu.Unlock()
}
}()
}
func (g *ErrorGroup) Wait() []error {
g.wg.Wait()
return g.errs
}
func main() {
eg := &ErrorGroup{}
// Simulate concurrent operations that may fail
for i := 0; i < 5; i++ {
i := i // capture loop variable
eg.Go(func() error {
return simulateOperation(i)
})
}
errs := eg.Wait()
if len(errs) > 0 {
fmt.Printf("Encountered %d errors:\n", len(errs))
for _, err := range errs {
fmt.Println(" -", err)
}
} else {
fmt.Println("All operations completed successfully")
}
}
func simulateOperation(id int) error {
// Simulate some work with random success/failure
if id%3 == 0 {
return fmt.Errorf("operation %d failed", id)
}
time.Sleep(time.Duration(id) * 100 * time.Millisecond)
return nil
}Context With Values and Metadata
Context's ability to carry values makes it incredibly powerful for request-scoped data. This pattern is commonly used in HTTP handlers to pass request IDs, user information, or other metadata.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
type key string
const (
requestIDKey key = "requestID"
userIDKey key = "userID"
)
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func withUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
func requestID(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey).(string); ok {
return id
}
return "unknown"
}
func userID(ctx context.Context) string {
if id, ok := ctx.Value(userIDKey).(string); ok {
return id
}
return "anonymous"
}
func middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = withRequestID(ctx, generateRequestID())
ctx = withUserID(ctx, getUserID(r))
next(w, r.WithContext(ctx))
}
}
func generateRequestID() string {
return fmt.Sprintf("req-%d", time.Now().UnixNano())
}
func getUserID(r *http.Request) string {
// Simplified example - in practice, extract from auth headers
return "user-123"
}
func apiHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fmt.Fprintf(w, "Request ID: %s, User ID: %s\n",
requestID(ctx), userID(ctx))
}Cancellation and Timeout Strategies
Effective cancellation requires understanding when and how to cancel operations. Different strategies work better for different scenarios.
| Strategy | Use Case | Advantages | Disadvantages |
|---|---|---|---|
| Timeout Context | Long-running operations | Prevents resource exhaustion | May cancel successful operations |
| Cancel Context | User-initiated cancellation | Immediate response to user actions | Requires explicit cancellation logic |
| Deadline Context | Time-sensitive operations | Built-in timeout handling | Less flexible than manual cancellation |
package main
import (
"context"
"fmt"
"time"
)
// Cancellable operation with multiple cancellation points
func cancellableOperation(ctx context.Context, workDuration time.Duration) error {
// Simulate work with periodic checks for cancellation
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
startTime := time.Now()
for {
select {
case <-ticker.C:
if time.Since(startTime) > workDuration {
return fmt.Errorf("operation timed out after %v", workDuration)
}
// Check if context was cancelled
select {
case <-ctx.Done():
return ctx.Err()
default:
// Continue working
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// Resource management with context
func resourceIntensiveOperation(ctx context.Context) error {
// Acquire resources
resource, err := acquireResource()
if err != nil {
return err
}
defer releaseResource(resource)
// Use context for operation with timeout
opCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return performWork(opCtx, resource)
}
func acquireResource() (interface{}, error) {
// Simulate resource acquisition
time.Sleep(10 * time.Millisecond)
return "resource", nil
}
func releaseResource(resource interface{}) {
// Simulate resource cleanup
fmt.Println("Resource released")
}
func performWork(ctx context.Context, resource interface{}) error {
// Simulate work with context awareness
select {
case <-time.After(3 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
// Test with timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := cancellableOperation(ctx, 1*time.Second)
if err != nil {
fmt.Println("Operation failed:", err)
} else {
fmt.Println("Operation completed successfully")
}
}Best Practices for Concurrent Error Handling
- Use Error Groups: For collecting errors from multiple goroutines
- Implement Context Propagation: Ensure all functions respect context cancellation
- Avoid Panic in Goroutines: Always handle errors explicitly
- Use Select Statements: For graceful handling of multiple channels
- Log Context Cancellations: Track when operations are cancelled for debugging
Learn more with useful resources
- Go Concurrency Patterns - Official Go documentation on concurrency
- Context Package Documentation - Comprehensive reference for context operations
- Go Concurrency Patterns: Context and Error Handling - Go team blog post on advanced context usage
