This post exists in a different category to the AWS material, which makes it
the fixture that proves /blog/category/go/ renders independently of
/blog/category/aws-cloud/.
Why bound anything
An unbounded go func() per item is fine until the input is a million rows.
Then you discover that goroutines are cheap but the file descriptors,
database connections, and downstream rate limits they contend for are not.
The shape
func Process(ctx context.Context, items []Item, workers int) error {
g, ctx := errgroup.WithContext(ctx)
in := make(chan Item)
g.Go(func() error {
defer close(in)
for _, it := range items {
select {
case in <- it:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
for i := 0; i < workers; i++ {
g.Go(func() error {
for it := range in {
if err := handle(ctx, it); err != nil {
return err
}
}
return nil
})
}
return g.Wait()
}
The part people get wrong
The producer must select on ctx.Done(). Without it, a worker returning an
error cancels the context, the remaining workers stop reading, and the
producer blocks forever on a send that nobody will receive.
Sizing the pool
For CPU-bound work, runtime.GOMAXPROCS(0) is the starting point. For
IO-bound work the right number is whatever the slowest downstream dependency
tolerates, which you find by measurement rather than by arithmetic.