Is Task Cancellation totally cooperative?

So if I have no code in any of the async methods invoked in a Task’s body nor the Task itself to check if the Task has been cancelled, would the call to cancel the Task do nothing?

And how does this translate to structured concurrency for TaskGroups and Async Let calls? Would a call to cancel the Task running just mark the child tasks as cancelled but it’s their responsibility to actually handle anything rather than the cancellation terminating any running code in the children?

And if a Task hasn’t started yet, would it still run after the parent task has been cancelled? Or it wouldn’t even.

Sorry if this is already discussed in official documentation. I just couldn’t find the answer myself

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.

2 Likes

How does task cancellation actually play out in structured currency?

Let's say, given some async call:

A:

withTaskGroup { group in 
    group.addTask { 
       // task has been cancelled
       await someAsyncCall()
   }
}

and

B:

Task { 
    // task has been cancelled
    await someAsyncCall()
}

In both cases we must manually check for cancellation, right? What conveniences does A offer that B doesn't?

Consider the following expanded rendition of yours, where I’m cancelling both A and B:

func a() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in 
        group.addTask {
            try await someAsyncCall("A")
        }
    }
}

func b() async throws {
    let task = Task {
        try await someAsyncCall("B")
    }
    
    _ = try await task.value
}

func someAsyncCall(_ message: String) async throws {
    do {
        print(message, "starting")
        try await Task.sleep(for: .seconds(5))
        print(message, "finishing")
    } catch {
        // Generally, I wouldn't catch the error only to turn around and print/rethrow it, but it’s useful here so you can see what is going on
        print(message, "caught", error)
        throw error
    }
}

let taskA = Task { try await a() }
let taskB = Task { try await b() }

Task { 
    try await Task.sleep(for: .seconds(1))
    print("Cancelling A")
    taskA.cancel()
    print("Attempting cancelling B")
    taskB.cancel()
}

You’ll see that A propagated the cancellation to someAsyncCall, but B did not:

If you want to propagate the cancellation with Task {…}, you have to manually do that:

func b() async throws {
    let task = Task {
        try await someAsyncCall("B")
    }
    
    try await withTaskCancellationHandler { 
        _ = try await task.value
    } onCancel: { 
        task.cancel()
    }
}

Because of the additional syntactic noise associated with this Task {…} pattern, we’d generally only do this where we really needed the unstructured concurrency (which we don’t, here).

3 Likes

The other way the structured case (in this case withTaskGroup) helps is that you’re more likely to notice if cancellation doesn’t actually stop the task, because withTaskGroup will not return until every task completes. Whereas for an unstructured task, you might think you’ve cancelled it, and the rest of the program keeps humming along…but meanwhile the task keeps running, eating up your CPU time and the user’s battery at best, and possibly performing actions with stale information at worst.

4 Likes