Go Concurrency Pipelines: Mastering Fan-In, Fan-Out, and errgroup
"Deep-dive into concurrent pipeline architectures in Go using Fan-In, Fan-Out, and errgroup.SetLimit for scalable batch and stream data processing."
Go Concurrency Pipelines: Mastering Fan-In, Fan-Out, and errgroup
Streaming data architectures and batch-processing jobs often require multi-stage pipelines. Go's native channels and concurrency primitives excel at composing streaming stages, but managing channel lifecycles, error propagation, and goroutine synchronization requires precise patterns.
This guide explores idiomatic Pipeline, Fan-Out, and Fan-In patterns in Go, alongside modern error-group orchestration using golang.org/x/sync/errgroup.
Pipeline Fundamentals: Channel Invariants
A pipeline stage is a function that:
- Receives values from an inbound channel.
- Performs transformation or I/O work.
- Sends values to an outbound channel.
- Closes the outbound channel when inbound channel closes or context cancels.
Channel Rule of Thumb
The sender owns the channel. Never let a consumer close a channel, or you will trigger a runtime panic:
panic: send on closed channel.
Stage 1: Generator Stage
A generator converts a slice or slice-like source into a read-only streaming channel:
package pipeline
import "context"
func Generator[T any](ctx context.Context, items ...T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for _, item := range items {
select {
case <-ctx.Done():
return
case out <- item:
}
}
}()
return out
}
Stage 2: Fan-Out Pattern
Fan-Out distributes work from a single channel across multiple concurrent goroutines. This is ideal when a processing step is computationally expensive or I/O heavy (e.g., image thumbnailing, external API calls, or cryptographic hashing).
func Worker[T any, R any](
ctx context.Context,
in <-chan T,
transform func(context.Context, T) (R, error),
) <-chan Result[R] {
out := make(chan Result[R])
go func() {
defer close(out)
for item := range in {
res, err := transform(ctx, item)
select {
case <-ctx.Done():
return
case out <- Result[R]{Value: res, Err: err}:
}
}
}()
return out
}
type Result[T any] struct {
Value T
Err error
}
Stage 3: Fan-In Pattern
Fan-In multiplexes multiple input channels into a single output stream.
import "sync"
func FanIn[T any](ctx context.Context, channels ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
multiplex := func(c <-chan T) {
defer wg.Done()
for val := range c {
select {
case <-ctx.Done():
return
case out <- val:
}
}
}
wg.Add(len(channels))
for _, c := range channels {
go multiplex(c)
}
// Closer goroutine
go func() {
wg.Wait()
close(out)
}()
return out
}
Orchestrating Complex Workflows with errgroup
While raw channels are great for continuous streams, batch pipelines that require "all-or-nothing" execution and fast-fail error propagation are best managed with errgroup.Group.
Practical Example: Parallel Batch Processing with Rate Limiting
package main
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Document struct {
ID string
Data string
}
func ProcessDocuments(ctx context.Context, docs []Document) error {
// Create an errgroup derived from caller's context
g, ctx := errgroup.WithContext(ctx)
// Limit concurrency to 8 parallel workers (Go 1.20+)
g.SetLimit(8)
for _, doc := range docs {
d := doc // Capture loop variable for closure
g.Go(func() error {
// Early exit if another worker already failed
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return transformAndUpload(ctx, d)
})
}
// Wait blocks until all tasks complete or the first returns non-nil error
if err := g.Wait(); err != nil {
return fmt.Errorf("pipeline execution failed: %w", err)
}
return nil
}
func transformAndUpload(ctx context.Context, doc Document) error {
// Simulate I/O work
select {
case <-time.After(50 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Channel Pipeline vs. errgroup.SetLimit: When to Use What?
| Criteria | Channel Fan-Out/Fan-In | errgroup.Group |
|---|---|---|
| Data Stream | Continuous infinite streaming | Finite slice or batch |
| Error Handling | Custom result wrapper needed | Built-in first-error propagation |
| Backpressure | Managed via channel buffers | Managed via SetLimit(N) |
| Complexity | Higher (manual sync/closing) | Minimal (declarative) |
Conclusion
When building high-performance Go pipelines:
- Always couple channel reads and writes with
ctx.Done(). - Encapsulate channel mutations within the creator goroutine.
- Use
errgroupfor batch parallel tasks and streaming channels for infinite consumer pipelines.
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.