Skip to content

Commit dd99837

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

File tree

1 file changed

+211
-0
lines changed

1 file changed

+211
-0
lines changed

proposals/ABCD-continuation.md

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

0 commit comments

Comments
 (0)