Idioms for frustration-free go channel pipelines
including context , error handling, timeouts, OpenTelemetry , etc.
Idioms for frustration-free pipelines
Debugging
See the debugging guide
High-level Architecture
One Source (first step/stage, streams data out to a channel)
One Sink (last step/stage, consumes final results from a channel)
Multiple intermediate processors connected via channels
Everything running at the same time :-)
Tools
Use errgroup (an Official library)
... because it gives you functionality like Kotlin's coroutine scope
Otherwise, you must manage your own WaitGroups
... because it propagates error
s up to group.Wait()
Otherwise you must add error
channels and if
blocks or select
blocks everywhere
... because it handles context cancellation for the whole group of tasks
... because it handles rate limiting
... because the source code is simple, correct, tested, well documented, recommended in several books
like this and this
Control
Make one control-flow-managing func
Construct & setup the errGroup here
Use g.Go(...)
to start and to wait for subtasks
See example below
Subtasks
See example below
Spawn tasks using g.Go(...)
, not go
The only counter-case is when you Wait()
on the errGroup, like this:
go func () { // <-- only time you must use 'go' keyword directly
err := g .Wait ()
// ... either handle the error here, or call g.Wait() again outside this goroutine
close (finalChannelWhichSinkReads )
}()
Most of your functions should be "regular" go functions
meaning they neither accept nor return a channel
Counter-examples:
functions that produce values slowly (meaning slow IO)
functions that produce too many values to keep in memory
These functions should accept the outCh
and errCh
as parameters (and return nothing)
Context
Propagate the errGroup to subtasks using context.Context
Pass context
parameter into subtasks (not errGroup parameter)
Subtasks get the current errGroup from the context
argument
eg. g := ErrGroupFromContext(ctx)
See example below
Channels
Ensure every channel has a closing strategy
Only Sender closes the channel (never the reader/consumer)
Only close
when completely done writing, use defer
Use buffering so channels aren't blocked and to avoid exhausting memory
Waiting
Let errgroup manage waiting for you
Do NOT use your own WaitGroup ,
Errors
Let the errGroup mange errors, just check err := g.Wait()
you can call err := g.Wait()
multiple times
Useful if you need to Wait()
and handle errors in different goroutines
Cancellation
Let the errGroup manage cancellation
You can bind the errGroup to a context with a deadline
Tracing
Create spans inside, at the start of (some) subtasks
Use context.Context
to propagate SpanContext
See tracing doc
See example below
g .Go (func () error {
// -- start a span (for tracing)
ctx , span := otel .Tracer ("" ).Start (ctx , "doSomethingInteresing" )
defer span .End ()
// -- sender (me) responsible for closing outCh channel
defer close (outCh )
// -- do a subtask, subtask might start sub-subtasks using g.Go(...)
result , err := doASubtaskHere (ctx , arg1 , arg2 , ... )
if err != nil {
// -- add helpful messages for tracing errors to their source
otzap .AddErrorEvent (span , "failed to doSomething because Foo" , err )
// -- propagate error thru errGroup (errGroup handles cancellation)
return err
}
// -- send the subtask result
outCh <- result
})
Example: Propagate errGroup thru context.Context
type errGroupContextKeyType int
const currentErrGroupKey errGroupContextKeyType = iota
// ErrGroupFromContext returns the errGroup associated with the context, or nil.
func ErrGroupFromContext (ctx context.Context ) * errgroup.Group {
if g , ok := ctx .Value (currentErrGroupKey ).(* errgroup.Group ); ok {
return g
}
return nil
}
// WithErrGroup returns a new context containing the given errgroup.Group.
func WithErrGroup (parent context.Context , g * errgroup.Group ) context.Context {
return context .WithValue (parent , currentErrGroupKey , g )
}
// NewErrGroup simplifies linking the errGroup and context.
func NewErrGroup (parent context.Context ) (* errgroup.Group , context.Context ) {
g , ctx := errgroup .WithContext (parent )
return g , WithErrGroup (ctx , g )
}
// MergeChannels consumes all input channels,
// merges messages into single output channel
//
// use ctx for timeout, cancellation, pipeline termination
func MergeChannels [T any ](
ctx context.Context ,
chs []<- chan T ,
outCh chan <- T ,
) {
g := ErrGroupFromContext (ctx )
taskCount := len (chs )
done := make (chan struct {}, taskCount )
for _ , current := range chs {
ch := current // exclusive ref
g .Go (func () error {
// -- consume channel
for req := range ch {
outCh <- req
}
done <- struct {}{}
return nil
})
}
// -- Cleanup
g .Go (func () error {
defer close (doneWriting )
defer close (outCh )
for i := 0 ; i < taskCount ; i ++ {
<- done // wait for signal on each subtask
}
// -- Invariant: consumed all input channels
return nil
})
}
https://go.dev/blog/pipelines
See concurrency.debug.md