Again and again, a concurrent pattern emerges from the need to control goroutine lifecycles and handle their errors, and I call it the “Runner Pattern”.
The runner interface and its contract
The pattern is as simple as a single-method interface:
// Runner defines the Run method to be executed within a goroutine
type Runner interface {
Run(ctx context.Context) error
}
The contract of the interface covers two aspects.
On the goroutine lifecycle, the Run
method will block until one of the following occurs:
- it completes successfully and returns nil
- it fails and returns an error
- it returns the context error as soon as the context gets cancelled
On the error handling, The contract of a Runner
also implies that:
- all the errors that need to be handled by the caller are returned by the
Run
method - the
Run
method either can be called concurrently or returns an error if it cannot - the
Run
method does not spawn its own goroutine directly unless there are nestedRunner
s, whose errors need to be returned by theRun
method too
A group of runners
To manage multiple runners as a group, it would be straightforward to implement a runner group as below, with some of the sync primitives like WaitGroup
and Once
(a reference implementation):
type Group interface {
Go(r Runner)
Wait() error
}
func NewGroup(ctx context.Context) Group
With this simple group API, we could add multiple runners to a group and wait for all of them to complete. By default,
Wait
method returns the first error that occurred in any of the runners, or nil if all runners completed successfully.
Rationale
Go’s CSP-style concurrency model enables us writing synchronous code intuitively but under the hood, schedules them off the thread when they block and resumes them when they unblock.
We should make full use of the unique ability of Go, controlling lifecycles and handling errors in an intuitively synchronous way rather than fighting against CSP and writing asynchronous-style code everywhere (e.g. callbacks, future/promise, async/await etc).
With the runner pattern, we abstracts away the boilerplate code of goroutine spawning and error handling, so that each piece of concurrent code can focus on its own business logic.
Is that just an error group?
The error group could be used to implement the runner pattern. In a sense, you could even call it an error group pattern. However, the runner pattern is more about the interface contract rather than the group implementation, and the contract is not only about error handling but also about the goroutine lifecycle.
Implementation tips of a runner
Here are a few tips to make a runner correct and efficient:
- options, input/output channels and other dependencies can all be arguments passed into the constructor function of a runner
- a runner object should not contain any state mutable after initialisation, instead, mutable state and other resources should be scoped within the
Run
method - the error handling logic of returning the first error can be overridden by handling a specific type of error within the
Run
method - remember passing the context into wherever it is needed, and monitor if it gets cancelled, especially within loops
- all the resources allocated within the
Run
method must be released when it returns