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

Time:08-20

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! ;)

CodePudding user response:

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 the parent getting canceled. When the parent task is canceled, that will cancel its subtasks, too.

Another minor clarification (and my apologies for splitting hairs): You said that task2 “performs a cancellation operation.” Throwing a CancellationError is not the same thing as canceling a task. Yes, in response to a task being canceled, it may throw CancellationError, but the converse is not true. When task2 throws an error, that is all it is doing, throwing an error. It could have been any error that was thrown.

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

let log = OSLog(subsystem: "Cancellation", category: .pointsOfInterest)

func demo() async throws {
    let id = OSSignpostID(log: log)
    os_signpost(.begin, log: log, name: #function, signpostID: id)
    defer { os_signpost(.end, log: log, name: #function, signpostID: id) }

    async let value1 = task1()
    async let value2 = task2()

    let values = try await [value1, value2]
    print(values)
}

func task1() async throws -> String {
    let id = OSSignpostID(log: log)
    os_signpost(.begin, log: log, name: #function, signpostID: id)
    defer { os_signpost(.end, log: log, name: #function, signpostID: id) }

    try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
    return "one"
}

func task2() async throws -> String {
    let id = OSSignpostID(log: log)
    os_signpost(.begin, log: log, name: #function, signpostID: id)
    defer { os_signpost(.end, log: log, name: #function, signpostID: id) }

    try await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
    throw MyError.timedOut
}

If you are not familiar with that os_signpost stuff, don't worry about it, too much. Just know that it allows “Points of Interest” intervals in Instruments. I.e., profile the app and it will yield:

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].

But change the order:

let values = try await [value2, value1]

And the results change:

enter image description here

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

Obviously, the idea is evaluate these in the order that they complete, not the order that they happened to appear in our code. To solve this, we would use a task group. E.g.

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()) }

        try await group.waitForAll()
    }
}

That's great. That will run task1 and task2 in parallel and it will not await the result in order, but let them just finish in whatever order that they may. That way, as soon as one throws an error, the whole group will be canceled (assuming, of course, that the tasks are cancelable).

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’s fine. But in other cases you need to be able to access the final results in the original order.

In those cases, we have each group task return enough information to collate the results. For example, here, I return a tuple in each individual group task, and then gather the results into a dictionary, from which I can extricate results in the original order using the keys:

func demo() async throws {
    let values: [Int: String] = try await withThrowingTaskGroup(of: (Int, String).self) { group in
        group.addTask { try await (0, self.task1()) }
        group.addTask { try await (1, self.task2()) }

        return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
    }

    print(values)
}

Now this example of index values of 0 and 1 is a little contrived. A more practical example might be if you were dealing with an array of inputs. E.g., perhaps fetching an series of Foo instances from an array of URLs:

func foos(for urls: [URL]) async throws -> [URL: Foo] {
    try await withThrowingTaskGroup(of: (URL, Foo).self) { group in
        for url in urls {
            group.addTask { try await (url, self.foo(for: url)) }
        }

        return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
    }
}

But the key message is that if you want to cancel a series of tasks, allowing an error from one to cancel the others, use a TaskGroup.

  • Related