Go is famous for its built-in concurrency features such as goroutines and channels. But using them effectively takes more than a plain go func(): it takes structure. In this article, we will explore three practical concurrency patterns that will help you build reliable, maintainable concurrent programs in Go.
Table of contents:
- Worker Pool: limiting the number of goroutines that process jobs
- Broadcast Stop Signal: stopping multiple goroutines cleanly
- Fan-In: combining output from multiple goroutines
Each pattern includes code examples, usage guidelines and real-world use cases.
1. Worker Pool: controlled concurrency
Imagine you have a list of tasks to run, perhaps 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 overload external services).
A Worker Pool solves this problem 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 one.
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 the jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// Collect the results
for a := 1; a <= numJobs; a++ {
fmt.Println("Result:", <-results)
}
}
✅ When to use it
- You have many small, independent tasks
- You want to limit concurrency (to avoid overloading a server)
- Your workers can be reused and do not need unique persistent state
❌ When to avoid it
- Tasks are infrequent: creating ad-hoc goroutines may be simpler
- Each worker needs its own long-lived state (such as a DB connection)
- You need instant, unbounded parallelism (use goroutines directly, with care)
💡 Real-world use cases
- Sending emails in batches
- API calls with rate limits
- Processing jobs from a queue
2. Broadcast Stop Signal: coordinated shutdown
Sometimes you have several goroutines running in the background, perhaps listening for events or processing jobs. When your app shuts down (or an error occurs), you want to tell all of them to stop.
This pattern is simple and avoids common mistakes such as 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 the workers to stop
}
This works because in Go, closing a channel signals all of its consumers. It cannot be done simply by sending data on the channel, because that would be consumed only by the first available goroutine and not by the others.
✅ When to use it
- You have long-running goroutines (such as listeners or pollers)
- You want a centralised shutdown signal
- You do not need per-operation timeouts or error propagation
❌ When to avoid it
- For request-scoped logic: prefer
context.Context - When you use 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 around, not stored
- They represent the lifetime of an operation, not of a component
- Reusing a cancelled context leads to subtle bugs and undefined behaviour
Instead, use a simple channel such as stopCh to signal the component's shutdown: it is idiomatic and safer.
💡 Real-world use cases
- Gracefully stopping background workers on app shutdown
- Stopping consumers from a message broker (e.g. Kafka)
- Shutting down TCP/UDP listeners on SIGTERM
3. Fan-In: stream aggregation
Sometimes you have several 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 centralise processing logic while keeping the 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 it
- Multiple goroutines produce values concurrently
- You want to merge their outputs to process them in one place
- Each producer can work independently
❌ When to avoid it
- If the order of the output matters (fan-in does not preserve it)
- If you need to track which source each message comes from
- If the producers are not running concurrently: a simple merge may be enough
💡 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.
By understanding when to use these patterns, and when not to, you can build concurrent systems in Go that are fast, safe and maintainable.
This article was written by Paolo Galeotti, Backend Developer at Quinck. Read more articles on our blog for insights on software development and technological innovation.


