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 nested Runners, whose errors need to be returned by the Run 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