Advanced Concurrency Patterns in Go (with Examples)

Published: Jun 27, 2025
By: Paolo Galeotti

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.

Share on socials: