Skip to content

Commit ff506d4

Browse files
committed
Introduce a dedicated proposal for with*Continuation
1 parent 1823e8c commit ff506d4

File tree

1 file changed

+233
-0
lines changed

1 file changed

+233
-0
lines changed

proposals/ABCD-continuation.md

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# Continuations for interfacing async tasks with synchronous code
2+
3+
* Proposal: [SE-ABCD](ABCD-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: TBD
6+
* Status: **Awaiting review**
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+
20+
## Motivation
21+
22+
Swift APIs often provide asynchronous code execution by way of a callback. This
23+
may occur either because the code itself was written prior to the introduction
24+
of async/await, or (more interestingly in the long term) because it ties in
25+
with some other system that is primarily event-driven. In such cases, one may
26+
want to provide an async interface to clients while using callbacks internally.
27+
In these cases, the calling async task needs to be able to suspend itself,
28+
while providing a mechanism for the event-driven synchronous system to resume
29+
it in response to an event.
30+
31+
## Proposed solution
32+
33+
The library will provide APIs to get a **continuation** for the current
34+
asynchronous task. Getting the task's continuation suspends the task, and
35+
produces a value that synchronous code can then use a handle to resume the
36+
task. Given a completion callback based API like:
37+
38+
```
39+
func beginOperation(completion: (OperationResult) -> Void)
40+
```
41+
42+
we can turn it into an `async` interface by suspending the task and using its
43+
continuation to resume it when the callback is invoked, turning the argument
44+
passed into the callback into the normal return value of the async function:
45+
46+
```
47+
func operation() async -> OperationResult {
48+
// Suspend the current task, and pass its continuation into a closure
49+
// that executes immediately
50+
return await withUnsafeContinuation { continuation in
51+
// Invoke the synchronous callback-based API...
52+
beginOperation(completion: { result in
53+
// ...and resume the continuation when the callback is invoked
54+
continuation.resume(returning: result)
55+
})
56+
}
57+
}
58+
```
59+
60+
## Detailed design
61+
62+
### Raw unsafe continuations
63+
64+
The library provides two functions, `withUnsafeContinuation` and
65+
`withUnsafeThrowingContinuation`, that allow one to call into a callback-based
66+
API from inside async code. Each function takes an *operation* closure,
67+
which is expected to call into the callback-based API. The closure
68+
receives a continuation instance that must be resumed by the callback,
69+
either to provide the result value or (in the throwing variant) the thrown
70+
error that becomes the result of the `withUnsafeContinuation` call when the
71+
async task resumes:
72+
73+
74+
```
75+
struct UnsafeContinuation<T> {
76+
func resume(returning: T)
77+
}
78+
79+
func withUnsafeContinuation<T>(
80+
_ operation: (UnsafeContinuation<T>) -> ()
81+
) async -> T
82+
83+
struct UnsafeThrowingContinuation<T> {
84+
func resume(returning: T)
85+
func resume(throwing: Error)
86+
func resume<E: Error>(with result: Result<T, E>)
87+
}
88+
89+
func withUnsafeThrowingContinuation<T>(
90+
_ operation: (UnsafeThrowingContinuation<T>) -> ()
91+
) async throws -> T
92+
```
93+
94+
The operation must follow one of the following invariants:
95+
96+
- Either the resume function must only be called *exactly-once* on each
97+
execution path the operation may take (including any error handling paths),
98+
or else
99+
- the resume function must be called exactly at the end of the operation
100+
function's execution.
101+
102+
`Unsafe*Continuation` is an unsafe interface, so it is undefined behavior if
103+
these invariants are not followed by the operation. This allows
104+
continuations to be a low-overhead way of interfacing with synchronous code.
105+
Wrappers can provide checking for these invariants, and the library will provide
106+
one such wrapper, discussed below.
107+
108+
Using the `Unsafe*Continuation` API, one may for example wrap such
109+
(purposefully convoluted for the sake of demonstrating the flexibility of
110+
the continuation API) function:
111+
112+
```
113+
func buyVegetables(
114+
shoppingList: [String],
115+
// a) if all veggies were in store, this is invoked *exactly-once*
116+
onGotAllVegetables: ([Vegetable]) -> (),
117+
118+
// b) if not all veggies were in store, invoked one by one *one or more times*
119+
onGotVegetable: (Vegetable) -> (),
120+
// b) if at least one onGotVegetable was called *exactly-once*
121+
// this is invoked once no more veggies will be emitted
122+
onNoMoreVegetables: () -> (),
123+
124+
// c) if no veggies _at all_ were available, this is invoked *exactly once*
125+
onNoVegetablesInStore: (Error) -> ()
126+
)
127+
// returns 1 or more vegetables or throws an error
128+
func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
129+
try await withUnsafeThrowingContinuation { continuation in
130+
var veggies: [Vegetable] = []
131+
132+
buyVegetables(
133+
shoppingList: shoppingList,
134+
onGotAllVegetables: { veggies in continuation.resume(returning: veggies) },
135+
onGotVegetable: { v in veggies.append(v) },
136+
onNoMoreVegetables: { continuation.resume(returning: veggies) },
137+
onNoVegetablesInStore: { error in continuation.resume(throwing: error) },
138+
)
139+
}
140+
}
141+
142+
let veggies = try await buyVegetables(shoppingList: ["onion", "bell pepper"])
143+
```
144+
145+
Thanks to weaving the right continuation resume calls into the complex
146+
callbacks of the `buyVegetables` function, we were able to offer a much nicer
147+
overload of this function, allowing async code to interact with this function in
148+
a more natural straight-line way.
149+
150+
### Checked continuations
151+
152+
`Unsafe*Continuation` provides a lightweight mechanism for interfacing
153+
sync and async code, but it is easy to misuse, and misuse can corrupt the
154+
process state in dangerous ways. In order to provide additional safety and
155+
guidance when developing interfaces between sync and async code, the
156+
library will also provide a wrapper which checks for invalid use of the
157+
continuation:
158+
159+
```
160+
struct CheckedContinuation<T> {
161+
func resume(returning: T)
162+
}
163+
164+
func withCheckedContinuation<T>(
165+
_ operation: (CheckedContinuation<T>) -> ()
166+
) async -> T
167+
168+
struct CheckedThrowingContinuation<T> {
169+
func resume(returning: T)
170+
func resume(throwing: Error)
171+
func resume<E: Error>(with result: Result<T, E>)
172+
}
173+
174+
func withCheckedThrowingContinuation<T>(
175+
_ operation: (CheckedThrowingContinuation<T>) -> ()
176+
) async throws -> T
177+
```
178+
179+
The API is intentionally identical to the `Unsafe` variants, so that code
180+
can switch easily between the checked and unchecked variants. For instance,
181+
the `buyVegetables` example above can opt into checking merely by turning
182+
its call of `withUnsafeThrowingContinuation` into one of `withCheckedThrowingContinuation`:
183+
184+
```
185+
// returns 1 or more vegetables or throws an error
186+
func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
187+
try await withCheckedThrowingContinuation { continuation in
188+
var veggies: [Vegetable] = []
189+
190+
buyVegetables(
191+
shoppingList: shoppingList,
192+
onGotAllVegetables: { veggies in continuation.resume(returning: veggies) },
193+
onGotVegetable: { v in veggies.append(v) },
194+
onNoMoreVegetables: { continuation.resume(returning: veggies) },
195+
onNoVegetablesInStore: { error in continuation.resume(throwing: error) },
196+
)
197+
}
198+
}
199+
```
200+
201+
Instead of leading to undefined behavior, `CheckedContinuation` will instead
202+
ignore attempts to resume the continuation multiple times, logging a warning
203+
message. `CheckedContinuation` will also log a warning if the continuation
204+
is discarded without ever resuming the task, which leaves the task stuck in its
205+
suspended state, leaking any resources it holds.
206+
207+
## Alternatives considered
208+
209+
### Name `CheckedContinuation` just `Continuation`
210+
211+
We could position `CheckedContinuation` as the "default" API for doing
212+
sync/async interfacing by leaving the `Checked` word out of the name. This
213+
would certainly be in line with the general philosophy of Swift that safe
214+
interfaces are preferred, and unsafe ones used selectively where performance
215+
is an overriding concern. There are a couple of reasons to hesitate at doing
216+
this here, though:
217+
218+
- Although the consequences of misusing `CheckedContinuation` are not as
219+
severe as `UnsafeContinuation`, it still only does a best effort at checking
220+
for some common misuse patterns, and it does not render the consequences of
221+
continuation misuse entirely moot: dropping a continuation without resuming
222+
it will still leak the un-resumed task, and attempting to resume a
223+
continuation multiple times will still cause the information passed through
224+
the continuation to be lost. It is still a serious programming error if
225+
a `with*Continuation` operation misuses the continuation;
226+
`CheckedContinuation` only helps make the error more apparent.
227+
- Naming a type `Continuation` now might take the "good" name away if,
228+
after we have move-only types at some point in the future, we want to
229+
introduce a continuation type that statically enforces the exactly-once
230+
property.
231+
232+
233+
### Don't expose `UnsafeContinuation`

0 commit comments

Comments
 (0)