Synchronous functions can support cancellation too

Updates:

  1. Added a comment from Jordan Rose arguing that this kind of cancellation support should be opt-in.

Cancellation is a Swift concurrency feature, but this doesn’t mean it’s only available in async functions. Synchronous functions can also support cancellation, and by doing so they’ll become better concurrency citizens when called from async code.

Motivating example: JSONDecoder

Supporting cancellation makes sense for functions that can block for significant amounts of time (say, more than a few milliseconds). Take JSON decoding as an example. Suppose we wrote an async function that performs a network request and decodes the downloaded JSON data:

import Foundation

func loadJSON<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
  let (data, _) = try await URLSession.shared.data(from: url)
  return try JSONDecoder().decode(type, from: data)
}

The JSONDecoder.decode call is synchronous: it will block its thread until it completes. And if the download is large, decoding may take hundreds of milliseconds or even longer.

Avoid blocking if possible

In general, async code should avoid calling blocking APIs if possible. Instead, async functions are expected to suspend regularly to give waiting tasks a chance to run. But JSONDecoder doesn’t have an async API (yet?), and I’m not even sure it can provide one that works with the existing Codable protocols, so let’s work with what we have. And if you think about it, it’s not totally unreasonable for JSONDecoder to block. After all, it is performing CPU-intensive work (assuming the data it’s working on doesn’t have to be paged in), and this work has to happen on some thread.

Async/await works best for I/O-bound functions that spend most of their time waiting for the disk or the network. If an I/O-bound function suspends, the runtime can give the function’s thread to another task that can make more productive use of the CPU.

Responding to cancellation

Cancellation is a cooperative process. Canceling a task only sets a flag in the task’s metadata. It’s up to individual functions to periodically check for cancellation and abort if necessary. If a function doesn’t respond promptly to cancellation or outright ignores the cancellation flag, the program may appear to the user to be stalling.

Now, if the task is canceled while JSONDecoder.decode is running, our loadJSON function can’t react properly because it can’t interrupt the decoding process. To fix this, the decode method would have to perform its own periodic cancellation checks, using the usual APIs, Task.isCancelled or Task.checkCancellation(). These can be called from anywhere, including synchronous code.

Internals

How does this work? How can synchronous code access task-specific metadata? Here’s the code for Task.isCancelled in the standard library:

extension Task where Success == Never, Failure == Never {
  public static var isCancelled: Bool {
      withUnsafeCurrentTask { task in
        task?.isCancelled ?? false
      }
  }
}

This calls withUnsafeCurrentTask to get a handle to the current task. When the runtime schedules a task to run on a particular thread, it stores a pointer to the task object in that thread’s thread-local storage, where any code running on that thread – sync or async – can access it.

If task == nil, there is no current task, i.e. we haven’t been called (directly or indirectly) from an async function. In this case, cancellation doesn’t apply, so we can return false.

If we do have a task handle, we ask the task for its isCancelled flag and return that. Reading the flag is an atomic (thread-safe) operation because other threads may be writing to it concurrently.

Conclusion

I hope we’ll see cancellation support in the Foundation encoders and decoders in the future. If you have written synchronous functions that can potentially block their thread for a significant amount of time, consider adding periodic cancellation checks. It’s a quick way to make your code work better with the concurrency system, and you don’t even have to change your API to do it.

Update February 2, 2022: Jordan Rose argues that cancellation support for synchronous functions should be opt-in because it introduces a failure mode that’s hard to reason about locally as the “source“ of the failure (the async context) may be several levels removed from the call site. Definitely something to consider!