Continuation - connects asynchronous tasks and synchronous code

Posted by anups on Thu, 27 Jan 2022 22:12:44 +0100

  • Proposal: SE-0300
  • Authors: John McCall, Joe Groff, Doug Gregor, Konrad Malawski
  • Audit Supervisor: Ben Cohen
  • Status: implemented in Swift 5.5
  • Historical revision: 1, 2

introduce

Asynchronous Swift code needs to be able to be used with existing synchronous code, which uses technologies such as completion callback or delegate method to respond to events. On continuations, asynchronous tasks can suspend themselves, and synchronous code can capture and call continuations to recover tasks and respond to events.

Swift evolution key timeline:

motivation

Swift APIs often provide asynchronous code execution through callback. This may be because the code itself was written before the introduction of async/await, or because it is associated with some systems mainly composed of event drivers. In this case, it may be necessary to provide asynchronous interfaces to the program while using callback internally. Calling an asynchronous task needs to be able to suspend itself and provide an event driven synchronous system with a mechanism to recover it in response to events.

Proposed solution

The Swift library will provide an API to obtain continuation for the current asynchronous task. Obtaining the continuation of a task will suspend the task and generate a value. The synchronization code can use handle to recover the task. Finally, the given API is based on completion callback, for example:

func beginOperation(completion: (OperationResult) -> Void)

We can turn the above beginOperation(completion:) into an async interface, that is, suspend the task, use the continuation of the task to recover it when calling the callback, and turn the parameters passed into the callback into the normal return value of the asynchronous function:

func operation() async -> OperationResult {
  // Suspend the current task and pass its continuation to the closure, which will execute directly
  return await withUnsafeContinuation { continuation in
    // Call the synchronous callback based API
    beginOperation(completion: { result in
      // When the callback is executed, resume the continuation
      continuation.resume(returning: result)
    }) 
  }
}

Design details

Original unsafe continuations

Swift library provides two functions: withUnsafeContinuation and withUnsafeThrowingContinuation, both of which allow callback based APIs to be called from within asynchronous code. Each function accepts an operation closure parameter, which will be called by the callback based API. This operation closure parameter accepts a continuation instance. The continuation instance must perform the recovery operation in the callback, provide the return value or throw an error. They will become the call result of withUnsafeContinuation or withUnsafeThrowingContinuation when the asynchronous task recovers.

struct UnsafeContinuation<T, E: Error> {
  func resume(returning: T)
  func resume(throwing: E)
  func resume(with result: Result<T, E>)
}

extension UnsafeContinuation where T == Void {
  func resume() { resume(returning: ()) }
}

extension UnsafeContinuation where E == Error {
  // Allow covariant use of a `Result` with a stricter error type than
  // the continuation:
  func resume<ResultError: Error>(with result: Result<T, ResultError>)
}

func withUnsafeContinuation<T>(
    _ operation: (UnsafeContinuation<T, Never>) -> ()
) async -> T

func withUnsafeThrowingContinuation<T>(
    _ operation: (UnsafeContinuation<T, Error>) throws -> ()
) async throws -> T

In the context of the current task, withunsafe * continuation (meaning withunsafe continuation and withUnsafeThrowingContinuation, similar below) will immediately execute the closure corresponding to the operation parameter and pass in the continuation parameter value used to recover the task. The operation must be scheduled to resume at a later point. After the operation function returns, the current task has also been suspended. The current task must jump out of the pending state by calling the resume method of continuation. Note that resume will immediately return the control of the context to the caller after converting the task from the suspended state. If the executor of the task does not reschedule it, the task itself will not actually resume execution. resume(throwing:) can be used to resume a task by passing a given error. For convenience, you can use the given Result. Resume (with:) restores the task by returning normally or raising an error according to the Result status. If the operation raises an uncapped error before returning, it's like the operation calls resume(throwing:) with an error.

If the return type of withUnsafe*Continuation is Void, the value of () must be specified when calling the resume(returning:) function. This will lead to strange code (such as resume(returning: ()), so unsafe * continuation < Void > has another member function resume(), which makes the resume call more readable.

After withUnsafeContinuation is called, the resume function must be called only once in each execution path of the program. Unsafe*Continuation is an unsafe interface. Therefore, if the resume method is called multiple times on the same continuation, undefined behavior will occur. The task is suspended before resuming execution. If the continuation is cancelled and resume is never called, the task will remain suspended until the end of the program, resulting in memory leakage of all its resources. The Wrapper can provide a check for these misuse continuations, and the library will also provide such a Wrapper, as described below.

For example, using the unsafe * continuation API, you can wrap such functions (the example is deliberately written to show the flexibility of the continuation API):

func buyVegetables(
  shoppingList: [String],
  // a) if all veggies were in store, this is invoked *exactly-once*
  onGotAllVegetables: ([Vegetable]) -> (),

  // b) if not all veggies were in store, invoked one by one *one or more times*
  onGotVegetable: (Vegetable) -> (),
  // b) if at least one onGotVegetable was called *exactly-once*
  //    this is invoked once no more veggies will be emitted
  onNoMoreVegetables: () -> (),
  
  // c) if no veggies _at all_ were available, this is invoked *exactly once*
  onNoVegetablesInStore: (Error) -> ()
)
// returns 1 or more vegetables or throws an error
func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
  try await withUnsafeThrowingContinuation { continuation in
    var veggies: [Vegetable] = []

    buyVegetables(
      shoppingList: shoppingList,
      onGotAllVegetables: { veggies in continuation.resume(returning: veggies) },
      onGotVegetable: { v in veggies.append(v) },
      onNoMoreVegetables: { continuation.resume(returning: veggies) },
      onNoVegetablesInStore: { error in continuation.resume(throwing: error) },
    )
  }
}

let veggies = try await buyVegetables(shoppingList: ["onion", "bell pepper"])

By writing the correct continuation recovery operation call into the complex callback of the buyVegetables function, we can provide better overloading for the function and allow asynchronous code to interact with the function in a more natural top-down manner.

Checked continuations

Unsafe*Continuation provides a lightweight mechanism for connecting synchronous and asynchronous code, but it is easy to be misused, which will destroy the processing state in a dangerous way. In order to provide additional security and guidance during synchronous and asynchronous code development interfaces, the library will provide a wrapper to check the illegal use of continuation:

struct CheckedContinuation<T, E: Error> {
  func resume(returning: T)
  func resume(throwing: E)
  func resume(with result: Result<T, E>)
}

extension CheckedContinuation where T == Void {
  func resume()
}

extension CheckedContinuation where E == Error {
  // Allow covariant use of a `Result` with a stricter error type than
  // the continuation:
  func resume<ResultError: Error>(with result: Result<T, ResultError>)
}

func withCheckedContinuation<T>(
    _ operation: (CheckedContinuation<T, Never>) -> ()
) async -> T

func withCheckedThrowingContinuation<T>(
  _ operation: (CheckedContinuation<T, Error>) throws -> ()
) async throws -> T

The Unsafe*Continuation API is intentionally designed to be the same as Unsafe*Continuation, so that code can easily switch between checked and unchecked. For example, in the above example of buyvehicles, you can select check by changing withUnsafeThrowingContinuation to withCheckedThrowingContinuation:

// returns 1 or more vegetables or throws an error
func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
  try await withCheckedThrowingContinuation { continuation in
    var veggies: [Vegetable] = []

    buyVegetables(
      shoppingList: shoppingList,
      onGotAllVegetables: { veggies in continuation.resume(returning: veggies) },
      onGotVegetable: { v in veggies.append(v) },
      onNoMoreVegetables: { continuation.resume(returning: veggies) },
      onNoVegetablesInStore: { error in continuation.resume(throwing: error) },
    )
  }
}

If the program attempts to restore continuation multiple times, unsafe * continuation will lead to undefined behavior, and CheckedContinuation will lead to trap. CheckedContinuation will also record a warning. If the continuation fails to recover, the task will be discarded, which will cause the task to remain stuck in the suspended state, and all its resources will be leaked. These checks are performed regardless of the optimization level of the program.

Other examples

Continuations can also be used to interact with event driven interfaces, which are more complex than callback. As long as the whole process follows the requirement that continuation is correctly performed once, continuation can perform recovery operations anywhere. For example, when the Operation implements the finish Operation, the recovery Operation of continuation will be triggered:

class MyOperation: Operation {
  let continuation: UnsafeContinuation<OperationResult, Never>
  var result: OperationResult?

  init(continuation: UnsafeContinuation<OperationResult, Never>) {
    self.continuation = continuation
  }

  /* rest of operation populates `result`... */

  override func finish() {
    continuation.resume(returning: result!)
  }
}

func doOperation() async -> OperationResult {
  return await withUnsafeContinuation { continuation in
    MyOperation(continuation: continuation).start()
  }
}

The following example is from Structured concurrency proposal In, it encapsulates URLSession into tasks, allows the cancellation of tasks, controls the cancellation of sessions, and uses continuation to respond to data and error events in network activities:

func download(url: URL) async throws -> Data? {
  var urlSessionTask: URLSessionTask?

  return try Task.withCancellationHandler {
    urlSessionTask?.cancel()
  } operation: {
    let result: Data? = try await withUnsafeThrowingContinuation { continuation in
      urlSessionTask = URLSession.shared.dataTask(with: url) { data, _, error in
        if case (let cancelled as NSURLErrorCancelled)? = error {
          continuation.resume(returning: nil)
        } else if let error = error {
          continuation.resume(throwing: error)
        } else {
          continuation.resume(returning: data)
        }
      }
      urlSessionTask?.resume()
    }
    if let result = result {
      return result
    } else {
      Task.cancel()
      return nil
    }
  }
}

The wrapper based on callback API can also comply with the cancellation operation of its parent / current task, for example:

func fetch(items: Int) async throws -> [Items] {
  let worker = ... 
  return try Task.withCancellationHandler(
    handler: { worker?.cancel() }
  ) { 
    return try await withUnsafeThrowingContinuation { c in 
      worker.work(
        onNext: { value in c.resume(returning: value) },
        onCancelled: { value in c.resume(throwing: CancellationError()) },
      )
    } 
  }
}

If a task is allowed to have instances, you can obtain the task instance that calls the fetch(items:) function, and cancel the call to the task when there is an appropriate scenario within withUnsafeThrowingContinuation that can be called.

alternative

Name CheckedContinuation Continuation continuation

We can position CheckedContinuation as the "default" API for executing synchronous / asynchronous interfaces by removing the word Checked from the name. This is certainly in line with Swift's common concept, that is, the preferred secure interface. When performance is the primary consideration, you have to choose to use unsafe interfaces. However, there are two concerns that prevent us from doing so:

  • Although the consequences of misuse of CheckedContinuation are not as serious as those of misuse of UnsafeContinuation, it still tries its best to check some common misuse patterns, and does not make the consequences of continued misuse completely meaningless: discarding the continuation without recovery operation will still leak the unrecovered task; Attempting to restore the continuation for many times will still cause the loss of information transmitted to the continuation; If the with*Continuation operation misuses continuation, it is still a serious programming error. CheckedContinuation will only make the error more obvious.
  • Now naming the continuation type takes up a "good" name. If we only move the type at some time in the future, we want to introduce a continuation type that statically enforces the "just once" attribute.

Do not disclose UnsafeContinuation

It is believed that UnsafeContinuation should not be exposed because it can be replaced by the Checked form. We believe that as long as users verify that their performance sensitive APIs are correct, they can avoid the inspection cost of interacting with these APIs.

Let CheckedContinuation catch all misuse, or record all misuse

CheckedContinuation recommends that the program capture when it attempts to recover the same task twice on the same continuation, but only record the warning when it abandons the continuation without performing the recovery operation. We believe this is the right trade-off for these situations for the following reasons:

  • For CheckedContinuation, multiple recovery operations break the task process and leave it in an undefined state. By capturing when a task resumes multiple times, CheckedContinuation turns undefined behavior into a well-defined capture situation. This is similar to other checked/unchecked in the standard library, such as! And unsafe unwrapped for Optional.
  • In contrast, UnsafeContinuation fails to perform the recovery operation, which will not destroy the task except that it will leak the resources of the suspended task; The remaining tasks of the program can continue to be executed normally. Moreover, the only way to detect and report such leaks is to use the deinit method when the class is implemented. Due to the recalculation variability from ARC optimization, the exact point at which deinit is performed is not fully predictable. If the deinit method is captured, whether and when the capture is executed may vary with the optimization level, which we don't think will bring a good experience.

Expose more task APIs on * continuation, or allow Handle recovery in continuation

The Task and handle APIs provide the holder of the handle with additional control over the Task status, especially the ability to query and set the cancellation status, as well as the ability to wait for the final result of the Task. People think why the * continuation type does not expose these functions. The role of continuation is very different from that of handle. Handle represents and controls the whole Task life cycle, while continuation only represents a single hanging point in the Task life cycle. Moreover, * continuation API is mainly designed to allow communication with code outside the structured concurrency model in Swift, and the interaction between tasks should be handled within the model as much as possible.

Note that * continuation itself does not need to support any task API. For example, someone wants a task to cancel itself when responding to a callback. They can do this by inserting a sentinel in the resume type of continuation (such as optional nil):

let callbackResult: Result? = await withUnsafeContinuation { c in
  someCallbackBasedAPI(
    completion: { c.resume($0) },
    cancellation: { c.resume(nil) })
}

if let result = callbackResult {
  process(result)
} else {
  cancel()
}

Provide an API for immediate recovery tasks to avoid "queue jump"

In addition to accepting completion handler and proxy, some APIs also allow programs to control where to call completion handler and proxy. For example, some APIs on the Apple platform use parameters for the scheduling queue that should call completion handler. In these cases, if the original API can directly restore the task on the scheduling queue (regardless of the life scheduling mechanism, such as thread or run loop), this is the best scenario, and the task executor will continue to execute the task.

In order to do this, we provide a variant of with*Continuation, which not only provides continuation, but also provides the scheduling queue on which the task expects to resume execution. with*Continuation type will provide a set of unsafeResumeImmediately API s, which will immediately resume the execution of the current task on the current thread. They could be:

// Given an API that takes a queue and completion handler:
func doThingAsynchronously(queue: DispatchQueue, completion: (ResultType) -> Void)

// We could wrap it in a Swift async function like:
func doThing() async -> ResultType {
  await withUnsafeContinuationAndCurrentDispatchQueue { c, queue in
    // Schedule to resume on the right queue, if we know it
    doThingAsynchronously(queue: queue) {
      c.unsafeResumeImmediately(returning: $0)
    }
  }
}

This API must be used very carefully. Programmers should also be careful to check whether unsafeResumeImmediately is invoked in the correct context, and it is safe to take control of the current thread from the caller within a period of unlimited time. If the task is executed in the wrong context, it will destroy all the assumptions made by the current existing code, compiler and runtime, and eventually lead to errors, which are difficult to debug. If the "queue jump" based on the continuation adapter is found to be a performance problem in practice, we can study it as a supplement to the core proposal.

Modify record

Third modification:

  • Replace the separate * continuation < T > and * throwingcontinuation < T > types with a single continuation < T, e: Error > type, which has an Error type.
  • Add a resume() method for continuation, which is equivalent to the resume(returning: ()) method, and the return value is of Void type.
  • with*ThrowingContinuation adds an operationblock, which may throw exceptions. If an uncapped error is sent out from the operation, the block will immediately resume the execution of the task that threw the error.

Second modification:

  • Describe clearly with*Continuation and * continuation The execution behavior of resume, that is, before suspending the task, with*Continuation will immediately execute its operation parameters in the current context. After canceling the suspension of the task, the corresponding resume will immediately return to its caller, and the task will be scheduled by its executor.
  • Deleted an invariant that is unnecessary when resume must be called; At any time point after the execution of the with*Continuation operation, resume can only be called effectively once; When the with*Continuation operation returns, you do not need to call resume exactly.
  • Add the "future direction" section to discuss a possible higher-level API that allows continuations to resume their tasks directly when they know the correct scheduling queue.
  • Add resume() on the returned Continuation type.

Topics: Swift iOS