Search code examples
swiftasync-awaittry-catchtaskcancellation

Cancellation caused by subtask 1 should be sensed by other subtasks in Swift, but is not


In Swift, If a Task has two subtasks, and one of them performs a cancellation operation, the other will also perceive it, is this true?

I wrote two subtasks: task1(), task2(). Among them, task2 will cause the cancellation operation after 2 seconds. Then task 1 wakes up after sleeping for 5 seconds. At this time, task 1 will check whether it has been canceled, but task 1 will not find that it has been canceled.

PlaygroundPage.current.needsIndefiniteExecution = true

print("before")

func task1() async throws -> String {
    print("\(#function): before first sleep")
    
    do{
        try await Task.sleep(until: .now + .seconds(5.0), clock: .suspending)
        print("\(#function): after first sleep")
        // Cancellation raised from task 2 should be detected by task 1 here, but is not!
        try Task.checkCancellation()
    }catch{
        print("cancelled by other")
    }
    
    print("\(#function): before 2nd sleep")
    try await Task.sleep(until: .now + .seconds(2.0), clock: .suspending)
    print("\(#function): after 2nd sleep")
    
    return "task 1"
}

func task2() async throws -> String {
    print("\(#function): before first sleep")
    try await Task.sleep(until: .now + .seconds(2.0), clock: .suspending)
    print("\(#function): after first sleep")
    
    print("BOOM!")
    // Cancellation should be sensed by task 1, but it is not
    throw CancellationError()
    
    print("\(#function): before 2nd sleep")
    try await Task.sleep(until: .now + .seconds(2.0), clock: .suspending)
    print("\(#function): after 2nd sleep")
    
    return "task 2"
}

let t = Task {
    
    print("enter root task")
    async let v1 = task1()
    async let v2 = task2()
    print("step 1")
    
    do {
        let vals = try await [v1, v2]
        print(vals)
        print("leave root task")
    }catch {
        print("error root task")
    }
}

print("after")

(The above code runs in the Playground in Xcode 14beta5)

So, Where is the problem?

How to make subtask 2 get perception when subtask 2 is canceled? if possible?

Thanks! ;)


Solution

  • tl;dr

    If you want to cancel a series of tasks as soon as any one of them throws an error, consider using a TaskGroup.


    You asked:

    In Swift, If a Task has two subtasks, and one of them performs a cancellation operation, the other will also perceive it, is this true?

    I might rephrase that: When a subtask throws an error, it can result in an “early exit” of the caller. And when we exit the function, any async let tasks which have not yet been awaited now fall out of scope. With async let, if a child task falls out of scope before it is done, it is “implicitly canceled”. (See SE-0317.)

    Another minor clarification (and my apologies for splitting hairs): You said that task2 “performs a cancellation operation.” Technically, it is not a “cancel operation”, but rather is throwing an error. Sure, in response to a task being canceled, we might throw a CancellationError. But the pattern applies regardless of what type of error is thrown.

    With all of that having been said, I would suggest being careful with async let. Consider, the following renditions of task1 and task2, replacing print statements with “Points of Interest” intervals:

    import os.log
    
    actor Experiment {
        let poi = OSSignposter(subsystem: "Experiment", category: .pointsOfInterest)
    
        func demoAsyncLet() async throws {
            let state = poi.beginInterval(#function, id: poi.makeSignpostID())
    
            async let value1 = task1()
            async let value2 = task2()
    
            do {
                let values = try await [value1, value2]
                poi.endInterval(#function, state, "\(values)")
                print(values)
            } catch {
                poi.endInterval(#function, state, "caught: \(error)")
                throw error
            }
        }
    
        func task1() async throws -> String {
            let state = poi.beginInterval(#function, id: poi.makeSignpostID())
    
            do {
                try await Task.sleep(for: .seconds(2))
                poi.endInterval(#function, state, "finished")
                return "one"
            } catch {
                poi.endInterval(#function, state, "throwing \(error)")
                throw error
            }
        }
    
        func task2() async throws -> String {
            let state = poi.beginInterval(#function, id: poi.makeSignpostID())
    
            do {
                try await Task.sleep(for: .seconds(1))
                poi.emitEvent("Task2 throws error")
                throw ExperimentError.timedOut
            } catch {
                poi.endInterval(#function, state, "throwing \(error)")
                throw error
            }
        }
    }
    
    extension Experiment {
        enum ExperimentError: Error, CustomStringConvertible {
            case timedOut
    
            var description: String {
                switch self {
                case .timedOut: ".timedOut"
                }
            }
        }
    }
    

    If you are not familiar with that OSSignposter stuff, do not worry about it. It just allows me to visualize what is going on in Instruments’ “Points of Interest” intervals. E.g., if I profile the above code in Instruments (e.g., using the “Time Profiler” template), I will see something like:

    enter image description here

    You can see that although it is running task1 and task2 in parallel, demo is only evaluating the results of task2 after it finished awaiting the result of task1 that because you await [value1, value2]. So, we see the Ⓢ signpost where task2 threw its error, but demoAsyncLet did not evaluate that until after it finished awaiting task1.

    But if we change the order that we await them…

    let values = try await [value2, value1]
    

    … the results will change, now awaiting task2 first, and when its error is caught, it leaves, task1 falls out of scope, and thereby cancels task1:

    enter image description here

    Since it is now awaiting task2 first, it caught the error and exited the method (and implicitly canceling task1 for us). But that only worked because we awaited them in this different order, this time awaiting task2 and then awaiting task1.


    At the risk of belaboring the point, let us understand why task1 was canceled after try await [value2, value1] caught the error thrown by task2. It is not because the tasks can “sense each other’s cancellation operation.” It is because when task2 threw the error, the try await triggered an “early exit” of demo. Thus, demo finished before it got to the await of task1. And with async let, if a child task falls out of scope before it has been awaited, it will “implicitly canceled.”


    But going back to the original question, rather than worrying about the order in which we await the individual async let, we might use a task group, instead. That does not suffer from the async let behavior, where the order in which we await the child tasks can affect the results. With task groups, we now evaluate these child tasks in the order that they complete, not the order that they happened to appear in our code. So, using withThrowingTaskGroup, you can manually await group.next(), and if any error is caught, group.cancelAll():

    func demo() async throws {
        try await withThrowingTaskGroup(of: Void.self) { group in
            group.addTask { try await print(self.task1()) }
            group.addTask { try await print(self.task2()) }
    
            while !group.isEmpty {
                do {
                    try await group.next()
                } catch {
                    print(error)
                    group.cancelAll()        // unlike `async let`, if we want to stop the other child tasks, we must do this explicitly
                    throw error
                }
            }
        }
    }
    

    As of iOS 17/macOS 14, this is even easier if using “discarding” task groups. In a discarding task group, withThrowingDiscardingGroup, if any child task throws an error, it will automatically cancel the whole group (unlike a traditional task group, where we had to do this manually, as shown above):

    func demo() async throws {
        try await withThrowingDiscardingTaskGroup { group in
            group.addTask { try await print(self.task1()) }
            group.addTask { try await print(self.task2()) }
        }
    }
    

    That achieves what we were looking for. That will run task1 and task2 in parallel and it will not await the result in order that they appeared in our code, but rather in the order that they complete. That way, as soon as one throws an error, the whole group can promptly be canceled, if that was your intent (assuming, of course, that the tasks are cancelable). This sort of pattern is shown in more detail in the group.waitForAll() documentation.


    As an aside, the downside in letting the task group handle them in the order in which they finish is that they can finish in any order. Sometimes that is fine. But frequently you want to associate the individual results with some original sequence of identifiers (or what have you).

    In those cases, we have each group task return enough information to collate the results. For example, below, I have the task group return a tuple (both the original identifier and the associated value). Then we can gather the results into a dictionary, from which the caller can efficiently retrieve results:

    func demo(_ identifiers: [Int]) async throws -> [Int: String] {
        try await withThrowingTaskGroup(of: (Int, String).self) { group in
            for id in identifiers {
                group.addTask { try await (id, self.foo(id)) }
            }
    
            do {
                var results: [Int: String] = [:]
    
                for try await (index, result) in group {
                    results[index] = result
                }
    
                return results
            } catch {
                group.cancelAll()
                throw error
            }
        }
    }
    

    There are permutations of the theme, but the basic idea is that I might want to cancel the group upon any failure. Because we now use a task group, instead of async let, I no longer have to worry about the order I await the tasks, but can still enjoy a “cancel all if any fail” (assuming that was the desired behavior).

    So, in this last example, task 4 threw an error, but of the original ten tasks, two of them, 0 and 6, were not done yet. But because we employed this group.cancelAll() pattern, those other two tasks were canceled:

    enter image description here

    This enjoys the “cancel all as soon as one fails” behavior you were asking for.