Async let cancellation - Bug? (confused)

I am a bit confused about tasks being cancelled.

Overview:

  • checkCancellation function has 2 child tasks computeA and computeB that run concurrently, computeB throws an error.

Doubt:

  • I expected child task computeA to be cancelled because computeB threw an error, but computeA was never cancelled.
  • Is my understanding wrong or am I missing something?
  • Or is this a bug?

Note:

  • I am using a SwiftUI project (as Swift Playgrounds don't support async let)
  • macOS Big Sur 11.5.2 (20G95)
  • Xcode Version 13.0 beta 5 (13A5212g)

Output:

A - started
B - going to throw
A - going to return, Task.isCancelled = false
error: infinity

Concurrent Function Definitions:

import Foundation
import UIKit

enum ComputationError: Error {
    case infinity
}

fileprivate func computeA() async throws -> Int {
    print("A - started")
    await Task.sleep(2 * 100_000_000)
    print("A - going to return, Task.isCancelled = \(Task.isCancelled)") //I expected Task.isCancelled to be true
    return 25
}

fileprivate func computeB() async throws -> Int {
    print("B - going to throw")
    throw ComputationError.infinity
}

func checkCancellation() async throws {
    async let a = computeA()
    async let b = computeB()
    
    let c = try await a + b
    print("c = \(c)")
}

Invoking Concurrent function

struct ContentView: View {
    var body: some View {
        Button("check cancellation") {
            Task {
                do {
                    try await checkCancellation()
                    print("normal exit")
                } catch {
                    print("error: \(error)")
                }
            }
        }
    }
}

Observation:

  • When I change the code to let c = try await b + a

Output:

A - started
B - going to throw
A - going to return, Task.isCancelled = true
error: infinity

Doubt:

I am still not sure I understand the reason for this behaviour in the original code

3 Likes

This is a faulty assumption - see a similar example in the structured concurrency proposal.

Errors thrown in a child task are not handled until the child task is awaited, and when you write let c = try await a + b, the compiler is really doing something like this under the hood:

let a_temp = try await a
let b_temp = try await b
let c = a_temp + b_temp

This means that what you’re seeing is expected behavior: when you await on computeA first, your checkCancellation call doesn’t even see the fact that computeB threw an error until after computeA completes. When you reverse the ordering, the parent task sees the error earlier and marks itself (and thus its child tasks) as cancelled and computeA can then respond cooperatively to the cancellation (in your case, just print its cancellation status).

1 Like

Ah, that makes sense. Thanks!

It is unintuitive to me though. I would expect let c = try await a + b to await child tasks in the order they finish rather than their position in the expression, similar to the vegetable chopping example that you linked.

Thoughts, @John_McCall @Joe_Groff @Douglas_Gregor @ktoso?

Below is my understanding (I could be wrong):

Based on Explore structured concurrency in Swift - WWDC21 - Videos - Apple Developer (8:33) when one of the child task throws an error, it would cancel the remaining child tasks.

These are asynchronous tasks so would run in parallel, based on the output computeB throws the error first, computeA continues execution and before returning Task.isCancelled is still false

In the example shown above computeA was never cancelled.

This is not what the video is implying, the more correct way of stating the behavior is:

when the parent task is cancelled, it cancels the remaining child tasks.

And the parent task is only cancelled when the error from the child task is actually propagated to the parent (via an await). Note that the specific example in the video has two explicit awaits and they only talk about what happens when the first throws an error - this corresponds to your try await b + a example.

Note that I do think that this behavior is a little unintuitive, but it is not a bug or unexpected, at least as I understand the structured concurrency architecture. Additionally, the exact ordering of which of a or b is awaited on first is likely an implementation detail that may change in the future (or with a different level of optimization), so you may want to be careful relying on this exact behavior.

Thanks a lot @MPLewis for the explanation.

Bear with me, am so confused about the following part:

Yes, when the parent task is cancelled, it cancels the remaining child tasks.

  1. Question is when does the parent task get cancelled?
  2. When the child task running computeB throws an error, wouldn't the control be thrown back to the invoking parent task?
  3. If returned to parent task then wouldn't it know that one of child tasks ended abnormally and other child tasks need to be cancelled?

It would be nice to have some clarification on this.

When it throws an error or otherwise terminates itself.

No. The child task is marked as cancelled, but it is running in the "background" and the parent task is not notified of its status. The parent task only gets notified of child task status when it calls await, and it's at that point that any errors thrown by the child task are propagated (and this can then lead to the cancellation of the parent task, see above).

Yes, once the parent task is cancelled all outstanding child tasks are also marked as cancelled.

One key point about the above is the parent task can only await on a single child task at a time, meaning that if computeA is being awaited on while computeB throws an error in the background, the parent will not see any status from computeB until after computeA completes and then computeB is awaited on. If. you need this sort of "await on multiple tasks simultaneously, taking the first completed result" behavior, use an explicit TaskGroup instead, with the next() function returning the tasks in the order in which they completed.

1 Like

Thanks a lot @MPLewis for the detailed explanation

sorry to drag on with this, but just had one more doubt:

It seems like moment when we use async let doesn't start the async child tasks, which sees odd and different from my understanding

func checkCancellation() async throws {
    async let a = computeA()
    async let b = computeB()
    print("before loop")
    for _ in 1...19000000 {
    }
    print("after loop")
    let c = try await a + b
    print("c = \(c)")
}

Output:

before loop
after loop
A - started
B - going to throw
A - going to return, Task.isCancelled = false
error: infinity

Are you running this in the Xcode simulator? There are limitations in the simulator as to how many threads are running at once which could explain that behavior. Try running on an actual device if you can.

More to the point, this is concurrency: there are basically no guarantees as to the order in which things execute. Even when running on an actual device, there’s a possibility you get the behavior you experienced where the parent gets all the way to its await before the children start - it all depends on the exact state of the device you’re running on at the moment, and will be affected by (for instance) other applications running at the same time.

1 Like

Thanks a lot @MPLewis

Yeah you are right the output was bit confusing, will wait try it on a real device once iOS 15 and macOS Monterey are released.