Go is well known for its built-in concurrency features like goroutines and channels. But using them effectively requires more than just go func() — it takes structure. In this post, we'll look at three practical concurrency patterns that can help you build reliable, maintainable concurrent programs in Go.
We'll cover:
Worker Pool – limiting the number of goroutines processing jobs
Broadcast Stop Signal – stopping multiple goroutines cleanly
Fan-In – combining output from multiple goroutines
Each comes with a code snippet, usage guidelines, and real-world examples.
1. Worker Pool
Imagine you have a list of tasks to perform — maybe resizing images, scraping URLs, or sending emails. You want to process many of them in parallel, but without spawning hundreds of goroutines (which could exhaust system resources or flood external services).
A Worker Pool solves this by starting a fixed number of goroutines (the "workers") and feeding them tasks through a channel. Each worker takes a task, processes it, then waits for the next.
This way, you keep concurrency under control, and your program becomes more predictable and efficient.
🔧 Code Example
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(time.Second) // Simulated work
results <- job * 2
}
}
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// Start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Send jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// Collect results
for a := 1; a <= numJobs; a++ {
fmt.Println("Result:", <-results)
}
}
✅ When to Use
You have many small independent tasks
You want to limit concurrency (e.g., to avoid overloading a server)
Your workers can be reused and don't need persistent unique state
❌ When to Avoid
Tasks are infrequent — spinning up goroutines ad-hoc may be simpler
Each worker needs its own long-lived state (like a DB connection)
You need instant parallelism without limits (use goroutines directly, with care)
💡 Real-World Use Cases
Sending emails in batch
Making API calls with rate limits
Processing jobs from a queue
2. Broadcast Stop Signal
Sometimes you have multiple goroutines running in the background, maybe listening for events or processing jobs. When your app shuts down (or an error occurs), you want to signal all of them to stop.
This pattern is simple and avoids common mistakes like using context.Context in long-lived structs (which is considered an anti-pattern).
🔧 Code Example
type Worker struct {
id int
stopCh <-chan struct{}
}
func (w Worker) Start() {
go func() {
for {
select {
case <-w.stopCh:
fmt.Printf("Worker %d stopping\n", w.id)
return
default:
fmt.Printf("Worker %d is working...\n", w.id)
time.Sleep(500 * time.Millisecond)
}
}
}()
}
func main() {
stop := make(chan struct{})
workers := []Worker{
{1, stop},
{2, stop},
}
for _, w := range workers {
w.Start()
}
time.Sleep(2 * time.Second)
close(stop) // Broadcast stop signal
time.Sleep(1 * time.Second) // Wait for workers to shut down
}
This works because in Go when you close a channel it is signaled to all consumers. This cannot be done by simply sending data on the channel as this will be only consumed by the first available goroutine and not the other ones.
✅ When to Use
You have long-running goroutines (like listeners or pollers)
You want a centralized shutdown signal
You don't need per-operation timeouts or error propagation
❌ When to Avoid
For request-scoped logic: prefer context.Context
When using timeouts, deadlines, or parent-child cancellation chains
When each goroutine manages its own lifecycle separately
⚠️ Why Not Use context.Context Here?
Using context.Context as a field in a long-lived struct is an anti-pattern because:
Contexts are meant to be passed, not stored
They represent the lifetime of an operation, not a component
Reusing a canceled context leads to subtle bugs and undefined behaviour
Instead, use a simple channel like stopCh for signaling component shutdown — it’s idiomatic and safer.
💡 Real-World Use Cases
Gracefully stopping background workers on app shutdown
Stopping consumers from a message broker (e.g., Kafka)
Halting TCP/UDP listeners on SIGTERM
3. Fan-In
Sometimes you have multiple goroutines producing data independently (scrapers, sensors, file readers, etc.), and you want to combine their outputs into a single stream.
The Fan-In pattern merges multiple input channels into one output channel. This makes it easy to centralize processing logic while keeping producers separate and independent.
🔧 Code Example
func producer(id int, out chan<- string) {
for i := 0; i < 3; i++ {
out <- fmt.Sprintf("Producer %d: item %d", id, i)
time.Sleep(time.Duration(id) * 200 * time.Millisecond)
}
close(out)
}
func fanIn(channels ...<-chan string) <-chan string {
out := make(chan string)
for _, ch := range channels {
go func(c <-chan string) {
for val := range c {
out <- val
}
}(ch)
}
return out
}
func main() {
a := make(chan string)
b := make(chan string)
go producer(1, a)
go producer(2, b)
merged := fanIn(a, b)
for i := 0; i < 6; i++ {
fmt.Println("Received:", <-merged)
}
}
✅ When to Use
Multiple goroutines are producing values concurrently
You want to merge their outputs to process in one place
Each producer can run independently
❌ When to Avoid
If the order of output matters (fan-in does not preserve it)
If you need to track which source each message came from
If producers are not running concurrently — simple merging may suffice
💡 Real-World Use Cases
Merging logs from multiple microservices
Combining results from parallel HTTP scrapers
Aggregating events from different input sources
Conclusion
Each of these concurrency patterns helps structure your Go code for clarity, safety, and performance:
PatternBest ForAvoid WhenWorker PoolControlled parallelism of tasksWhen tasks are rare or statefulBroadcast StopCoordinated shutdown of background loopsFor short-lived operations with timeoutsFan-InCombining outputs from concurrent sourcesWhen order or source identity is critical
By understanding when to use these patterns — and when not to — you can build concurrent systems in Go that are fast, safe, and maintainable.