|
| 1 | +# Continuations for interfacing async tasks with synchronous code |
| 2 | + |
| 3 | +* Proposal: [SE-0300](0300-continuation.md) |
| 4 | +* Authors: [John McCall](https://github.com/rjmccall), [Joe Groff](https://github.com/jckarter), [Doug Gregor](https://github.com/DougGregor), [Konrad Malawski](https://github.com/ktoso) |
| 5 | +* Review Manager: [Ben Cohen](https://github.com/airspeedswift) |
| 6 | +* Status: **Active review (15 - 26 January 2021)** |
| 7 | + |
| 8 | +## Introduction |
| 9 | + |
| 10 | +Asynchronous Swift code needs to be able to work with existing synchronous |
| 11 | +code that uses techniques such as completion callbacks and delegate methods to |
| 12 | +respond to events. Asynchronous tasks can suspend themselves on |
| 13 | +**continuations** which synchronous code can then capture and invoke to |
| 14 | +resume the task in response to an event |
| 15 | + |
| 16 | +Swift-evolution thread: |
| 17 | + |
| 18 | +- [Structured concurrency](https://forums.swift.org/t/concurrency-structured-concurrency/41622) |
| 19 | +- [Continuations for interfacing async tasks with synchronous code](https://forums.swift.org/t/concurrency-continuations-for-interfacing-async-tasks-with-synchronous-code/43619) |
| 20 | + |
| 21 | +## Motivation |
| 22 | + |
| 23 | +Swift APIs often provide asynchronous code execution by way of a callback. This |
| 24 | +may occur either because the code itself was written prior to the introduction |
| 25 | +of async/await, or (more interestingly in the long term) because it ties in |
| 26 | +with some other system that is primarily event-driven. In such cases, one may |
| 27 | +want to provide an async interface to clients while using callbacks internally. |
| 28 | +In these cases, the calling async task needs to be able to suspend itself, |
| 29 | +while providing a mechanism for the event-driven synchronous system to resume |
| 30 | +it in response to an event. |
| 31 | + |
| 32 | +## Proposed solution |
| 33 | + |
| 34 | +The library will provide APIs to get a **continuation** for the current |
| 35 | +asynchronous task. Getting the task's continuation suspends the task, and |
| 36 | +produces a value that synchronous code can then use a handle to resume the |
| 37 | +task. Given a completion callback based API like: |
| 38 | + |
| 39 | +``` |
| 40 | +func beginOperation(completion: (OperationResult) -> Void) |
| 41 | +``` |
| 42 | + |
| 43 | +we can turn it into an `async` interface by suspending the task and using its |
| 44 | +continuation to resume it when the callback is invoked, turning the argument |
| 45 | +passed into the callback into the normal return value of the async function: |
| 46 | + |
| 47 | +``` |
| 48 | +func operation() async -> OperationResult { |
| 49 | + // Suspend the current task, and pass its continuation into a closure |
| 50 | + // that executes immediately |
| 51 | + return await withUnsafeContinuation { continuation in |
| 52 | + // Invoke the synchronous callback-based API... |
| 53 | + beginOperation(completion: { result in |
| 54 | + // ...and resume the continuation when the callback is invoked |
| 55 | + continuation.resume(returning: result) |
| 56 | + }) |
| 57 | + } |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +## Detailed design |
| 62 | + |
| 63 | +### Raw unsafe continuations |
| 64 | + |
| 65 | +The library provides two functions, `withUnsafeContinuation` and |
| 66 | +`withUnsafeThrowingContinuation`, that allow one to call into a callback-based |
| 67 | +API from inside async code. Each function takes an *operation* closure, |
| 68 | +which is expected to call into the callback-based API. The closure |
| 69 | +receives a continuation instance that must be resumed by the callback, |
| 70 | +either to provide the result value or (in the throwing variant) the thrown |
| 71 | +error that becomes the result of the `withUnsafeContinuation` call when the |
| 72 | +async task resumes: |
| 73 | + |
| 74 | + |
| 75 | +``` |
| 76 | +struct UnsafeContinuation<T> { |
| 77 | + func resume(returning: T) |
| 78 | +} |
| 79 | +
|
| 80 | +func withUnsafeContinuation<T>( |
| 81 | + _ operation: (UnsafeContinuation<T>) -> () |
| 82 | +) async -> T |
| 83 | +
|
| 84 | +struct UnsafeThrowingContinuation<T> { |
| 85 | + func resume(returning: T) |
| 86 | + func resume(throwing: Error) |
| 87 | + func resume<E: Error>(with result: Result<T, E>) |
| 88 | +} |
| 89 | +
|
| 90 | +func withUnsafeThrowingContinuation<T>( |
| 91 | + _ operation: (UnsafeThrowingContinuation<T>) -> () |
| 92 | +) async throws -> T |
| 93 | +``` |
| 94 | + |
| 95 | +The operation must follow one of the following invariants: |
| 96 | + |
| 97 | +- Either the resume function must only be called *exactly-once* on each |
| 98 | + execution path the operation may take (including any error handling paths), |
| 99 | + or else |
| 100 | +- the resume function must be called exactly at the end of the operation |
| 101 | + function's execution. |
| 102 | + |
| 103 | +`Unsafe*Continuation` is an unsafe interface, so it is undefined behavior if |
| 104 | +these invariants are not followed by the operation. This allows |
| 105 | +continuations to be a low-overhead way of interfacing with synchronous code. |
| 106 | +Wrappers can provide checking for these invariants, and the library will provide |
| 107 | +one such wrapper, discussed below. |
| 108 | + |
| 109 | +Using the `Unsafe*Continuation` API, one may for example wrap such |
| 110 | +(purposefully convoluted for the sake of demonstrating the flexibility of |
| 111 | +the continuation API) function: |
| 112 | + |
| 113 | +``` |
| 114 | +func buyVegetables( |
| 115 | + shoppingList: [String], |
| 116 | + // a) if all veggies were in store, this is invoked *exactly-once* |
| 117 | + onGotAllVegetables: ([Vegetable]) -> (), |
| 118 | +
|
| 119 | + // b) if not all veggies were in store, invoked one by one *one or more times* |
| 120 | + onGotVegetable: (Vegetable) -> (), |
| 121 | + // b) if at least one onGotVegetable was called *exactly-once* |
| 122 | + // this is invoked once no more veggies will be emitted |
| 123 | + onNoMoreVegetables: () -> (), |
| 124 | + |
| 125 | + // c) if no veggies _at all_ were available, this is invoked *exactly once* |
| 126 | + onNoVegetablesInStore: (Error) -> () |
| 127 | +) |
| 128 | +// returns 1 or more vegetables or throws an error |
| 129 | +func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] { |
| 130 | + try await withUnsafeThrowingContinuation { continuation in |
| 131 | + var veggies: [Vegetable] = [] |
| 132 | +
|
| 133 | + buyVegetables( |
| 134 | + shoppingList: shoppingList, |
| 135 | + onGotAllVegetables: { veggies in continuation.resume(returning: veggies) }, |
| 136 | + onGotVegetable: { v in veggies.append(v) }, |
| 137 | + onNoMoreVegetables: { continuation.resume(returning: veggies) }, |
| 138 | + onNoVegetablesInStore: { error in continuation.resume(throwing: error) }, |
| 139 | + ) |
| 140 | + } |
| 141 | +} |
| 142 | +
|
| 143 | +let veggies = try await buyVegetables(shoppingList: ["onion", "bell pepper"]) |
| 144 | +``` |
| 145 | + |
| 146 | +Thanks to weaving the right continuation resume calls into the complex |
| 147 | +callbacks of the `buyVegetables` function, we were able to offer a much nicer |
| 148 | +overload of this function, allowing async code to interact with this function in |
| 149 | +a more natural straight-line way. |
| 150 | + |
| 151 | +### Checked continuations |
| 152 | + |
| 153 | +`Unsafe*Continuation` provides a lightweight mechanism for interfacing |
| 154 | +sync and async code, but it is easy to misuse, and misuse can corrupt the |
| 155 | +process state in dangerous ways. In order to provide additional safety and |
| 156 | +guidance when developing interfaces between sync and async code, the |
| 157 | +library will also provide a wrapper which checks for invalid use of the |
| 158 | +continuation: |
| 159 | + |
| 160 | +``` |
| 161 | +struct CheckedContinuation<T> { |
| 162 | + func resume(returning: T) |
| 163 | +} |
| 164 | +
|
| 165 | +func withCheckedContinuation<T>( |
| 166 | + _ operation: (CheckedContinuation<T>) -> () |
| 167 | +) async -> T |
| 168 | +
|
| 169 | +struct CheckedThrowingContinuation<T> { |
| 170 | + func resume(returning: T) |
| 171 | + func resume(throwing: Error) |
| 172 | + func resume<E: Error>(with result: Result<T, E>) |
| 173 | +} |
| 174 | +
|
| 175 | +func withCheckedThrowingContinuation<T>( |
| 176 | + _ operation: (CheckedThrowingContinuation<T>) -> () |
| 177 | +) async throws -> T |
| 178 | +``` |
| 179 | + |
| 180 | +The API is intentionally identical to the `Unsafe` variants, so that code |
| 181 | +can switch easily between the checked and unchecked variants. For instance, |
| 182 | +the `buyVegetables` example above can opt into checking merely by turning |
| 183 | +its call of `withUnsafeThrowingContinuation` into one of `withCheckedThrowingContinuation`: |
| 184 | + |
| 185 | +``` |
| 186 | +// returns 1 or more vegetables or throws an error |
| 187 | +func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] { |
| 188 | + try await withCheckedThrowingContinuation { continuation in |
| 189 | + var veggies: [Vegetable] = [] |
| 190 | +
|
| 191 | + buyVegetables( |
| 192 | + shoppingList: shoppingList, |
| 193 | + onGotAllVegetables: { veggies in continuation.resume(returning: veggies) }, |
| 194 | + onGotVegetable: { v in veggies.append(v) }, |
| 195 | + onNoMoreVegetables: { continuation.resume(returning: veggies) }, |
| 196 | + onNoVegetablesInStore: { error in continuation.resume(throwing: error) }, |
| 197 | + ) |
| 198 | + } |
| 199 | +} |
| 200 | +``` |
| 201 | + |
| 202 | +Instead of leading to undefined behavior, `CheckedContinuation` will instead |
| 203 | +trap if the program attempts to resume the continuation multiple times. |
| 204 | +`CheckedContinuation` will also log a warning if the continuation |
| 205 | +is discarded without ever resuming the task, which leaves the task stuck in its |
| 206 | +suspended state, leaking any resources it holds. |
| 207 | + |
| 208 | +## Additional examples |
| 209 | + |
| 210 | +Continuations can be used to interface with more complex event-driven |
| 211 | +interfaces than callbacks as well. As long as the entirety of the process |
| 212 | +follows the requirement that the continuation be resumed exactly once, there |
| 213 | +are no other restrictions on where the continuation can be resumed. For |
| 214 | +instance, an `Operation` implementation can trigger resumption of a |
| 215 | +continuation when the operation completes: |
| 216 | + |
| 217 | +``` |
| 218 | +class MyOperation: Operation { |
| 219 | + let continuation: UnsafeContinuation<OperationResult> |
| 220 | + var result: OperationResult? |
| 221 | +
|
| 222 | + init(continuation: UnsafeContinuation<OperationResult>) { |
| 223 | + self.continuation = continuation |
| 224 | + } |
| 225 | +
|
| 226 | + /* rest of operation populates `result`... */ |
| 227 | +
|
| 228 | + override func finish() { |
| 229 | + continuation.resume(returning: result!) |
| 230 | + } |
| 231 | +} |
| 232 | +
|
| 233 | +func doOperation() async -> OperationResult { |
| 234 | + return await withUnsafeContinuation { continuation in |
| 235 | + MyOperation(continuation: continuation).start() |
| 236 | + } |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +Using APIs from the [structured concurrency proposal](https://github.com/DougGregor/swift-evolution/blob/structured-concurrency/proposals/nnnn-structured-concurrency.md), |
| 241 | +one can wrap up a `URLSession` in a task, allowing the task's cancellation |
| 242 | +to control cancellation of the session, and using a continuation to respond |
| 243 | +to data and error events fired by the network activity: |
| 244 | + |
| 245 | +``` |
| 246 | +func download(url: URL) async throws -> Data? { |
| 247 | + var urlSessionTask: URLSessionTask? |
| 248 | +
|
| 249 | + return try Task.withCancellationHandler { |
| 250 | + urlSessionTask?.cancel() |
| 251 | + } operation: { |
| 252 | + let result: Data? = try await withUnsafeThrowingContinuation { continuation in |
| 253 | + urlSessionTask = URLSession.shared.dataTask(with: url) { data, _, error in |
| 254 | + if case (let cancelled as NSURLErrorCancelled)? = error { |
| 255 | + continuation.resume(returning: nil) |
| 256 | + } else if let error = error { |
| 257 | + continuation.resume(throwing: error) |
| 258 | + } else { |
| 259 | + continuation.resume(returning: data) |
| 260 | + } |
| 261 | + } |
| 262 | + urlSessionTask?.resume() |
| 263 | + } |
| 264 | + if let result = result { |
| 265 | + return result |
| 266 | + } else { |
| 267 | + Task.cancel() |
| 268 | + return nil |
| 269 | + } |
| 270 | + } |
| 271 | +} |
| 272 | +``` |
| 273 | + |
| 274 | +It is also possible for wrappers around callback based APIs to respect their parent/current tasks's cancellation, as follows: |
| 275 | + |
| 276 | +``` |
| 277 | +func fetch(items: Int) async throws -> [Items] { |
| 278 | + let worker = ... |
| 279 | + return try Task.withCancellationHandler( |
| 280 | + handler: { worker?.cancel() } |
| 281 | + ) { |
| 282 | + return try await withUnsafeThrowingContinuation { c in |
| 283 | + worker.work( |
| 284 | + onNext: { value in c.resume(returning: value) }, |
| 285 | + onCancelled: { value in c.resume(throwing: CancellationError()) }, |
| 286 | + ) |
| 287 | + } |
| 288 | + } |
| 289 | +} |
| 290 | +``` |
| 291 | + |
| 292 | +If tasks were allowed to have instances, which is under discussion in the structured concurrency proposal, it would also be possible to obtain the task in which the fetch(items:) function was invoked and call isCanceled on it whenever the insides of the withUnsafeThrowingContinuation would deem it worthwhile to do so. |
| 293 | + |
| 294 | +## Alternatives considered |
| 295 | + |
| 296 | +### Name `CheckedContinuation` just `Continuation` |
| 297 | + |
| 298 | +We could position `CheckedContinuation` as the "default" API for doing |
| 299 | +sync/async interfacing by leaving the `Checked` word out of the name. This |
| 300 | +would certainly be in line with the general philosophy of Swift that safe |
| 301 | +interfaces are preferred, and unsafe ones used selectively where performance |
| 302 | +is an overriding concern. There are a couple of reasons to hesitate at doing |
| 303 | +this here, though: |
| 304 | + |
| 305 | +- Although the consequences of misusing `CheckedContinuation` are not as |
| 306 | + severe as `UnsafeContinuation`, it still only does a best effort at checking |
| 307 | + for some common misuse patterns, and it does not render the consequences of |
| 308 | + continuation misuse entirely moot: dropping a continuation without resuming |
| 309 | + it will still leak the un-resumed task, and attempting to resume a |
| 310 | + continuation multiple times will still cause the information passed through |
| 311 | + the continuation to be lost. It is still a serious programming error if |
| 312 | + a `with*Continuation` operation misuses the continuation; |
| 313 | + `CheckedContinuation` only helps make the error more apparent. |
| 314 | +- Naming a type `Continuation` now might take the "good" name away if, |
| 315 | + after we have move-only types at some point in the future, we want to |
| 316 | + introduce a continuation type that statically enforces the exactly-once |
| 317 | + property. |
| 318 | + |
| 319 | +### Don't expose `UnsafeContinuation` |
| 320 | + |
| 321 | +One could similarly make an argument that `UnsafeContinuation` shouldn't be |
| 322 | +exposed at all, since the `Checked` form can always be used instead. We think |
| 323 | +that being able to avoid the cost of checking when interacting with |
| 324 | +performance-sensitive APIs is valuable, once users have validated that their |
| 325 | +interfaces to those APIs are correct. |
| 326 | + |
| 327 | +### Have `CheckedContinuation` trap on all misuses, or log all misuses |
| 328 | + |
| 329 | +`CheckedContinuation` is proposed to trap when the program attempts to |
| 330 | +resume the same continuation twice, but only log a warning if a continuation |
| 331 | +is abandoned without getting resumed. We think this is the right tradeoff |
| 332 | +for these different situations for the following reasons: |
| 333 | + |
| 334 | +- With `UnsafeContinuation`, resuming multiple times corrupts the process and |
| 335 | + leaves it in an undefined state. By trapping when the task is resumed |
| 336 | + multiple times, `CheckedContinuation` turns undefined behavior into a well- |
| 337 | + defined trap situation. This is analogous to other checked/unchecked |
| 338 | + pairings in the standard library, such as `!` vs. `unsafelyUnwrapped` for |
| 339 | + `Optional`. |
| 340 | +- By contrast, failing to resume a continuation with `UnsafeContinuation` |
| 341 | + does not corrupt the task, beyond leaking the suspended task's resources; |
| 342 | + the rest of the program can continue executing normally. Furthermore, |
| 343 | + the only way we can currently detect and report such a leak is by using |
| 344 | + a class `deinit` in its implementation. The precise moment at which such |
| 345 | + a deinit would execute is not entirely predictable because of refcounting |
| 346 | + variability from ARC optimization. If `deinit` were made to trap, whether that |
| 347 | + trap is executed and when could vary with optimization level, which we |
| 348 | + don't think would lead to a good experience. |
| 349 | + |
| 350 | +### Expose more `Task` API on `*Continuation`, or allow a `Handle` to be recovered from a continuation |
| 351 | + |
| 352 | +The full `Task` and `Handle` API provides additional control over the task |
| 353 | +state to holders of the handle, particularly the ability to query and set |
| 354 | +cancellation state, as well as await the final result of the task, and one |
| 355 | +might wonder why the `*Continuation` types do not also expose this functionality. |
| 356 | +The role of a `Continuation` is very different from a `Handle`, in that a handle |
| 357 | +represents and controls the entire lifetime of the task, whereas a continuation |
| 358 | +only represents a *single suspension point* in the lifetime of the task. |
| 359 | +Furthermore, the `*Continuation` API is primarily designed to allow for |
| 360 | +interfacing with code outside of Swift's structured concurrency model, and |
| 361 | +we believe that interactions between tasks are best handled inside that model |
| 362 | +as much as possible. |
| 363 | + |
| 364 | +Note that `*Continuation` also does not strictly need direct support for any |
| 365 | +task API on itself. If, for instance, someone wants a task to cancel itself |
| 366 | +in response to a callback, they can achieve that by funneling a sentinel |
| 367 | +through the continuation's resume type, such as an Optional's `nil`: |
| 368 | + |
| 369 | +``` |
| 370 | +let callbackResult: Result? = await withUnsafeContinuation { c in |
| 371 | + someCallbackBasedAPI( |
| 372 | + completion: { c.resume($0) }, |
| 373 | + cancelation: { c.resume(nil) }) |
| 374 | +} |
| 375 | +
|
| 376 | +if let result = callbackResult { |
| 377 | + process(result) |
| 378 | +} else { |
| 379 | + cancel() |
| 380 | +} |
| 381 | +``` |
| 382 | + |
0 commit comments