Task.select
- Authors: Philippe Hausler
- Review Manager: TBD
- Status: Pitch
Introduction
A fundamental part of many asynchronous algorithms is being able to select the first resolved task from a given list of active tasks. This enables algorithms like debounce
or merge
where concurrent execution is needed but the cancellation or scoped execution behavior of groups is not desired.
Proposed Solution
Selecting the first task to complete from a list of active tasks is a similar algorithm to select(2)
. This has similar behavior to TaskGroup
except that instead of child tasks this function transacts upon already running tasks and does not cancel them upon completion of the selection and does not need to await for the completion of all of the tasks in the list to select.
extension Task {
public static func select<Tasks: Sequence & Sendable>(
_ tasks: Tasks
) async -> Task<Success, Failure>
public static func select(
_ tasks: Task<Success, Failure>...
) async -> Task<Success, Failure>
}
Selecting over existing tasks allows for non-group-like behavior. The tasks themselves can be long running or selected again after a selected task has been found from a previous call to select has been made. By utilizing this strategy, the tasks being selected are no longer child tasks but instead their own independent tasks running until vending a result.
Taking the example of the makeDinner()
from the structured concurrency proposal we can further investigate with an example of LineCook
that models an iterative job cooking meals.
actor LineCook {
enum CookingJob {
case veggies
case meat
case oven
}
enum CookingStep {
case veggies([Vegetable])
case meat(Meat)
case oven(Oven)
}
var activeJobs: Set<Task<CookingStep, Never>>
func addJob(_ step: CookingStep) async {
switch step {
case .veggies:
activeJobs.insert(Task {
await .veggies(chopVegetables())
})
case .meat:
activeJobs.insert(Task {
await .meat(marinateMeat())
})
case .oven:
activeJobs.insert(Task {
await .oven(preheatOven(temperature: 350))
})
}
}
func finishedJob() async -> CookingStep {
let finishedJob = await Task.select(activeJobs)
activeJobs.remove(finishedJob)
return await finishedJob.value
}
}
In this example awaiting a finished job will select the first finished task out of the active jobs. Just because one job has been completed does not mean that other jobs should be cancelled. Instead it is desirable to let those jobs continue to execute.
Detailed Design
Given any number of Task
objects that share the same Success
and Failure
types; Task.select
will suspend and await each tasks result and resume when the first task has produced a result. While the calling task of Task.select
is suspended if that task is cancelled the tasks being selected receive the cancel. This is similar to the family of TaskGroup
with a few behavioral and structural differences.
Behavior | withTaskGroup |
Task.select |
---|---|---|
Inner tasks | Creates efficient child tasks. | Works with pre-existing tasks. |
Finishing | Awaits all child tasks. | Awaits first task. |
Cancel on return | Cancels child tasks when returning. | Does not cancel on return. |
Min tasks | Supports 0 child tasks. | Requires at least 1 task. |
This means that withTaskGroup
is highly suited to run work in parallel, whereas Task.select
is intended to find the first task that provides a value. There is inherent additional cost to the non-child tasks so Task.select
should not be used as a replacement for anywhere that is more suitable as a group, but offers more potential for advanced algorithms.
Currently Task.select
is implemented with higher level constructs backing it in the Swift Async Algorithms package. Even though this API is relatively advanced in where it should be used, it feels more appropriate for general consumption from the swift concurrency library. Making this API have the new home of the swift concurrency library will allow for a considerably more efficient implementation but also have it along side the other APIs that work in the same arena.
Source compatibility
This proposal is additive and has no implication to source compatibility.
Effect on ABI stability
Implementation of the most optimized version of this proposal may have interoperation with the underlying implementation of Task
itself. However since it can be achieved without needing to make those changes we feel that this is achievable without any disruption to ABI.
Effect on API resilience
This proposal is additive and has no implication to API resilience.
Alternatives Considered
A number of alternative names were considered when naming this: one early choice was Task.race
, another was Task.first
. The name race
connotes perhaps less than ideal contexts since the goal of Swift concurrency is to avoid race conditions. First becomes lexically ambiguous; it is unclear on if it is referencing the first task in the sequence of tasks or the first task that finishes.
Another suggestion that was made for naming was Task.winner
; this is a slightly better name than Task.race
but still suffers similarly about what the behavior really is; in the case of winners it is perhaps inferred that losers are canceled somehow (which is not the desired territory of this API, that is in the domain of TaskGroup
).
Acknowledgments
Yasuhiro Inami has offered some helpful early feedback about naming and behaviors.
Tony Parker helped review the initial implementation.
Kevin Perry helped review, test, and debug the initial implementation.