Go Concurrency Patterns: Context
In distributed systems, misused goroutines can leak resources or hang indefinitely. Context is crucial for managing the lifetime and execution of goroutines. It allows us to systematically propagate cancellation signals, timeouts, and other request-scoped data across API boundaries.
Let's explore the use of context in the worker pool pattern, where a worker pool manages a a slice of workers:
Each Worker in the WorkerPool manages a channel of generic messages:
The Context interface from the context package is used to tie the lifetime of the goroutine to the parent goroutine that created it (we will look at that process shortly). WaitGroup from the sync package is used to signal the parent goroutine every time it's done processing a Message. If a message fails to be processed, we will enqueue it in a dead-letter queue. Since DeadLetterQueue will be accessed concurrently, we embed a Mutex to allow locking the data structure when its Add(message T) method is called.
When we create a new WorkerPool, we launch a goroutine for each Worker and pass the corresponding channel (by reference):
The messageProcessor function executed inside each goroutine waits until a message can be received from its channel (or until the channel is closed). If a message fails processing, we add it to the dead-letter queue. Since the parent goroutine will wait (block) until all messages are processed, every time a message is received and processed inside a Worker, we send a signal via WaitGroup.Done():
Before we examine the process function, let's look at the function that will process a batch of generic messages concurrently:
ProcessResources creates a message for each element in the input slice and sends it to a random worker's channel. Each Message carries the same Context with a timeout of 5 seconds (a Context is safe for simultaneous use by multiple goroutines). This means that ProcessResources has a deadline of 5 seconds to process ALL messages.
Why is this useful?
In HTTP/gRPC services, a context with a timeout becomes a guarantee that a function should not (and will not) take longer than T seconds to complete (including built-in retries, as we will see). This prevents a Go function from running indefinitely and consuming resources unnecessarily.
Let's look at the process function called every time a Message is received in a goroutine channel:
If the context has timed out, the function returns a context error in messageProcessor and the message is added to the dead-letter queue. Otherwise, we simulate work with time.Sleep(d) and print the message. This function also simulates a random error 20% of the time and recursively retries with exponential backoff. Total processing time (including any retries) across all worker goroutines, races with the context deadline.
In ProcessResources, if the context deadline is hit during wg.Wait():
The context is automatically canceled by the Go runtime
Workers (goroutines) notice ctx.Done() inside the process function and immediately stop processing further retries
Workers still call wg.Done() in messageProcessor once they return from the process function
wg.Wait() in ProcessResources will continue waiting for all workers to call Done()
After all workers call Done(), wg.Wait() will unblock normally.
Let's put it all together in main.go using messages of type string:
What are context best practices and gotchas you have encountered? How would you implement a Response struct for the return type of ProcessResources? Let me know in the comments!
Source code + unit tests available in the concurrency package of this GitHub project: https://github.com/CarlosLaraFP/go-wiki
Let me know if you found this article helpful and feel free to connect to exchange ideas!