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