When writing async code I often need to combine CheckedContinuation
APIs with task cancellation.
Here's a motivating example for a network fetch de-duplication service:
func fetch(from url: URL) async throws -> Data {
let operation = lookupOrCreateRunningOperation(for: url)
return try await withCheckedThrowingContinuation { continuation in
operation.addWaitingTask(continuation)
// operation eventually resumes waiting tasks
}
}
The missing piece is to unblock the suspended task on cancellation, using something like:
continuation.resume(throwing: CancellationError())
It is not easy to add this functionality because withTaskCancellationHandler
is difficult to get right in a thread safe way. Also, automatic cancellation interferes with the requirement to resume the continuation exactly once. How would other code know if the continuation has been resumed by cancellation?
This led me to build CancellingContinuation
as a reusable component, modeled after CheckedContinuation
. It can serve as a drop in replacement and provides a similar API:
func fetch(from url: URL) async throws -> Data {
let operation = lookupOrCreateRunningOperation(for: url)
return try await withCancellingContinuation { continuation in
operation.addWaitingTask(continuation)
}
}
The continuation
in the closure is a thread safe wrapper around CheckedContinuation
which handles cancellation.
The code is here on Github.
I would love to hear about your opinion on this construct as a reusable component in Swift Concurrency. Also, I'm not sure about the correctness of the code and would welcome any comments.
Note on extended API
Swift's CheckedContinuation
cannot be constructed directly. Instead, is is passed as an argument to the closure of with...Continuation
APIs.
This is different in CancellingContinuation
, mainly because of an implementation necessity: The cancellation handler needs something to address to before continuation
can be accessed in the closure.
So I decided to embrace this feature by making it public. Next to the established with...Continuation
API there is a second way on how to use the new construct:
func fetch(from url: URL) async throws -> Data {
let operation = lookupOrCreateRunningOperation(for: url)
let continuation = CancellingContinuation()
operation.addWaitingTask(continuation)
return try await continuation()
}
To await the result I'm using callAsFunction
because I think it reads best at the point of use. (There's no verb in with[...]Continuation
to reuse.)
Note on isolation
If you have problems with the isolation
parameters, just comment them out for now. They are a somewhat new concept from SE-0420 and give my compiler some headaches.
Note on Hashable conformance
Another minor point is the conformance to Hashable
: This is just there to enhance ergonomics for my typical use cases. Please ignore it in this discussion :)
TODO: Prevent dead lock
There's documentation (Cancellation handlers and locks) that reads like my use of resume in the cancellation handler is unsafe. I've not experienced any dead locks but nevertheless it might be safer to resume continuations outside of the lock's critical region.