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.
The most significant problem here is that the view controller is isolated to
@MainActor
, sotest
is isolated to the main actor, too. But this function proceeds to spin quickly, with no suspension points (i.e., noawait
), so you are indefinitely blocking the main actor. Therefore, you are blocking the main thread, too, and thus never even reaching yourtask.cancel()
line.To solve this, you can either:
Move
test
off the main actor:A
nonisolated
method that isasync
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,
Use the non-blocking
Task.sleep
in your loop (which introduces anawait
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:Actually, because
Task.sleep
actually already checks for cancellation, you don’t needcheckCancellation
or anisCancelled
test, at all: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:
Or see WWDC 2021 video Swift concurrency: Behind the scenes
Bottom line, if
test
is really is a time-consuming spinning on the CPU like your example, you would:try Task.checkCancellation()
; and alsotry Task.yield()
.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 someasync
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 whattest
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:
Call
async throws
functions that already support cancellation. Most of Apple’sasync throws
functions natively support cancellation, e.g.,Task.sleep
,URLSession
functions, etc. And if writing your ownasync
function, use any of the techniques outlined in the following three points.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 manualcheckCancellation
call.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.Alternatively, you can test
Task.isCancelled
, and if so, manually throwCancellationError
. 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.