[Concurrency] Structured concurrency

The effect of cancellation within the cancelled task is fully cooperative and synchronous. That is, cancellation has no effect at all unless something checks for cancellation. Conventionally, most functions that check for cancellation report it by throwing CancellationError() ; accordingly, they must be throwing functions, and calls to them must be decorated with some form of try . As a result, cancellation introduces no additional control-flow paths within asynchronous functions; you can always look at a function and see the places where cancellation can occur. As with any other thrown error, defer blocks can be used to clean up effectively after cancellation.

Did you consider a design where tasks are automatically cancelled at each suspension point by default? This is how Scala’s Zio library works and it is a design that has worked well for them. It ensures unnecessary work is skipped when there is no demand for a task’s result.

Zio supports an “uninterruptible” operator for cases where this default is problematic. Given the general design of this proposal for Swift I think we would need to burn a keyword to specify an block as not implicitly cancelled.

If we went in this direction, the implicit cancellation could be behave by throwing a standard library CancellationError at the next suspension point when cancelled, thus making async imply throws and await imply try in all cases (as was suggested elsewhere). If Swift ever supports typed errors, a TaskError protocol would allow async functions to support typed errors while still modeling the cancellation case.

protocol TaskError {
    init(cancelled: CancellationError)
}

Race

I'm hoping one of the authors can comment on whether this code would work as expected:

/// Runs `first` and `second` concurrently, returning the result of the first task to complete.
/// The loser of the race is cancelled.
func race<T>(_ first: () async -> T, _ second: () async -> T) async -> T {
Task.withNursery(resultType: T.self) { nursery in
    nursery.add(first)
    nursery.add(second)

   guard let result = await try nursery.next() else {
        // we only get here if the parent task was cancelled
        throw CancellationError()
   }

   return result

Detached tasks

Similarly, I'm wondering if the following code is a valid implementation technique for cases where an unbounded async operation (such as a network request) should not extend the lifetime of an object, but instead where the lifetime of the object should place an upper bound on the lifetime of the task. If there is a better way to express this semantics in the current proposal I would like to understand it.

protocol Cancellable {
    func cancel()
}
extension Task.Handle: Cancellable {}

class SomeClass {
    private var pendingTasks: [UUID: Cancellable] = [:
    
    deinit { 
        for task in pendingTasks.values {
            task.cancel()
        }
    }

    func startTask(_ task: () async -> String) { 
        let id = UUID()
        let handle = Task.runDetached(task)
        // I was surprised to find that I needed two calls to `Task.runDetached`
        // but am not sure how else to call back to `self` when the task finishes and `self`
        // is still around.
        Task.runDetached { [unowned self] in
             let result = await Result { try handle.get() }

             // assuming `self` is an actor, am I back in the correct context after the previous `await`?
             self.handleResult(result)
        }
        pendingTasks[id] = handle
    }

    func handleTaskResult(_ result: Result<String, Error>) { ... }
}

Cancellation checking

Shouldn't this be await try Task.checkCancellation()? If not, why does isCancelled introduce a suspension point and not checkCancellation?

2 Likes