Skip to content

Commit f1c7c30

Browse files
authored
Remove always-task-return, clear may_leave during post-return, expand Async.md (#531)
1 parent 80dde01 commit f1c7c30

File tree

5 files changed

+66
-72
lines changed

5 files changed

+66
-72
lines changed

design/mvp/Async.md

Lines changed: 29 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,22 @@ 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 `async` ABI sub-options are provided: a "stackless" ABI selected by
101+
providing a `callback` function and a "stackful" ABI selected by *not*
102+
providing a `callback` function. The stackless ABI allows core wasm to
103+
repeatedly return to an [event loop] to receive events concerning a selected
104+
set of "waitables", thereby clearing the native stack when waiting for events
105+
and allowing the runtime to reuse stack segments between events. In the
106+
[future](#TODO), a `strict-callback` option may be added to require (via
107+
runtime traps) *all* waiting to happen via the event loop, thereby giving the
108+
engine more up-front information that the engine can use to avoid allocating
109+
[fibers] in more cases. In the meantime, to support complex applications with
110+
mixed dependencies and concurrency models, the `callback` immediate allows
111+
*both* returning to the event loop *and* making blocking calls to wait for
112+
event.
113+
101114
To propagate backpressure, it's necessary for a component to be able to say
102115
"there are too many async export calls already in progress, don't start any
103116
more until I let some of them complete". Thus, the low-level async ABI provides
@@ -280,7 +293,7 @@ components uphold their end of the ABI contract. But when the host calls into
280293
a component, there is only a `Task` and, symmetrically, when a component calls
281294
into the host, there is only a `Subtask`.
282295

283-
Based on this, the call stack when a component calls a host-defined import will
296+
Based on this, the call stack when a component calls a host-defined import will
284297
have the general form:
285298
```
286299
[Host]
@@ -421,12 +434,11 @@ The Canonical ABI provides two ways for a task to wait on a waitable set:
421434
the waitable set as a return value to the event loop, which will block and
422435
then pass the event that occurred as a parameter to the `callback`.
423436

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),
437+
While the two approaches have significant runtime implementation differences,
427438
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.
439+
factored out into the [`Task.wait_for_event`] method. Thus, the difference between
440+
`callback` and non-`callback` is one of optimization (as described
441+
[above](#high-level-approach)), not expressivity.
430442

431443
In addition to waiting for an event to occur, a task can also **poll** for
432444
whether an event has already occurred. Polling does not block, but does allow
@@ -469,10 +481,7 @@ the "started" state.
469481
### Returning
470482

471483
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`).
484+
passing the core values that are to be lifted as *parameters*.
476485

477486
Returning values by calling `task.return` allows a task to continue executing
478487
even after it has passed its initial results to the caller. This can be useful
@@ -1090,6 +1099,9 @@ comes after:
10901099
* `recursive` function type attribute: allow a function to opt in to
10911100
recursive [reentrance], extending the ABI to link the inner and
10921101
outer activations
1102+
* add a `strict-callback` option that adds extra trapping conditions to
1103+
provide the semantic guarantees needed for engines to statically avoid
1104+
fiber creation at component-to-component `async` call boundaries
10931105
* add `stringstream` specialization of `stream<char>` (just like `string` is
10941106
a specialization of `list<char>`)
10951107
* allow pipelining multiple `stream.read`/`write` calls
@@ -1140,7 +1152,7 @@ comes after:
11401152
[`canon_subtask_cancel`]: CanonicalABI.md#-canon-subtaskcancel
11411153
[`Task`]: CanonicalABI.md#task-state
11421154
[`Task.enter`]: CanonicalABI.md#task-state
1143-
[`Task.wait_on`]: CanonicalABI.md#task-state
1155+
[`Task.wait_for_event`]: CanonicalABI.md#task-state
11441156
[`Waitable`]: CanonicalABI.md#waitable-state
11451157
[`TASK_CANCELLED`]: CanonicalABI.md#waitable-state
11461158
[`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
@@ -166,7 +166,6 @@ class CanonicalOptions(LiftLowerOptions):
166166
post_return: Optional[Callable] = None
167167
sync: bool = True # = !canonopt.async
168168
callback: Optional[Callable] = None
169-
always_task_return: bool = False
170169
```
171170
(Note that the `async` `canonopt` is inverted to `sync` here for the practical
172171
reason that `async` is a keyword and most branches below want to start with the
@@ -3003,27 +3002,30 @@ Each call to `canon lift` creates a new `Task` and waits to enter the component
30033002
instance, allowing the component instance to express backpressure before
30043003
lowering the arguments into the callee's memory.
30053004

3006-
In the synchronous case, if `always-task-return` ABI option is set, the lifted
3007-
core wasm code must call `canon_task_return` to return a value before returning
3008-
to `canon_lift` (or else there will be a trap in `Task.exit`), which allows the
3009-
core wasm to do cleanup and finalization before returning. Otherwise, if
3010-
`always-task-return` is *not* set, `canon_lift` will implicitly call
3011-
`canon_task_return` when core wasm returns and then make a second call into the
3012-
`post-return` function to let core wasm do cleanup and finalization. In the
3013-
future, `post-return` and the option to not set `always-task-return` may be
3014-
deprecated and removed.
3005+
In the synchronous case, `canon_lift` first calls into the lifted core
3006+
function, passing the lowered core flat parameters and receiving the core flat
3007+
results to be lifted. Once the core results are lifted, `canon_lift` optionally
3008+
makes a second call into any supplied `post-return` function, passing the flat
3009+
results as arguments so that the guest code and free any allocations associated
3010+
with compound return values.
30153011
```python
30163012
if opts.sync:
30173013
flat_results = await call_and_trap_on_throw(callee, task, flat_args)
3018-
if not opts.always_task_return:
3019-
assert(types_match_values(flat_ft.results, flat_results))
3020-
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
3021-
task.return_(results)
3022-
if opts.post_return is not None:
3023-
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
3014+
assert(types_match_values(flat_ft.results, flat_results))
3015+
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
3016+
task.return_(results)
3017+
if opts.post_return is not None:
3018+
task.inst.may_leave = False
3019+
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
3020+
task.inst.may_leave = True
30243021
task.exit()
30253022
return
30263023
```
3024+
By clearing `may_leave` for the duration of the `post-return` call, the
3025+
Canonical ABI ensures that synchronously-lowered calls to synchronously-lifted
3026+
functions can always be implemented by a plain synchronous function call
3027+
without the need for fibers which would otherwise be necessary if the
3028+
`post-return` function performed a blocking operation.
30273029

30283030
In both of the asynchronous cases below (`callback` and non-`callback`),
30293031
`canon_task_return` must be called (as checked by `Task.exit`).
@@ -3386,14 +3388,18 @@ wasm state and passes them to the caller via `Task.return_`:
33863388
```python
33873389
async def canon_task_return(task, result_type, opts: LiftOptions, flat_args):
33883390
trap_if(not task.inst.may_leave)
3389-
trap_if(task.opts.sync and not task.opts.always_task_return)
3391+
trap_if(task.opts.sync)
33903392
trap_if(result_type != task.ft.results)
33913393
trap_if(not LiftOptions.equal(opts, task.opts))
33923394
cx = LiftLowerContext(opts, task.inst, task)
33933395
results = lift_flat_values(cx, MAX_FLAT_PARAMS, CoreValueIter(flat_args), task.ft.result_types())
33943396
task.return_(results)
33953397
return []
33963398
```
3399+
The `trap_if(task.opts.sync)` prevents `task.return` from being called by
3400+
synchronously-lifted functions (which return their value by returning from the
3401+
lifted core function).
3402+
33973403
The `trap_if(result_type != task.ft.results)` guard ensures that, in a
33983404
component with multiple exported functions of different types, `task.return` is
33993405
not called with a mismatched result type (which, due to indirect control flow,
@@ -3428,10 +3434,14 @@ current task have already been dropped (and trapping in `Task.cancel` if not).
34283434
```python
34293435
async def canon_task_cancel(task):
34303436
trap_if(not task.inst.may_leave)
3431-
trap_if(task.opts.sync and not task.opts.always_task_return)
3437+
trap_if(task.opts.sync)
34323438
task.cancel()
34333439
return []
34343440
```
3441+
The `trap_if(task.opts.sync)` prevents `task.cancel` from being called by
3442+
synchronously-lifted functions (which must always return a value by returning
3443+
from the lifted core function).
3444+
34353445
`Task.cancel` also traps if there has been no cancellation request (in which
34363446
case the callee expects to receive a return value) or if the task has already
34373447
returned a value or already called `task.cancel`.
@@ -3451,7 +3461,6 @@ Calling `$f` calls `Task.yield_` to allow other tasks to execute:
34513461
```python
34523462
async def canon_yield(sync, task):
34533463
trap_if(not task.inst.may_leave)
3454-
trap_if(task.opts.callback and not sync)
34553464
event_code,_,_ = await task.yield_(sync)
34563465
match event_code:
34573466
case EventCode.NONE:
@@ -3469,11 +3478,6 @@ Because other tasks can execute, a subtask can be cancelled while executing
34693478
generators should handle cancellation the same way as when receiving the
34703479
`TASK_CANCELLED` event from `waitable-set.wait`.
34713480

3472-
The guard preventing `async` use of `task.poll` when a `callback` has
3473-
been used preserves the invariant that producer toolchains using
3474-
`callback` never need to handle multiple overlapping callback
3475-
activations.
3476-
34773481

34783482
### 🔀 `canon waitable-set.new`
34793483

@@ -3509,7 +3513,6 @@ returning its `EventCode` and writing the payload values into linear memory:
35093513
```python
35103514
async def canon_waitable_set_wait(sync, mem, task, si, ptr):
35113515
trap_if(not task.inst.may_leave)
3512-
trap_if(task.opts.callback and not sync)
35133516
s = task.inst.table.get(si)
35143517
trap_if(not isinstance(s, WaitableSet))
35153518
e = await task.wait_for_event(s, sync)
@@ -3533,10 +3536,6 @@ though, the automatic backpressure (applied by `Task.enter`) will ensure there
35333536
is only ever at most once synchronously-lifted task executing in a component
35343537
instance at a time.
35353538

3536-
The guard preventing `async` use of `wait` when a `callback` has been used
3537-
preserves the invariant that producer toolchains using `callback` never need to
3538-
handle multiple overlapping callback activations.
3539-
35403539

35413540
### 🔀 `canon waitable-set.poll`
35423541

@@ -3554,7 +3553,6 @@ same way as `wait`.
35543553
```python
35553554
async def canon_waitable_set_poll(sync, mem, task, si, ptr):
35563555
trap_if(not task.inst.may_leave)
3557-
trap_if(task.opts.callback and not sync)
35583556
s = task.inst.table.get(si)
35593557
trap_if(not isinstance(s, WaitableSet))
35603558
e = await task.poll_for_event(s, sync)
@@ -3563,10 +3561,6 @@ async def canon_waitable_set_poll(sync, mem, task, si, ptr):
35633561
When `async` is set, `poll_for_event` can yield to other tasks (in this or other
35643562
components) as part of polling for an event.
35653563

3566-
The guard preventing `async` use of `poll_for_event` when a `callback` has been
3567-
used preserves the invariant that producer toolchains using `callback` never
3568-
need to handle multiple overlapping callback activations.
3569-
35703564

35713565
### 🔀 `canon waitable-set.drop`
35723566

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

@@ -1865,12 +1864,13 @@ async def canon_lift(opts, inst, ft, callee, caller, on_start, on_resolve, on_bl
18651864

18661865
if opts.sync:
18671866
flat_results = await call_and_trap_on_throw(callee, task, flat_args)
1868-
if not opts.always_task_return:
1869-
assert(types_match_values(flat_ft.results, flat_results))
1870-
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
1871-
task.return_(results)
1872-
if opts.post_return is not None:
1873-
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
1867+
assert(types_match_values(flat_ft.results, flat_results))
1868+
results = lift_flat_values(cx, MAX_FLAT_RESULTS, CoreValueIter(flat_results), ft.result_types())
1869+
task.return_(results)
1870+
if opts.post_return is not None:
1871+
task.inst.may_leave = False
1872+
[] = await call_and_trap_on_throw(opts.post_return, task, flat_results)
1873+
task.inst.may_leave = True
18741874
task.exit()
18751875
return
18761876

@@ -2043,7 +2043,7 @@ async def canon_backpressure_set(task, flat_args):
20432043

20442044
async def canon_task_return(task, result_type, opts: LiftOptions, flat_args):
20452045
trap_if(not task.inst.may_leave)
2046-
trap_if(task.opts.sync and not task.opts.always_task_return)
2046+
trap_if(task.opts.sync)
20472047
trap_if(result_type != task.ft.results)
20482048
trap_if(not LiftOptions.equal(opts, task.opts))
20492049
cx = LiftLowerContext(opts, task.inst, task)
@@ -2055,15 +2055,14 @@ async def canon_task_return(task, result_type, opts: LiftOptions, flat_args):
20552055

20562056
async def canon_task_cancel(task):
20572057
trap_if(not task.inst.may_leave)
2058-
trap_if(task.opts.sync and not task.opts.always_task_return)
2058+
trap_if(task.opts.sync)
20592059
task.cancel()
20602060
return []
20612061

20622062
### 🔀 `canon yield`
20632063

20642064
async def canon_yield(sync, task):
20652065
trap_if(not task.inst.may_leave)
2066-
trap_if(task.opts.callback and not sync)
20672066
event_code,_,_ = await task.yield_(sync)
20682067
match event_code:
20692068
case EventCode.NONE:
@@ -2081,7 +2080,6 @@ async def canon_waitable_set_new(task):
20812080

20822081
async def canon_waitable_set_wait(sync, mem, task, si, ptr):
20832082
trap_if(not task.inst.may_leave)
2084-
trap_if(task.opts.callback and not sync)
20852083
s = task.inst.table.get(si)
20862084
trap_if(not isinstance(s, WaitableSet))
20872085
e = await task.wait_for_event(s, sync)
@@ -2098,7 +2096,6 @@ def unpack_event(mem, task, ptr, e: EventTuple):
20982096

20992097
async def canon_waitable_set_poll(sync, mem, task, si, ptr):
21002098
trap_if(not task.inst.may_leave)
2101-
trap_if(task.opts.callback and not sync)
21022099
s = task.inst.table.get(si)
21032100
trap_if(not isinstance(s, WaitableSet))
21042101
e = await task.poll_for_event(s, sync)

0 commit comments

Comments
 (0)