Skip to content

Commit febbf37

Browse files
committed
Remove always-task-return, clear may_leave during post-return, expand Async.md
1 parent 8ededcb commit febbf37

5 files changed

Lines changed: 67 additions & 72 deletions

File tree

design/mvp/Async.md

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,10 @@ by allowing components to import and export "async" functions which abstract
6767
over, and can be implemented by, idiomatic concurrency in a variety of
6868
programming languages:
6969
* `async` functions in languages like C#, JS, Python, Rust and Swift
70-
(implemented using [`callback` functions](#waiting))
7170
* stackful coroutines in languages like Kotlin, Perl, PHP and (recently) C++
7271
* green threads as-if running on a single OS thread in languages like Go and
7372
(initially and recently again) Java
7473
* callbacks, in languages with no explicit async support
75-
(also implemented using [`callback` functions](#waiting))
7674

7775
The Component Model supports this wide variety of language features by
7876
specifying a common low-level "async" ABI which the different languages'
@@ -84,10 +82,9 @@ Model "just another OS" from the language toolchains' perspective).
8482

8583
Moreover, this async ABI does not require components to use preemptive
8684
multi-threading ([`thread.spawn*`]) in order to achieve concurrency. Instead,
87-
concurrency can be achieved by cooperatively switching between different logical
88-
tasks running on a single thread. This switching may require the use of [fibers]
89-
or a [CPS transform], but may also be avoided entirely when a component's
90-
producer toolchain is engineered to always return to an [event loop].
85+
concurrency can be achieved by cooperatively switching between different
86+
logical tasks running on a single thread using [fibers] or a [CPS transform] in
87+
the wasm runtime as necessary.
9188

9289
To avoid partitioning the world along sync/async lines as mentioned in the
9390
Goals section, the Component Model allows *every* component-level function type
@@ -98,6 +95,23 @@ well-defined behavior. Specifically, the caller and callee can independently
9895
specify `async` as an immediate flags on the [lift and lower definitions] used
9996
to define their imports and exports.
10097

98+
To provide wasm runtimes with additional optimization opportunities for
99+
languages with "stackless" concurrency (e.g. languages using `async`/`await`),
100+
two ABI sub-options are provided for implementing `async` functions: a
101+
"stackless" ABI selected by providing a `callback` function immediate and a
102+
"stackful" ABI selected by *not* providing a `callback` function. The stackless
103+
`callback` ABI allows core wasm to repeatedly return to an [event loop] to
104+
receive events concerning a selected set of "waitables", thereby clearing the
105+
native stack when waiting for events and allowing the runtime to reuse stack
106+
segments between events. In the [future](#TODO), a `strict-callback` option may
107+
be added that forces (via runtime traps) *all* waiting to happen by returning
108+
to the event loop, thereby allowing engines to always avoid creating [fibers]
109+
when invoking an `async strict-callback` export. In contrast, to support
110+
complex applications with mixed dependencies and concurrency models, the
111+
`callback` immediate allows *both* returning to the event loop *and* making
112+
blocking calls to wait for events, with the latter case requiring engines to
113+
pessimistically create fibers in some composition scenarios.
114+
101115
To propagate backpressure, it's necessary for a component to be able to say
102116
"there are too many async export calls already in progress, don't start any
103117
more until I let some of them complete". Thus, the low-level async ABI provides
@@ -280,7 +294,7 @@ components uphold their end of the ABI contract. But when the host calls into
280294
a component, there is only a `Task` and, symmetrically, when a component calls
281295
into the host, there is only a `Subtask`.
282296

283-
Based on this, the call stack when a component calls a host-defined import will
297+
Based on this, the call stack when a component calls a host-defined import will
284298
have the general form:
285299
```
286300
[Host]
@@ -421,12 +435,11 @@ The Canonical ABI provides two ways for a task to wait on a waitable set:
421435
the waitable set as a return value to the event loop, which will block and
422436
then pass the event that occurred as a parameter to the `callback`.
423437

424-
While the two approaches have significant runtime implementation differences
425-
(the former requires [fibers] or a [CPS transform] while the latter only
426-
requires storing fixed-size context-local storage and [`Task`] state),
438+
While the two approaches have significant runtime implementation differences,
427439
semantically they do the same thing which, in the Canonical ABI Python code, is
428-
factored out into the [`Task.wait_on`] method. Thus, the difference between
429-
`callback` and non-`callback` is one of optimization, not expressivity.
440+
factored out into the [`Task.wait_for_event`] method. Thus, the difference between
441+
`callback` and non-`callback` is one of optimization (as described
442+
[above](#high-level-approach)), not expressivity.
430443

431444
In addition to waiting for an event to occur, a task can also **poll** for
432445
whether an event has already occurred. Polling does not block, but does allow
@@ -469,10 +482,7 @@ the "started" state.
469482
### Returning
470483

471484
The way an async function returns its value is by calling [`task.return`],
472-
passing the core values that are to be lifted as *parameters*. Additionally,
473-
when the `always-task-return` `canonopt` is set, synchronous functions also
474-
return their values by calling `task.return` (as a more expressive and
475-
general alternative to `post-return`).
485+
passing the core values that are to be lifted as *parameters*.
476486

477487
Returning values by calling `task.return` allows a task to continue executing
478488
even after it has passed its initial results to the caller. This can be useful
@@ -1090,6 +1100,9 @@ comes after:
10901100
* `recursive` function type attribute: allow a function to opt in to
10911101
recursive [reentrance], extending the ABI to link the inner and
10921102
outer activations
1103+
* add a `strict-callback` option that adds extra trapping conditions to
1104+
provide the semantic guarantees needed for engines to statically avoid
1105+
fiber creation at component-to-component `async` call boundaries
10931106
* add `stringstream` specialization of `stream<char>` (just like `string` is
10941107
a specialization of `list<char>`)
10951108
* allow pipelining multiple `stream.read`/`write` calls
@@ -1140,7 +1153,7 @@ comes after:
11401153
[`canon_subtask_cancel`]: CanonicalABI.md#-canon-subtaskcancel
11411154
[`Task`]: CanonicalABI.md#task-state
11421155
[`Task.enter`]: CanonicalABI.md#task-state
1143-
[`Task.wait_on`]: CanonicalABI.md#task-state
1156+
[`Task.wait_for_event`]: CanonicalABI.md#task-state
11441157
[`Waitable`]: CanonicalABI.md#waitable-state
11451158
[`TASK_CANCELLED`]: CanonicalABI.md#waitable-state
11461159
[`Task`]: CanonicalABI.md#task-state

design/mvp/Binary.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,6 @@ canonopt ::= 0x00 => string-encod
333333
| 0x05 f:<core:funcidx> => (post-return f)
334334
| 0x06 => async 🔀
335335
| 0x07 f:<core:funcidx> => (callback f) 🔀
336-
| 0x08 => always-task-return 🔀
337336
```
338337
Notes:
339338
* The second `0x00` byte in `canon` stands for the `func` sort and thus the

design/mvp/CanonicalABI.md

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ class CanonicalOptions(LiftLowerOptions):
167167
post_return: Optional[Callable] = None
168168
sync: bool = True # = !canonopt.async
169169
callback: Optional[Callable] = None
170-
always_task_return: bool = False
171170
```
172171
(Note that the `async` `canonopt` is inverted to `sync` here for the practical
173172
reason that `async` is a keyword and most branches below want to start with the
@@ -3093,27 +3092,30 @@ Each call to `canon lift` creates a new `Task` and waits to enter the component
30933092
instance, allowing the component instance to express backpressure before
30943093
lowering the arguments into the callee's memory.
30953094

3096-
In the synchronous case, if `always-task-return` ABI option is set, the lifted
3097-
core wasm code must call `canon_task_return` to return a value before returning
3098-
to `canon_lift` (or else there will be a trap in `Task.exit`), which allows the
3099-
core wasm to do cleanup and finalization before returning. Otherwise, if
3100-
`always-task-return` is *not* set, `canon_lift` will implicitly call
3101-
`canon_task_return` when core wasm returns and then make a second call into the
3102-
`post-return` function to let core wasm do cleanup and finalization. In the
3103-
future, `post-return` and the option to not set `always-task-return` may be
3104-
deprecated and removed.
3095+
In the synchronous case, `canon_lift` first calls into the lifted core
3096+
function, passing the lowered core flat parameters and receiving the core flat
3097+
results to be lifted. Once the core results are lifted, `canon_lift` optionally
3098+
makes a second call into any supplied `post-return` function, passing the flat
3099+
results as arguments so that the guest code and free any allocations associated
3100+
with compound return values.
31053101
```python
31063102
if opts.sync:
31073103
flat_results = await call_and_trap_on_throw(callee, task, flat_args)
3108-
if not opts.always_task_return:
3109-
assert(types_match_values(flat_ft.results, flat_results))
3110-
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
3111-
task.return_(results)
3112-
if opts.post_return is not None:
3113-
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
3104+
assert(types_match_values(flat_ft.results, flat_results))
3105+
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
3106+
task.return_(results)
3107+
if opts.post_return is not None:
3108+
task.inst.may_leave = False
3109+
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
3110+
task.inst.may_leave = True
31143111
task.exit()
31153112
return
31163113
```
3114+
By clearing `may_leave` for the duration of the `post-return` call, the
3115+
Canonical ABI ensures that synchronously-lowered calls to synchronously-lifted
3116+
functions can always be implemented by a plain synchronous function call
3117+
without the need for fibers which would otherwise be necessary if the
3118+
`post-return` function performed a blocking operation.
31173119

31183120
In both of the asynchronous cases below (`callback` and non-`callback`),
31193121
`canon_task_return` must be called (as checked by `Task.exit`).
@@ -3476,14 +3478,18 @@ wasm state and passes them to the caller via `Task.return_`:
34763478
```python
34773479
async def canon_task_return(task, result_type, opts: LiftOptions, flat_args):
34783480
trap_if(not task.inst.may_leave)
3479-
trap_if(task.opts.sync and not task.opts.always_task_return)
3481+
trap_if(task.opts.sync)
34803482
trap_if(result_type != task.ft.results)
34813483
trap_if(not LiftOptions.equal(opts, task.opts))
34823484
cx = LiftLowerContext(opts, task.inst, task)
34833485
results = lift_flat_values(cx, MAX_FLAT_PARAMS, CoreValueIter(flat_args), task.ft.result_types())
34843486
task.return_(results)
34853487
return []
34863488
```
3489+
The `trap_if(task.opts.sync)` prevents `task.return` from being called by
3490+
synchronously-lifted functions (which return their value by returning from the
3491+
lifted core function).
3492+
34873493
The `trap_if(result_type != task.ft.results)` guard ensures that, in a
34883494
component with multiple exported functions of different types, `task.return` is
34893495
not called with a mismatched result type (which, due to indirect control flow,
@@ -3518,10 +3524,14 @@ current task have already been dropped (and trapping in `Task.cancel` if not).
35183524
```python
35193525
async def canon_task_cancel(task):
35203526
trap_if(not task.inst.may_leave)
3521-
trap_if(task.opts.sync and not task.opts.always_task_return)
3527+
trap_if(task.opts.sync)
35223528
task.cancel()
35233529
return []
35243530
```
3531+
The `trap_if(task.opts.sync)` prevents `task.cancel` from being called by
3532+
synchronously-lifted functions (which must always return a value by returning
3533+
from the lifted core function).
3534+
35253535
`Task.cancel` also traps if there has been no cancellation request (in which
35263536
case the callee expects to receive a return value) or if the task has already
35273537
returned a value or already called `task.cancel`.
@@ -3541,7 +3551,6 @@ Calling `$f` calls `Task.yield_` to allow other tasks to execute:
35413551
```python
35423552
async def canon_yield(sync, task):
35433553
trap_if(not task.inst.may_leave)
3544-
trap_if(task.opts.callback and not sync)
35453554
event_code,_,_ = await task.yield_(sync)
35463555
match event_code:
35473556
case EventCode.NONE:
@@ -3559,11 +3568,6 @@ Because other tasks can execute, a subtask can be cancelled while executing
35593568
generators should handle cancellation the same way as when receiving the
35603569
`TASK_CANCELLED` event from `waitable-set.wait`.
35613570

3562-
The guard preventing `async` use of `task.poll` when a `callback` has
3563-
been used preserves the invariant that producer toolchains using
3564-
`callback` never need to handle multiple overlapping callback
3565-
activations.
3566-
35673571

35683572
### 🔀 `canon waitable-set.new`
35693573

@@ -3599,7 +3603,6 @@ returning its `EventCode` and writing the payload values into linear memory:
35993603
```python
36003604
async def canon_waitable_set_wait(sync, mem, task, si, ptr):
36013605
trap_if(not task.inst.may_leave)
3602-
trap_if(task.opts.callback and not sync)
36033606
s = task.inst.table.get(si)
36043607
trap_if(not isinstance(s, WaitableSet))
36053608
e = await task.wait_for_event(s, sync)
@@ -3623,10 +3626,6 @@ though, the automatic backpressure (applied by `Task.enter`) will ensure there
36233626
is only ever at most once synchronously-lifted task executing in a component
36243627
instance at a time.
36253628

3626-
The guard preventing `async` use of `wait` when a `callback` has been used
3627-
preserves the invariant that producer toolchains using `callback` never need to
3628-
handle multiple overlapping callback activations.
3629-
36303629

36313630
### 🔀 `canon waitable-set.poll`
36323631

@@ -3644,7 +3643,6 @@ same way as `wait`.
36443643
```python
36453644
async def canon_waitable_set_poll(sync, mem, task, si, ptr):
36463645
trap_if(not task.inst.may_leave)
3647-
trap_if(task.opts.callback and not sync)
36483646
s = task.inst.table.get(si)
36493647
trap_if(not isinstance(s, WaitableSet))
36503648
e = await task.poll_for_event(s, sync)
@@ -3653,10 +3651,6 @@ async def canon_waitable_set_poll(sync, mem, task, si, ptr):
36533651
When `async` is set, `poll_for_event` can yield to other tasks (in this or other
36543652
components) as part of polling for an event.
36553653

3656-
The guard preventing `async` use of `poll_for_event` when a `callback` has been
3657-
used preserves the invariant that producer toolchains using `callback` never
3658-
need to handle multiple overlapping callback activations.
3659-
36603654

36613655
### 🔀 `canon waitable-set.drop`
36623656

design/mvp/Explainer.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,6 @@ canonopt ::= string-encoding=utf8
12681268
| (post-return <core:funcidx>)
12691269
| async 🔀
12701270
| (callback <core:funcidx>) 🔀
1271-
| always-task-return 🔀
12721271
```
12731272
While the production `externdesc` accepts any `sort`, the validation rules
12741273
for `canon lift` would only allow the `func` sort. In the future, other sorts
@@ -1326,13 +1325,6 @@ validated to have the following core function type:
13261325
```
13271326
Again, see the [async explainer] for more details.
13281327

1329-
🔀 The `always-task-return` option may only be present in `canon lift` when
1330-
`post-return` is not set and specifies that even synchronously-lifted functions
1331-
will call `canon task.return` to return their results instead of returning
1332-
them as core function results. This is a simpler alternative to `post-return`
1333-
for freeing memory after lifting and thus `post-return` may be deprecated in
1334-
the future.
1335-
13361328
Based on this description of the AST, the [Canonical ABI explainer] gives a
13371329
detailed walkthrough of the static and dynamic semantics of `lift` and `lower`.
13381330

design/mvp/canonical-abi/definitions.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,6 @@ class CanonicalOptions(LiftLowerOptions):
211211
post_return: Optional[Callable] = None
212212
sync: bool = True # = !canonopt.async
213213
callback: Optional[Callable] = None
214-
always_task_return: bool = False
215214

216215
### Runtime State
217216

@@ -1928,12 +1927,13 @@ async def canon_lift(opts, inst, ft, callee, caller, on_start, on_resolve, on_bl
19281927

19291928
if opts.sync:
19301929
flat_results = await call_and_trap_on_throw(callee, task, flat_args)
1931-
if not opts.always_task_return:
1932-
assert(types_match_values(flat_ft.results, flat_results))
1933-
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
1934-
task.return_(results)
1935-
if opts.post_return is not None:
1936-
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
1930+
assert(types_match_values(flat_ft.results, flat_results))
1931+
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
1932+
task.return_(results)
1933+
if opts.post_return is not None:
1934+
task.inst.may_leave = False
1935+
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
1936+
task.inst.may_leave = True
19371937
task.exit()
19381938
return
19391939

@@ -2106,7 +2106,7 @@ async def canon_backpressure_set(task, flat_args):
21062106

21072107
async def canon_task_return(task, result_type, opts: LiftOptions, flat_args):
21082108
trap_if(not task.inst.may_leave)
2109-
trap_if(task.opts.sync and not task.opts.always_task_return)
2109+
trap_if(task.opts.sync)
21102110
trap_if(result_type != task.ft.results)
21112111
trap_if(not LiftOptions.equal(opts, task.opts))
21122112
cx = LiftLowerContext(opts, task.inst, task)
@@ -2118,15 +2118,14 @@ async def canon_task_return(task, result_type, opts: LiftOptions, flat_args):
21182118

21192119
async def canon_task_cancel(task):
21202120
trap_if(not task.inst.may_leave)
2121-
trap_if(task.opts.sync and not task.opts.always_task_return)
2121+
trap_if(task.opts.sync)
21222122
task.cancel()
21232123
return []
21242124

21252125
### 🔀 `canon yield`
21262126

21272127
async def canon_yield(sync, task):
21282128
trap_if(not task.inst.may_leave)
2129-
trap_if(task.opts.callback and not sync)
21302129
event_code,_,_ = await task.yield_(sync)
21312130
match event_code:
21322131
case EventCode.NONE:
@@ -2144,7 +2143,6 @@ async def canon_waitable_set_new(task):
21442143

21452144
async def canon_waitable_set_wait(sync, mem, task, si, ptr):
21462145
trap_if(not task.inst.may_leave)
2147-
trap_if(task.opts.callback and not sync)
21482146
s = task.inst.table.get(si)
21492147
trap_if(not isinstance(s, WaitableSet))
21502148
e = await task.wait_for_event(s, sync)
@@ -2161,7 +2159,6 @@ def unpack_event(mem, task, ptr, e: EventTuple):
21612159

21622160
async def canon_waitable_set_poll(sync, mem, task, si, ptr):
21632161
trap_if(not task.inst.may_leave)
2164-
trap_if(task.opts.callback and not sync)
21652162
s = task.inst.table.get(si)
21662163
trap_if(not isinstance(s, WaitableSet))
21672164
e = await task.poll_for_event(s, sync)

0 commit comments

Comments
 (0)