Skip to content

Commit 5f79481

Browse files
Introduce a dedicated proposal for with*Continuation (#1244)
* Introduce a dedicated proposal for `with*Continuation` * Updates in response to feedback - Trap on multiple resumes, rather than merely logging - Add discussion of alternatives considered, such as trapping vs logging tradeoffs, and lack of additional handle API * Add some more interesting examples from the pitch discussion * Add an example of a callback-based API respecting its parent task's cancellation state. Provided by @ktoso * Assign number, review manager Co-authored-by: Ben Cohen <[email protected]>
1 parent ff73300 commit 5f79481

File tree

1 file changed

+382
-0
lines changed

1 file changed

+382
-0
lines changed

proposals/0300-continuation.md

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
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

Comments
 (0)