Workpool is a structured concurrency library that implements a fork-join model, making concurrent tasks safer and more controllable.
// Create a new Workpool and limit the maximum concurrency to 4.
wp := workpool.New(context.TODO(), workpool.Options.ParallelLimit(4))
for _, task := range tasks {
task := task // Shadowing the task variable
wp.Go(func(ctx context.Context) error { // Execute the subtask asynchronously here.
return task(ctx)
})
}
err := wp.Wait() // Wait for all tasks to complete here and handle errors and panics.- Lightweight fork-join concurrency model with lazy expansion of worker goroutines.
- Collects errors from subtasks and aggregates them in the
Workpool.Wait()function. - The subtask panic will be thrown in the parent goroutine, thus avoiding the entire process from crashing.
- Controls the lifecycle of subtasks through
Context, ensuring that all worker goroutines are immediately released whenWorkpool.Wait()is called. - A single
Taskdetached from theWorkpoolcan also be executed asynchronously safely. - Supports phased tasks and can interactively obtain phased results from asynchronous tasks.
- Supports producer-consumer tasks based on
channel, with automatic notification to consumer tasks after all producer tasks are completed (requires generics).
New(), Go(), and Wait() correspond respectively to config, fork, and join.
Option can be passed into New() function, for example, wp := New(ctx, Options.TaskTimeout(time.Second), Options.Chain(Wraps.PanicAsErr)).
| Option | Description |
|---|---|
| Options.TaskTimeout(time.Duration) | Set an independent timeout for each task. |
| Options.ParallelLimit(uint) | Set the maximum concurrency for subtasks. |
| Options.ExitTogether() | Notify other subtasks to exit when any subtask completes, usually used when starting multiple resident services. |
| Options.WrapsChain(...wpcore.TaskWrap) | Add the passed wpcore.TaskWrap to each Task, with the effect applied from left to right. |
| Options.Recover(wpcore.Recover) | Customize how to handle a panic in a subtask. |
| Options.SkipPendingTask(bool) | By default, even if ctx is finished, the subsequent Task will not be skipped. With this option, new Task added after the end of ctx can be skipped directly. |
TaskWrap wraps a Task into a new Task, such as recording metrics and so on. It can be extended as needed.
It is usually used in conjunction with Options.WrapsChain() to be automatically applied to all Task.
wp := New(ctx, Options.WrapsChain(Wraps.PanicAsErr)) // Used in conjunction with `Options.WrapsChain()`.
wp.Go(Wraps.PanicAsErr(task)) // Used on a single `Task`.| TaskWrapper | Description |
|---|---|
| Wraps.PanicAsError | Converts panic in child tasks into an error. |
| Wraps.Phased | Converts phased tasks into normal tasks. See Phased Task for details. |
| Wraps.RunStopTask | Converts tasks that stop execution into tasks controlled by ctx. See Task with Separate Stop Function for details. |
Sometimes, we just need to asynchronously execute a single task and check its result afterwards.
In this case, using a Workpool might be too cumbersome.
However, we can still use Task.Go(context.Context) to start an asynchronous task without creating a new Workpool.
This function returns a wpcore.TaskWait, which is an alias for func() error.
When we execute the returned TaskWait, it will wait for the task to complete and return the result.
task := workpool.Task(func(context.Context) error {
// Order a coffee.
})
waitCoffee := task.Go(context.TODO())
// Save the world.
// Blah blah blah.
if err := waitCoffee(); err == nil {
// Enjoy your coffee.
}As with executing Task in a Workpool,
all errors or panics in the Task will be collected and thrown in Wait() when executing asynchronously using Task.Go(context.Context).
Additionally, you can also wrap the single task to be executed asynchronously using Wraps.PanicAsError.
Phased tasks provide a way to interact with asynchronous tasks. The concept is best explained with an example:
Suppose we have an asynchronous task that updates data on a timer, but the first update must succeed when the task starts.
Without a phased task, a common solution would be to perform the initial update synchronously and then start the remaining updates as an asynchronous Task.
// Constructing `wp`, `ctx`...
err := initTask(ctx)
if err != nil {
return err
}
wp.Go(func(ctx context.Context) error {
// Task blah blah
})But the problem with this approach is that the initialization part cannot be processed asynchronously (which is necessary if there are multiple such tasks), and the logic of a single task is fragmented, making it difficult to maintain.
With phased tasks, this problem can be easily solved:
// Constructing `wp`, `ctx`...
task, supervisor := Wraps.Phased(func(ctx context.Context, helper wpcore.PhasedTaskHelper) error {
err := taskInit(ctx)
if err != nil {
return err
}
// Task initialization is complete. Let's mark this milestone.
helper.MarkAMilestone(taskInitOk)
// Process the remaining parts of the task.
})
wp.Go(task)
initResult, status := supervisor.WaitMilestoneOrCancel(ctx)In phased tasks, we can use helper.MarkAMilestone(interface{}) to record phased achievements by calling it 0 or more times.
This is similar to the yield operation in generators in other languages,
but the difference is that phased tasks do not suspend after MarkAMilestone,
but continue to execute.
Outside of the task, we can interact with the task through the PhasedTaskSupervisor returned by Wraps.Parsed(),
achieving operations such as confirming phased achievements or setting deadlines for phased achievements to cancel if exceeded:
| Function | Description |
|---|---|
| WaitMilestone | Wait for a milestone to be reached. |
| WaitMilestoneOrCancel | Wait for a milestone to be reached, and cancel the task if the deadline is exceeded. |
In addition, the WaitMilestone series of functions also returns a PhasedTaskStatus,
which can be used to determine the state at the time the function returns:
| Status | Description | Note |
|---|---|---|
| IsOK() | Successfully reached a milestone. | |
| IsContextDone() | Context is done and the milestone was not reached. | May coexist with IsTaskNotRunning(). |
| IsTaskDone() | The task has finished, but no milestone was reached. | |
| IsTaskNotRunning() | The task has not started running when the context is done. | Will always coexist with IsContextDone(). |
Some existing long-running services are not controlled by ctx for stopping,
but instead provide a separate Stop/Close function to control shutdown.
For example, http.Server starts the service through the Serve() function
and stops the service through net.Listener.Close().
Wraps.RunStopTask() provides a simple wrapper to convert this type of task into a workpool.Task.
Taking http.Server as an example, the following code can be used to convert it to a workpool.Task:
task := Wraps.RunStopTask(func() error { // Running function.
err := srv.Serve(l)
if errors.Is(err, http.ErrServerClosed) { // Ignore the ServerClosed error.
return nil
}
return err
}, func() error { // Stopping function.
return l.Close()
})