swiftasync-awaitconcurrencyswift-concurrency

How to check if the current task is cancelled in Swift Tasks (async/await)


Why the following code doesn't print "cancelled". Am I checking task cancellation in a wrong way?

import UIKit

class ViewController: UIViewController {
    
    private var task: Task<Void, Never>?
    
    override func viewDidLoad() {
        let task = Task {
            do {
                try await test()
            } catch {
                if Task.isCancelled {
                    print("cancelled in catch block..")
                }
                if let cancellationError = error as? CancellationError {
                    print("Task canceled..")
                }
            }
        }
        self.task = task
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
            task.cancel()
        }
    }
    
    func test() async throws {
        while true {
            if Task.isCancelled {
                print("cancelled..")
                throw URLError(.badURL)
            }
            // OUTPUT:
            // "cancelled.." will not be printed
            // "Task canceled.." will not be printed
            // "cancelled in catch block.." will not be printed
        }        
    }
}


However, if I put if Task.isCancelled { print("cancelled in catch block..") } inside the catch block, cancelled in catch block.. will be executed as expected.


Solution

  • The most significant problem here is that the view controller is isolated to @MainActor, so test is isolated to the main actor, too. But this function proceeds to spin quickly, with no suspension points (i.e., no await), so you are indefinitely blocking the main actor. Therefore, you are blocking the main thread, too, and thus never even reaching your task.cancel() line.

    To solve this, you can either:

    1. Move test off the main actor:

      nonisolated func test() async throws { … }
      

      A nonisolated method that is async will not run on the current actor. See SE-0338.

      (Note, getting it off the main actor is, at best, only a partial solution, as you really should never block any of the threads in the Swift concurrency cooperative thread pool. But more on that later.)

      Or,

    2. Use the non-blocking Task.sleep in your loop (which introduces an await suspension point) instead of a tightly spinning loop. This would avoiding the blocking of the main actor, and transform it to something that just periodically checks for cancelation:

      func test() async throws {
          while true {
              try? await Task.sleep(for: .seconds(0.5))
              try Task.checkCancellation()
      
              // or
              //
              // try? await Task.sleep(for: .seconds(0.5))
              // if Task.isCancelled {
              //     print("cancelled..")
              //     throw CancellationError()       // very misleading to throw URLError(.badURL) !!!
              // }
          }
      }
      

      Actually, because Task.sleep actually already checks for cancellation, you don’t need checkCancellation or an isCancelled test, at all:

      func test() async throws {
          while true {
              try await Task.sleep(for: .seconds(0.5))
          }
      }
      

    Never have long-running synchronous code (like your while loop) in Swift concurrency. We have a contract with Swift concurrency to never block the current actor (especially the main actor).

    See SE-0296, which says:

    Asynchronous functions should avoid calling functions that can actually block the thread…

    Or see WWDC 2021 video Swift concurrency: Behind the scenes

    We want you to be able to write straight-line code that is easy to reason about and also gives you safe, controlled concurrency. In order to achieve this behavior that we are after, the operating system needs a runtime contract that threads will not block …

    And lastly, the final consideration has to do with the runtime contract that is foundational to the efficient threading model in Swift. Recall that with Swift, the language allows us to uphold a runtime contract that threads will always be able to make forward progress. … As you adopt Swift concurrency, it is important to ensure that you continue to maintain this contract in your code as well so that the cooperative thread pool can function optimally. …

    Bottom line, if test is really is a time-consuming spinning on the CPU like your example, you would:

    But I must acknowledge the possibility that this while loop was introduced when preparing your example (because it really is a bit of an edge-case). If, for example, test is really just calling some async function that handles cancelation (e.g., a network call), then none of this silliness of manually checking for cancelation is generally needed. It would not block the main actor, and likely already handles cancelation. We would need to see what test really is doing to advise further.


    Setting aside the idiosyncrasies of the code snippet, in answer to your original question, “How to check if the current task is cancelled?”, there are four basic approaches:

    1. Call async throws functions that already support cancellation. Most of Apple’s async throws functions natively support cancellation, e.g., Task.sleep, URLSession functions, etc. And if writing your own async function, use any of the techniques outlined in the following three points.

    2. Use withTaskCancellationHandler(operation:onCancel:) to wrap your cancelable asynchronous process.

      This is useful when calling a cancelable legacy API and wrapping it in a Task. This way, canceling a task can proactively stop the asynchronous process in your legacy API, rather than waiting until you reach a manual checkCancellation call.

    3. When we are performing some manual, computationally-intensive process with a loop, we would just try Task.checkCancellation(). But all the previously mention caveats about not blocking threads, yielding, etc., still apply.

    4. Alternatively, you can test Task.isCancelled, and if so, manually throw CancellationError. This is the most cumbersome approach, but it works.

    Many discussions about handling cooperative cancelation tend to dwell on these latter two approaches, but when dealing with legacy cancelable API, the aforementioned withTaskCancellationHandler is generally the better solution.