Backend: create unified `lib/group` package
Created by: camdencheek
This PR is my attempt to unify the concept of goroutine groups behind a uniform, ergonomic interface. It simplifies common patterns like limiting concurrency, passing contexts, collecting errors, collecting results, concurrently processing streams, and recovering from panics. It is designed to not get in your way if you want to do weird things. Its API is built to make it difficult to misuse.
Some explanations below, but I'd recommend skipping to the examples table below to get a feel for how the package works.
The main entrypoints to the package are the constructors:
-
group.New()
: a simpleGroup
, basically justgo func()
+wg.Add()/wg.Done()
-
group.NewWithResults[T]()
: a group (ResultGroup
) of tasks that return results -
group.NewWithStreaming[T]()
: a group (StreamGroup
) of tasks that stream their results (in order) with a callback. This supercedesgroup.ParallelOrdered
, which was created by me and only used by me.
All of these group types share a set of configuration methods, but there are some configuration methods are only available for certain types. The following apply to all group types:
-
g.WithMaxConcurrent(n)
returns a group configured to only run up ton
concurrent goroutines at a time -
g.WithConcurrencyLimiter(l)
returns a group configured to use the given limiter. This interface is implemented by the limiter in the internal/mutablelimiter package. -
g.WithErrors()
returns a group (Error(Result|Stream)?Group
) that runs tasks that return an error -
g.WithContext()
returns a group (Context(Result|Stream)?Group
) that runs tasks that require a context. It also will use its context to unblock waiting on the limiter if the context is canceled.
The following is only available after g.WithErrors()
or g.WithContext()
:
-
g.WithFirstError()
configures the group to only hold on to the first error returned by a task. By default, it will return a combined error with every error returned by the task. This option is useful in case you don't really need a combined error with a million context errors. Part of me wonders if this should be default to match behavior oferrgroup.Group
.
The following is only available after g.WithContext()
:
-
g.WithCancelOnError()
configures the group to cancel its context if a task returns an error
This interface makes a few common problems more difficult to hit. Many of these are easy to catch and easy to fix, but if it's even better if they're not possible to hit in the first place. Some common issues this solves:
- Returning early when a semaphore errors. This means we never clean up the started goroutines, so we return from the function with leaked goroutines.
- Forgetting a
wg.Add()
orwg.Done()
orwg.Wait()
(though you still needg.Wait()
) - Unintentionally canceling the context on error when using a
errgroup.Group
- Unintentionally swallowing errors because
errgroup.Group
only returns the first error - Forgetting mutexes when collecting results or errors
- Not adding a panic handler for goroutines, causing the whole process to crash if they panic (a future version may allow adding a custom panic handler)
Generics are only used where necessary, and the basic Group
/ErrorGroup
/ContextGroup
avoids them entirely.
I have no plans to migrate the codebase to use this new package. If you prefer to keep using your tried-and-true errgroup.WithContext()
or raw sync.WaitGroup
, I won't get in your way.
Without lib/group
|
With lib/group
|
---|---|
|
|
|
|
|
|
|
|
A surprising amount of code that is difficult to understand and difficult to maintain. |
|
Test plan
Added extensive unit tests.