Hi Andrew, thank you for elaborating on that many details here. Again I’m just comparing it to the structured concurrency model I’m experienced with, which does not imply that it has to be identical. If Verse has a slightly different direction in mind, that’s totally fine. I’m just trying to wrap my head around it as this a fairly advanced feature.
Let me bring up examples from Swift just to reference their behavior so we can compare a little better.
func nonAsyncFunc() {
// - this function is not async
// - it can initiate an async call, but that would require to
// spawn an unstructured `Task`
let task = Task { ... }
// this function cannot await for `task` to complete
// as it would require it to be able to suspend
// trivial: `nonAsyncFunc` can be called from an async function
}
Every root task starts always from some synchronous context and has to use a Task
to enter the asynchronous domain. The execution within the scope of that unstructured Task
however becomes structured.
func asyncFunc() async {
anotherAsyncFunction()
// - this function will spawn an unstructured task, but that
// task is NOT a child task of the current structured task,
// as it becomes detached
// - Swift also differentiates between `Task {}` and
// `Task.detached {}` where the former does inherit some
// execution context and the latter doesn't
nonAsyncFunc()
// - existing the scope of this function guarantees that all
// "structured" child tasks have been completed or cancelled
// - completion or failure can be viewed as the same, the child task
// returned either its value or a failure error
// - errors in Swift are not exceptions, those are also values
}
An unstructured Task
in Swift can do many things:
- we can check if it’s cancelled
task.isCancelled
- we can await it’s value
await task.value
- we can explicitly cancel it
task.cancel()
However, task cancellation in Swift is ‘cooperative’ and not eager. That means that the task will not potentially immediate resume or gets thrown away. It will run until it can unwind if at all.
To check within a (un-)structured task if it’s cancelled or not Swift provides two static options.
Task<Int, Never> {
// do some work
// we can dually return early if we wanted
if Task.isCancelled { return 42 }
// do more work
return someComputedResult
}
Task<Int, any Error> {
// do some work
// this option throws a cancellation error
try Task.checkCancellation()
// do more work
return someComputedResult
}
The cancellation error again is just an empty struct.
struct CancellationError: Error, Sendable {}
As you might have noticed by now a task in Swift has a return value and a failure (error). It’s basically like Either<A, B>
. It expresses that it might return either the value for success or failure.
I mentioned “it might” because it doesn’t have to if it does not want to. Even with cooperative cancellation this is still enforced. It’s up to the task on how it wants to handle the cancellation.
let task = Task {
var iteration = 0
while true {
iteration = iteration &+ 1 // with overflow
if Task.isCancelled {
print("cancelled", iteration)
} else {
print(iteration)
}
}
}
task.cancel()
In the above example the task will never return or throw any error value. It will indefinitely loop, but it will eventually start printing cancelled
when the task is known to be cancelled.
If on the other hand the cancellation in Swift was eager instead of cooperative, the task would be immediately killed and potentially require it to always be failable.
The beauty of having cooperative task cancellation is that it permits the implementation of gracefully shutdowns. The cancellation eventually propagates through all interconnected structured child tasks and they all decide on their own how they want to finish their execution if at all.
The similarity in verse is that unstructured tasks spawned within a structured task are not implicitly interconnected. Therefore such unstructured task can outlive the structured one. However any true structured child task is not allowed to outlive the parent. I’m not partially sure if branch
enforces that behavior.
Right now Swift does have some ways to explicitly spawn structured child task through [Discarding]TaskGroup
.
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try boom() }
group.addTask { try boom() }
group.addTask { try boom() }
try await group.next() // re-throws whichever error happened first
} // since body threw, the group and remaining tasks are immediately cancelled
All child tasks must finish in some way before withThrowingTaskGroup
itself is allowed to be resumed.
Swift is still evolving in that area and functionality like withThrowingTaskGroup
are the basic primitives for things like race
, sync
etc. in Verse.
If Verse has static members you could potentially also expose a form of task local cancellation checks.
Pseudo example:
MyAsyncFunc()<suspends>: int = {
var Result: int = 0
loop {
if (task.IsCancelled()) {
set Result = Result + 10 // bump + 10 on cancellation
break
}
set Result += 1
}
return Result
}
If Verse also went the cooperative cancellation route then I could implement the cancellation gracefully and take as much time as needed to complete any critical computation I’d have already running instead of killing that process asap.
One last thing. The above Swift’s TaskGroup
adds structured child tasks, but they currently cannot be explicitly cancelled. However this is just the status quo as the API surface for that hasn’t been implemented yet. There’s no technical reason why it cannot be done, it’s just not there yet.