Correct.
Fortunately, most async API that we call and await will detect cancellation for us. And if calling other asynchronous functions, remaining within structured concurrency will greatly simplify the effort to make sure cancellations are propagated to child tasks automatically.
This only becomes a pragmatic concern if you either:
- call legacy synchronous API;
- have a long-running task that neglects to periodically call
try Task.checkCancellation(); or
- introduce unstructured concurrency and accidentally neglect to add a cancellation handler.
As I mentioned in my answer to your other question, this is why we are also very wary of introducing unnecessary unstructured concurrency, and where we bear the burden for manually detecting/propagating cancellation with withTaskCancellationHandler.
Yes, all cancellation in Swift concurrency is “cooperative”, not “preemptive”. This means that our task must either detect and handle cancellation itself, or, more commonly, merely await something that does this for us, which most async API do. So, if you remain with structured concurrency and are calling async API, all of this is generally a non-issue, as the API will detect cancellation and throw CancellationError if cancelled and we’re done. It largely only becomes problematic in the case of some legacy API or writing your own long-running synchronous code. (The latter can frequently be addressed by periodically calling try Task.checkCancellation().)
When we use Task {…}, that will not create a child task, but rather, “a new unstructured top-level task.” Cancelling the “parent” task will therefore have no effect on these separate top-level tasks it created (unless you manually do so). So, yes, these other top-level tasks created by the “parent” will still run.
But even within structured concurrency, where the cancellation is propagated for us, the child task still must detect and handle cancellation. Generally, if it’s just awaiting some async API, we get this behavior for free. But we can contrive an example where cancellation is not detected/handled:
let task = Task {
let foo = Foo()
async let task1: () = foo.bar()
async let task2: () = foo.bar()
async let task3: () = foo.bar()
_ = await (task1, task2, task3)
}
Task {
try await Task.sleep(for: .seconds(0.5))
task.cancel()
}
actor Foo {
func bar(_ duration: Duration = .seconds(1)) {
let start = ContinuousClock.now
while start.duration(to: .now) < duration {
// intentionally spinning; simulating some slow, synchronous work that doesn’t detect cancellation
}
}
}
In this case, because Foo.bar is an actor isolated method, despite using async let, task2 will not run until task1 finishes. And although the main Task {…} is cancelled while task1 is still running, none of these calls to Foo.bar will detect cancellation, and therefore the top level task will blithely carry on despite having been cancelled.
However, if I call some API that detects and handles cancellation (like calling try await Task.sleep(for:) instead of spinning, or periodically calling try Task.checkCancellation), then these tasks will be cancelled:
actor Foo {
func bar(_ duration: Duration = .seconds(1)) throws {
let start = ContinuousClock.now
while start.duration(to: .now) < duration {
try Task.checkCancellation()
}
}
}
Now I’ve illustrated the try Task.checkCancellation pattern to simulate a slow, synchronous calculation, but that’s obviously a bit of an edge case scenario. The vast majority of our Swift concurrency code is just performing an await of some async API (or our own async methods that are awaiting other functions). And because most async API we call almost always handle cancellation for us, all of this manual checking for cancellation is a non-issue.
The big gotcha for new developers is where they start sprinkling their code with unstructured concurrency (i.e., Task {…}) without appreciating the burden that places on their shoulders, namely the manual propagation of cancellation. If you remain within structured concurrency (and favor modern async API over legacy API), life is much easier.
I should note that all of this cancellation conversation is really independent of the async let and task group factors. It really applies to all Swift concurrency programming. Wherever possible, write code that handles cancellation properly (which you effectively get for free when calling most async API). Just avoid/minimize the use of the three anti-patterns I enumerated above.