There is a lot to unpack here.
A minor terminological clarification (and I apologize for splitting hairs): If a throwing task group is cancelled (say, if one of its subtasks throws an error), it does not propagate the error to the other child tasks. It cancels those tasks. That is very different.
Let us again refer to the documentation, this time for TaskGroup
. It describes two ways to cancel the task group:
This is very similar to the ThrowingTaskGroup
documentation that I referenced in a prior answer, except this one only has two of those options (because throwing is off the table). And of these two options, the second one is more common. We use it all the time. So, I’m going to focus on that.
At this point, it would be useful to look at examples. But, if you forgive me, I’m going to temporarily sidestep yours, as it suffers from a lot of problems that might distract us from the cancellation question.
Instead, let us consider a very practical example. And, like I said, I’m going to focus on the “cancel the task running the task group” scenario, as that is more common.
So, let’s imagine we have an iOS app, and a view wants to know when the app became active and when it went into background. Maybe you want to restore/save something. Or maybe you want to update the UI. Regardless, you might have routines that monitor these lifecycle events:
func monitorDidBecomeActive() async {
for await notification in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
await becameActive() // this is our function that will handle when it became active
}
}
func monitorDidEnterBackground() async {
for await notification in NotificationCenter.default.notifications(named: UIApplication.didEnterBackgroundNotification) {
await enteringBackground() // this is our function that will handle when app goes into background
}
}
And when we want to run them concurrently, we might do:
func monitorLifecycleEvents() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await monitorDidBecomeActive() }
group.addTask { await monitorDidEnterBackground() }
}
}
So, how would you cancel that when, say, the view in question is dismissed? The answer is generally to cancel the Task
running this task group. To do this, in UIKit, we would create a property for the Task
, e.g., set it in viewDidAppear
, and cancel it in viewDidDisappear
:
class ViewController: UIViewController {
var lifecycleEventsTask: Task<Void, Never>?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
lifecycleEventsTask = Task { await monitorLifecycleEvents() }
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
lifecycleEventsTask?.cancel()
lifecycleEventsTask = nil
}
}
Or in SwiftUI it is even easier, because that will automatically cancel the task launched in a .task
view modifier when the view disappears:
struct ContentView: View {
var body: some View {
VStack {…}
.task {
await monitorLifecycleEvents()
}
}
}
So, in both UIKit and SwiftUI, you generally just cancel the task that is running the withTaskGroup
, and you’re done. No need for group.cancelAll()
at all.
Now, you technically can use group.cancelAll()
with a non-throwing withTaskGroup
, too. But we just don’t use this pattern too often, because if a function returns a value, but also supports cancellation, the idiomatic pattern is to (a) make it a throwing function; and (b) ensure that it throws CancellationError
if cancelled. And that reduces the problem to our prior withThrowingTaskGroup
examples.
But, like you said, you theoretically can use optionals rather than the more idiomatic throwing pattern:
func experiment() async {
let result = try await strings()
print(result)
}
func strings() async -> [String] {
try await withTaskGroup(of: String?.self) { group in
group.addTask { await foo() }
group.addTask { await bar() }
var strings: [String] = []
for await string in group {
guard let string else { // if we get an nil values, cancel and return
group.cancelAll()
return []
}
strings.append(string)
}
return strings
}
}
// return “foo” in 5 seconds, unless canceled, in which case it will return `nil`
func foo() async -> String? {
let start = ContinuousClock.now
print("foo start")
do {
try await Task.sleep(for: .seconds(5))
print("foo successfully finished in", start.duration(to: .now))
return "foo"
} catch {
print("foo caught", error, "and is return `nil` in", start.duration(to: .now))
return nil
}
}
// return `nil` after one second
func bar() async -> String? {
let start = ContinuousClock.now
print("bar start")
do {
try await Task.sleep(for: .seconds(1))
print("bar is returning `nil` in", start.duration(to: .now))
return nil
} catch {
print("bar caught", error, "and is stopping in", start.duration(to: .now))
return nil
}
}
The key differences between this example and your non-throwing example include:
- Use
Task.sleep
so the tasks take long enough that we can reliably manifest the cancellation;
- Actually support cancellation in
foo
and bar
(in this case, achieved by using Task.sleep
) … there is obviously no point in worrying about task groups cancelling child tasks if those child tasks do not support cancellation in the first place.
Anyway, that yields:
foo start
bar start
bar is returning `nil` in 1.055175833 seconds
foo caught CancellationError() and is return `nil` in 1.058339708 seconds
[]
You also asked:
Yes, whenever dealing with task group cancellation, there is always a chance that some child tasks have finished. You just decide whether you want to return the partial results that finished before the cancellation or whether you want to throw (or return nil
or whatever) if any of the tasks failed. It just depends upon the use case.