Cancellation tokens have recently surfaced in Swift Evolution in a conversation about async/await. I’ve been using this cancellation model in Nuke for more than a year now, so I decided to share some of my experiences with it.
Update: starting with Nuke 7, Nuke no longer uses cancellation tokens.
Cancellation Token #
Nuke has to manage cancellation of lots of chained asynchronous operations. In the earlier versions cancellation was implemented using a few different ad-hoc mechanisms, including Foundation.Operation
cancellation, some ad-hoc tasks responsible for cancelling multiple underlying operations. It was a mess. In an effort to simplify cancellation I’ve looked at some ideas outside of the Swift world.
One of the most promising patterns I found was C# Cancellation Token. It is a unified model for cooperative cancellation of asynchronous operations:
This model is based on a lightweight object called a cancellation token. The object that invokes one or more cancelable operations, for example by creating new threads or tasks, passes the token to each operation. Individual operations can in turn pass copies of the token to other operations. At some later time, the object that created the token can use it to request that the operations stop what they are doing. Only the requesting object can issue the cancellation request, and each listener is responsible for noticing the request and responding to it in an appropriate and timely manner.
Implementation #
The general pattern for implementing the cooperative cancellation model consists of two components:
- A cancellation token (
CancellationToken
) providing every operation with a way to register for cancellation notifications - A cancellation token source (
CancellationTokenSource
) that manages tokens and sends cancellation notifications to cancellation tokens
Here’s an API for those two types as implemented in Nuke:
public struct CancellationToken {
/// Returns `true` if cancellation has been requested for this token.
public var isCancelling: Bool { get }
/// Registers the closure that will be called when the token is canceled.
/// If this token is already cancelled, the closure will be run immediately
/// and synchronously.
public func register(closure: @escaping () -> Swift.Void)
}
final public class CancellationTokenSource {
/// Returns `true` if cancellation has been requested.
public var isCancelling: Bool { get }
/// Creates a new token associated with the source.
public var token: CancellationToken { get }
/// Communicates a request for cancellation to the managed tokens.
public func cancel()
}
Usage #
To use this model, you first create a token source at a point where you start one or more asynchronous operations. Then you create a token using the token source and pass the token to each of the operations, which in turn register for cancellation notifications using those tokens. Here’s a code example from Nuke:
let cts = CancellationTokenSource()
Manager.shared.loadImage(with: url, token: cts.token) {
print("result \($0)")
}
func loadData(with request: URLRequest, token: CancellationToken, completion: @escaping (Result<Data>) -> Void) {
let task = session.dataTask(with: request)
// <...>
token.register { task.cancel() }
}
When cancel()
is called on the CancellationTokenSource
all the tokens are cancelled.
Pros #
There are quite a few advantages of using cancellation tokens.
-
It works extremely well for the cooperative cancellation of multiple tasks. For example,
Loader
class in Nuke simply passes a cancellation token from one operation to another. -
It makes cancellation an orthogonal concept which simplifies other parts of the system. You no longer have to bake cancellation into operations / tasks / promises. This is the primary reason why cancellation token has surfaced in Swift Evolution. Async/await (as well as many Promise implementations) doesn’t offer a cancellation mechanism of its own. However, you can always use a cancellation token.
-
Operations can register for cancellation after the token was created. This is very useful when you start executing the work asynchronously the way Nuke does:
public func loadImage(with request: Request, token: CancellationToken?, completion: @escaping (Result<Image>) -> Void) {
queue.async {
let task = Task() // create task asynchronously
token.register { task.cancel() } // register asynchronously
}
}
Performance #
The main con of this model is that it is less ergonomic than some alternatives. It requires two types that coordinate with each other. And a token source has to have a way to synchronize access to the underlying storage for observers, which might make it a bit heavy-weight and have a negative effect on performance. Nuke makes some optimizations in that area.
Thanks to the way CancellationTokenSource
is implemented in Nuke, it is able to use a single shared lock for all of the sources. This is possible for two reasons. First, CancellationTokenSource
never executes any of the registered closures inside a lock, eliminating the possibility of deadlocks. And second, critical sections executed inside the lock are extremely fast.
Thanks to that optimizations, CancellationTokenSource
becomes super cheap to use.
Alternatives #
Cancellation tokens get the job done in Nuke, however, it does have some cons. It might be a bit cumbersome to use and is unfamiliar to Swift developers. My favorite cancellation model remains disposing from RxSwift1. It’s more convenient to use and offers a rich set of features. RxNuke wraps Nuke’s cancellation tokens in disposables. However, I find cancellation tokens to be a better fit for core Nuke project because of how lightweight the pattern is.
Resources #
-
It is not originated in RxSwift, but it is probably the best known implementation in Swift. ↩