Proposal to add cancellation abilities for Async/Await

I have a proposal to include cancellation support with the upcoming async/await feature, which I wrote it up in a fork off of Chris Lattner's and Joe Groff's "Async/Await for Swift" gist:

https://gist.github.com/dougzilla32/ce47a72067f9344742e10020ad4c8c41
https://gist.github.com/dougzilla32/ce47a72067f9344742e10020ad4c8c41/revisions

The motivation is to provide a consistent and simple API to cancel asynchronous tasks. My theory (seemingly shared by some) is that supporting cancellation is too much trouble for many programmers, because it is a bit involved and the app may still basically work without it. But performance will likely suffer. With this proposal cancellation becomes a simple task so is much more likely to be implemented by app developers.

I created a prototype to see if the proposal actually works (it does!), which simulates async/await and cancellation: https://github.com/dougzilla32/AsyncCancellation

The idea is to have a flavor of beginAsync that returns something that can be cancelled, which would in turn cancel all enclosed asynchronous operations:

/// Defines an asynchonous task for the purpose of cancellation.
protocol AsyncTask {
    func cancel()
    var isCancelled: Bool { get }
}

/// A list of async tasks along with the associated error handler
public protocol AsyncTaskList: AsyncTask {
    var tasks: [(task: AsyncTask, error: (Error) -> ())] { get }
}

/// Returns an 'AsyncTaskList' that can be used to cancel the enclosed
/// asynchronous tasks.
func beginAsyncTask(_ body: () async throws -> Void) rethrows -> AsyncTaskList

///  Invoking 'task' will add the given 'AsyncTask' to the chain of tasks enclosed by
/// 'beginAsyncTask', or does nothing if enclosed by 'beginAsync'.
func suspendAsync<T>(
  _ body: (_ continuation: @escaping (T) -> (),
           _ error: @escaping (Error) -> (),
           _ task: @esccaping (AsyncTask) -> ()) -> ()
) async throws -> T

It is possible to extend AsyncTaskList to add other methods:

/// Add 'suspend' and 'resume' capabilities to AsyncTaskList
extension AsyncTaskList {
    func suspend() { tasks.forEach { ($0.task as? URLSessionTask)?.suspend() } }
    func resume() { tasks.forEach { ($0.task as? URLSessionTask)?.resume() } }
}

/// Extend URLSessionTask to be an AsyncTask
extension URLSessionTask: AsyncTask {
    public var isCancelled: Bool {
        return state == .canceling || (error as NSError?)?.code == NSURLErrorCancelled
    }
}

/// Add async version of dataTask(with:) which uses suspendAsync to handle the callback
extension URLSession {
    func dataTask(with request: URLRequest) async -> (request: URLRequest, response: URLResponse, data: Data) {
        return await suspendAsync { continuation, error, task in
            let dataTask = self.dataTask(with: request) { data, response, err in
                if let err = err {
                    error(err)
                } else if let response = response, let data = data {
                    continuation((request, response, data))
                }
            }
            task(dataTask)
            dataTask.resume()
        }
    }
}

Example usage for async URLSession.dataTask:

func performAppleSearch() async -> String {
    let urlSession = URLSession(configuration: .default)
    let request = URLRequest(url: URL(string: "https://itunes.apple.com/search")!)
    let result = await urlSession.dataTask(with: request)
    if let resultString = String(data: result.data, encoding: .utf8) {
        return resultString
    }
    throw WebResourceError.invalidResult
}

// Execute the URLSession example
do {
    let chain = try beginAsyncTask {
        let result = try performAppleSearch()
        print("Apple search result: \(result)")
    }   
...
    chain.suspend()
...
    chain.cancel()
} catch {
    print("Apple search error: \(error)")
}

More detailed information can be found in the gist and in the prototype.

Cancellation could be implemented separately, but including it with Swift Async/Await would encourage authors of coroutines to provide an AsyncTask to cancel the coroutine.

I'd love to get feedback and suggestions!!

Doug

3 Likes