Skip to content

Commit 46457f0

Browse files
committed
Make 'async' part of the function type, not a hint
1 parent fef280b commit 46457f0

File tree

6 files changed

+264
-148
lines changed

6 files changed

+264
-148
lines changed

design/mvp/Binary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ valtype ::= i:<typeidx> => i
216216
resourcetype ::= 0x3f 0x7f f?:<funcidx>? => (resource (rep i32) (dtor f)?)
217217
| 0x3e 0x7f f:<funcidx> cb?:<funcidx>? => (resource (rep i32) (dtor async f (callback cb)?)) 🚝
218218
functype ::= 0x40 ps:<paramlist> rs:<resultlist> => (func ps rs)
219+
| 0x43 ps:<paramlist> rs:<resultlist> => (func async ps rs)
219220
paramlist ::= lt*:vec(<labelvaltype>) => (param lt)*
220221
resultlist ::= 0x00 t:<valtype> => (result t)
221222
| 0x01 0x00 =>
@@ -288,7 +289,6 @@ canon ::= 0x00 0x00 f:<core:funcidx> opts:<opts> ft:<typeidx> => (canon lift
288289
| 0x01 0x00 f:<funcidx> opts:<opts> => (canon lower f opts (core func))
289290
| 0x02 rt:<typeidx> => (canon resource.new rt (core func))
290291
| 0x03 rt:<typeidx> => (canon resource.drop rt (core func))
291-
| 0x07 rt:<typeidx> => (canon resource.drop rt async (core func)) 🚝
292292
| 0x04 rt:<typeidx> => (canon resource.rep rt (core func))
293293
| 0x08 => (canon backpressure.set (core func)) 🔀✕
294294
| 0x24 => (canon backpressure.inc (core func)) 🔀

design/mvp/CanonicalABI.md

Lines changed: 120 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ complementarily using `parent_lock` and `fiber_lock` as follows:
570570
assert(not self.running())
571571

572572
def suspend(self, cancellable) -> SuspendResult:
573+
assert(self.task.may_suspend())
573574
assert(self.running() and not self.cancellable and self.suspend_result is None)
574575
self.cancellable = cancellable
575576
self.parent_lock.release()
@@ -877,15 +878,24 @@ synchronously or with `async callback`. This predicate is used by the other
877878
```python
878879
def needs_exclusive(self):
879880
return not self.opts.async_ or self.opts.callback
881+
```
880882

883+
The `Task.may_suspend` predicate returns whether the type of the function for
884+
which this `Task` was spawned allows current execution to suspend.
885+
Specifically, synchronous functions that have not yet returned a value to their
886+
caller may not suspend.
887+
```python
888+
def may_suspend(self):
889+
return self.ft.async_ or self.state == Task.State.RESOLVED
881890
```
882891

883-
The `Task.enter` method implements [backpressure] between when a caller makes a
884-
call to an imported callee and when the callee's core wasm entry point is
885-
executed. This interstitial placement allows an overloaded component instance
886-
to avoid the need to otherwise-endlessly allocate guest memory for blocked
887-
async calls until OOM. When backpressure is enabled, `enter` will block until
888-
backpressure is disabled. There are three sources of backpressure:
892+
The `Task.enter` method implements [backpressure] between when the caller of an
893+
`async`-typed function initiates the call and when the callee's core wasm entry
894+
point is executed. This interstitial placement allows a component instance that
895+
has been overloaded with concurrent function invocations to avoid OOM while
896+
allocating core wasm memory for each concurrent task. When backpressure is
897+
enabled, `enter` will block new `async`-typed calls until backpressure is
898+
disabled. There are three sources of backpressure:
889899
1. *Explicit backpressure* is triggered by core wasm calling
890900
`backpressure.{inc,dec}` which modify the `ComponentInstance.backpressure`
891901
counter.
@@ -896,9 +906,18 @@ backpressure is disabled. There are three sources of backpressure:
896906
`enter` that need to be given the chance to start without getting starved
897907
by new tasks.
898908

909+
Note that, because non-`async`-typed functions ignore backpressure entirely,
910+
they may reenter core wasm while a previous `async`-typed call is suspended
911+
at a cooperative yield point (where an `async`-typed function would have been
912+
blocked by implicit backpressure). Thus, export bindings generators must be
913+
careful to handle this kind of reentrance (e.g., while maintaining the
914+
linear-memory shadow stack) for components with mixed `async`- and non-`async`-
915+
typed exports.
899916
```python
900917
def enter(self, thread):
901918
assert(thread in self.threads and thread.task is self)
919+
if not self.ft.async_:
920+
return True
902921
def has_backpressure():
903922
return self.inst.backpressure > 0 or (self.needs_exclusive() and self.inst.exclusive)
904923
if has_backpressure() or self.inst.num_waiting_to_enter > 0:
@@ -931,6 +950,8 @@ returns to clear the `exclusive` flag set by `Task.enter`, allowing other
931950
```python
932951
def exit(self):
933952
assert(len(self.threads) > 0)
953+
if not self.ft.async_:
954+
return
934955
if self.needs_exclusive():
935956
assert(self.inst.exclusive)
936957
self.inst.exclusive = False
@@ -3249,12 +3270,17 @@ function (specified as a `funcidx` immediate in `canon lift`) until the
32493270
inst.exclusive = False
32503271
match code:
32513272
case CallbackCode.YIELD:
3252-
event = task.yield_until(lambda: not inst.exclusive, thread, cancellable = True)
3273+
if thread.task.may_suspend():
3274+
event = task.yield_until(lambda: not inst.exclusive, thread, cancellable = True)
3275+
else:
3276+
event = (EventCode.NONE, 0, 0)
32533277
case CallbackCode.WAIT:
3278+
trap_if(not thread.task.may_suspend())
32543279
wset = inst.table.get(si)
32553280
trap_if(not isinstance(wset, WaitableSet))
32563281
event = task.wait_until(lambda: not inst.exclusive, thread, wset, cancellable = True)
32573282
case CallbackCode.POLL:
3283+
trap_if(not thread.task.may_suspend())
32583284
wset = inst.table.get(si)
32593285
trap_if(not isinstance(wset, WaitableSet))
32603286
event = task.poll_until(lambda: not inst.exclusive, thread, wset, cancellable = True)
@@ -3272,6 +3298,12 @@ built-ins. Thus, the main difference between stackful and stackless async is
32723298
whether these suspending operations are performed from an empty or non-empty
32733299
core wasm callstack (with the former allowing additional engine optimization).
32743300

3301+
If a `Task` is not allowed to suspend because it was created for a non-`async`-
3302+
typed function call and has not yet returned a value, `YIELD` is always a no-op
3303+
and `WAIT` and `POLL` always trap. Thus, it *is* possible for a component to
3304+
implement a non-`async`-typed function with the `async callback` ABI, but the
3305+
component *must* call `task.return` *before* `WAIT`ing or `POLL`ing.
3306+
32753307
The event loop also releases `ComponentInstance.exclusive` (which was acquired
32763308
by `Task.enter` and will be released by `Task.exit`) before potentially
32773309
suspending the thread to allow other synchronous and `async callback` tasks to
@@ -3363,14 +3395,23 @@ Based on this, `canon_lower` is defined in chunks as follows:
33633395
```python
33643396
def canon_lower(opts, ft, callee: FuncInst, thread, flat_args):
33653397
trap_if(not thread.task.inst.may_leave)
3366-
subtask = Subtask()
3367-
cx = LiftLowerContext(opts, thread.task.inst, subtask)
3398+
trap_if(not thread.task.may_suspend() and ft.async_ and not opts.async_)
33683399
```
3400+
A non-`async`-typed function export unconditionally traps if it transitively
3401+
attempts to make a synchronous call to an `async`-typed function import (even
3402+
if the callee wouldn't have actually suspended at runtime). It is however
3403+
always fine to `async`-lowered call an `async`-typed function import, since
3404+
this *never* suspends.
3405+
33693406
Each call to `canon_lower` creates a new `Subtask`. However, this `Subtask` is
33703407
only added to the current component instance's table (below) if `async` is
33713408
specified *and* `callee` blocks. In any case, this `Subtask` is used as the
33723409
`LiftLowerContext.borrow_scope` for `borrow` arguments, ensuring that owned
33733410
handles are not dropped before `Subtask.deliver_return` is called (below).
3411+
```python
3412+
subtask = Subtask()
3413+
cx = LiftLowerContext(opts, thread.task.inst, subtask)
3414+
```
33743415

33753416
The next chunk makes the call to `callee` (which has type `FuncInst`, as
33763417
defined in the [Embedding](#embedding) interface). The [current task] serves as
@@ -3415,6 +3456,7 @@ above).
34153456
flat_results = lower_flat_values(cx, max_flat_results, result, ft.result_type(), flat_args)
34163457

34173458
subtask.callee = callee(thread.task, on_start, on_resolve)
3459+
assert(ft.async_ or subtask.state == Subtask.State.RETURNED)
34183460
```
34193461
The `Subtask.state` field is updated by the callbacks to keep track of the
34203462
call progres. The `on_progress` variable starts as a no-op, but is used by the
@@ -3423,7 +3465,9 @@ call progres. The `on_progress` variable starts as a no-op, but is used by the
34233465
According to the `FuncInst` calling contract, the call to `callee` should never
34243466
"block" (i.e., wait on I/O). If the `callee` *would* block, it will instead
34253467
return a `Call` object which is stored in the `Subtask` (so that it can be used
3426-
to `request_cancellation` in the future).
3468+
to `request_cancellation` in the future). Furthermore, if the function type
3469+
does not have the `async` attribute, the function *must* have returned a value
3470+
by the time is blocks or returns.
34273471

34283472
In the synchronous case (when the `async` `canonopt` is not set), if the
34293473
`callee` blocked before calling `on_resolve`, the synchronous caller's thread
@@ -3518,45 +3562,45 @@ For a canonical definition:
35183562
validation specifies:
35193563
* `$rt` must refer to resource type
35203564
* `$f` is given type `(func (param i32))`
3521-
* 🔀+🚝 - `async` is allowed (otherwise it is not allowed)
35223565

35233566
Calling `$f` invokes the following function, which removes the handle from the
35243567
current component instance's table and, if the handle was owning, calls the
35253568
resource's destructor.
35263569
```python
3527-
def canon_resource_drop(rt, async_, thread, i):
3570+
def canon_resource_drop(rt, thread, i):
35283571
trap_if(not thread.task.inst.may_leave)
35293572
inst = thread.task.inst
35303573
h = inst.table.remove(i)
35313574
trap_if(not isinstance(h, ResourceHandle))
35323575
trap_if(h.rt is not rt)
35333576
trap_if(h.num_lends != 0)
3534-
flat_results = [] if not async_ else [0]
35353577
if h.own:
35363578
assert(h.borrow_scope is None)
35373579
if inst is rt.impl:
35383580
if rt.dtor:
35393581
rt.dtor(h.rep)
35403582
else:
35413583
if rt.dtor:
3542-
caller_opts = CanonicalOptions(async_ = async_)
3584+
caller_opts = CanonicalOptions(async_ = False)
35433585
callee_opts = CanonicalOptions(async_ = rt.dtor_async, callback = rt.dtor_callback)
3544-
ft = FuncType([U32Type()],[])
3586+
ft = FuncType([U32Type()],[], async_ = False)
35453587
callee = partial(canon_lift, callee_opts, rt.impl, ft, rt.dtor)
3546-
flat_results = canon_lower(caller_opts, ft, callee, thread, [h.rep])
3588+
[] = canon_lower(caller_opts, ft, callee, thread, [h.rep])
35473589
else:
35483590
thread.task.trap_if_on_the_stack(rt.impl)
35493591
else:
35503592
h.borrow_scope.num_borrows -= 1
3551-
return flat_results
3552-
```
3553-
In general, the call to a resource's destructor is treated like a
3554-
cross-component call (as-if the destructor was exported by the component
3555-
defining the resource type). This means that cross-component destructor calls
3556-
follow the same concurrency rules as normal exports. However, since there are
3557-
valid reasons to call `resource.drop` in the same component instance that
3558-
defined the resource, which would otherwise trap at the reentrance guard of
3559-
`Task.enter`, an exception is made when the resource type's
3593+
return []
3594+
```
3595+
The call to a resource's destructor is defined as a non-`async`-lowered,
3596+
non-`async`-typed function call to a possibly-`async`-lifted callee, passing
3597+
the private `i32` representation as a parameter with an empty return. Thus,
3598+
destructors *may* block on I/O, but only after they `task.return`, ensuring
3599+
that `resource.drop` never blocks.
3600+
3601+
Since there are valid reasons to call `resource.drop` in the same component
3602+
instance that defined the resource, which would otherwise trap at the
3603+
reentrance guard of `Task.enter`, an exception is made when the resource type's
35603604
implementation-instance is the same as the current instance (which is
35613605
statically known for any given `canon resource.drop`).
35623606

@@ -3798,6 +3842,7 @@ returning its `EventCode` and writing the payload values into linear memory:
37983842
```python
37993843
def canon_waitable_set_wait(cancellable, mem, thread, si, ptr):
38003844
trap_if(not thread.task.inst.may_leave)
3845+
trap_if(not thread.task.may_suspend())
38013846
wset = thread.task.inst.table.get(si)
38023847
trap_if(not isinstance(wset, WaitableSet))
38033848
event = thread.task.wait_until(lambda: True, thread, wset, cancellable)
@@ -3810,6 +3855,10 @@ def unpack_event(mem, thread, ptr, e: EventTuple):
38103855
store(cx, p2, U32Type(), ptr + 4)
38113856
return [event]
38123857
```
3858+
A non-`async`-typed function export unconditionally traps if it transitively
3859+
attempts to call to `waitable-set.wait` (regardless of whether there are any
3860+
waitables with pending events).
3861+
38133862
The `lambda: True` passed to `wait_until` means that `wait_until` will only
38143863
wait for the given `wset` to have a pending event with no extra conditions.
38153864

@@ -3837,6 +3886,7 @@ same way as `wait`.
38373886
```python
38383887
def canon_waitable_set_poll(cancellable, mem, thread, si, ptr):
38393888
trap_if(not thread.task.inst.may_leave)
3889+
trap_if(not thread.task.may_suspend())
38403890
wset = thread.task.inst.table.get(si)
38413891
trap_if(not isinstance(wset, WaitableSet))
38423892
event = thread.task.poll_until(lambda: True, thread, wset, cancellable)
@@ -3845,7 +3895,8 @@ def canon_waitable_set_poll(cancellable, mem, thread, si, ptr):
38453895
Even though `waitable-set.poll` doesn't block until the given waitable set has
38463896
a pending event, `poll_until` does transitively perform a `Thread.suspend`
38473897
which allows the embedder to nondeterministically switch to executing another
3848-
task (like `thread.yield`).
3898+
task (like `thread.yield`). Thus, a non-`async`-typed function export
3899+
unconditionally traps if it transitively attempts to call `waitable-set.poll`.
38493900

38503901
If `cancellable` is set, then `waitable-set.poll` will return whether the
38513902
supertask has already or concurrently requested cancellation.
@@ -3944,6 +3995,7 @@ BLOCKED = 0xffff_ffff
39443995

39453996
def canon_subtask_cancel(async_, thread, i):
39463997
trap_if(not thread.task.inst.may_leave)
3998+
trap_if(not thread.task.may_suspend() and not async_)
39473999
subtask = thread.task.inst.table.get(i)
39484000
trap_if(not isinstance(subtask, Subtask))
39494001
trap_if(subtask.resolve_delivered())
@@ -3963,9 +4015,13 @@ def canon_subtask_cancel(async_, thread, i):
39634015
assert(subtask.resolve_delivered())
39644016
return [subtask.state]
39654017
```
3966-
The initial trapping conditions disallow calling `subtask.cancel` twice for the
3967-
same subtask or after the supertask has already been notified that the subtask
3968-
has returned.
4018+
A non-`async`-typed function export unconditionally traps if it transitively
4019+
attempts to make a synchronous call to `subtask.cancel` (regardless of whether
4020+
the cancellation would have succeeded without suspending).
4021+
4022+
The following trapping conditions disallow calling `subtask.cancel` twice for
4023+
the same subtask or after the supertask has already been notified that the
4024+
subtask has returned.
39694025

39704026
A race condition handled by the above code is that it's possible for a subtask
39714027
to have already resolved (by calling `task.return` or `task.cancel`) and
@@ -4060,13 +4116,20 @@ def canon_stream_write(stream_t, opts, thread, i, ptr, n):
40604116
stream_t, opts, thread, i, ptr, n)
40614117
```
40624118

4063-
Introducing the `stream_copy` function in chunks, `stream_copy` first checks
4064-
that the element at index `i` is of the right type and allowed to start a new
4065-
copy. (In the future, the "trap if not `IDLE`" condition could be relaxed to
4066-
allow multiple pipelined reads or writes.)
4119+
Introducing the `stream_copy` function in chunks, a non-`async`-typed function
4120+
export unconditionally traps if it transitively attempts to make a synchronous
4121+
call to `stream.{read,write}` (regardless of whether the operation would have
4122+
succeeded eagerly without suspending).
40674123
```python
40684124
def stream_copy(EndT, BufferT, event_code, stream_t, opts, thread, i, ptr, n):
40694125
trap_if(not thread.task.inst.may_leave)
4126+
trap_if(not thread.task.may_suspend() and not opts.async_)
4127+
```
4128+
4129+
Next, `stream_copy` checks that the element at index `i` is of the right type
4130+
and allowed to start a new copy. (In the future, the "trap if not `IDLE`"
4131+
condition could be relaxed to allow multiple pipelined reads or writes.)
4132+
```python
40704133
e = thread.task.inst.table.get(i)
40714134
trap_if(not isinstance(e, EndT))
40724135
trap_if(e.shared.t != stream_t.t)
@@ -4166,11 +4229,14 @@ def canon_future_write(future_t, opts, thread, i, ptr):
41664229
```
41674230

41684231
Introducing the `future_copy` function in chunks, `future_copy` starts with the
4169-
same set of guards as `stream_copy` for parameters `i` and `ptr`. The only
4170-
difference is that, with futures, the `Buffer` length is fixed to `1`.
4232+
same set of guards as `stream_copy` regarding whether suspension is allowed and
4233+
parameters `i` and `ptr`. The only difference is that, with futures, the
4234+
`Buffer` length is fixed to `1`.
41714235
```python
41724236
def future_copy(EndT, BufferT, event_code, future_t, opts, thread, i, ptr):
41734237
trap_if(not thread.task.inst.may_leave)
4238+
trap_if(not thread.task.may_suspend() and not opts.async_)
4239+
41744240
e = thread.task.inst.table.get(i)
41754241
trap_if(not isinstance(e, EndT))
41764242
trap_if(e.shared.t != future_t.t)
@@ -4254,6 +4320,7 @@ def canon_future_cancel_write(future_t, async_, thread, i):
42544320

42554321
def cancel_copy(EndT, event_code, stream_or_future_t, async_, thread, i):
42564322
trap_if(not thread.task.inst.may_leave)
4323+
trap_if(not thread.task.may_suspend() and not async_)
42574324
e = thread.task.inst.table.get(i)
42584325
trap_if(not isinstance(e, EndT))
42594326
trap_if(e.shared.t != stream_or_future_t.t)
@@ -4269,8 +4336,12 @@ def cancel_copy(EndT, event_code, stream_or_future_t, async_, thread, i):
42694336
assert(not e.copying() and code == event_code and index == i)
42704337
return [payload]
42714338
```
4272-
Cancellation traps if there is not currently an async copy in progress (sync
4273-
copies do not expect or check for cancellation and thus cannot be cancelled).
4339+
A non-`async`-typed function export unconditionally traps if it transitively
4340+
attempts to make a synchronous call to `{stream,future}.cancel-{read,write}`
4341+
(regardless of whether the cancellation would have completed without
4342+
suspending). There is also a trap if there is not currently an async copy in
4343+
progress (sync copies do not expect or check for cancellation and thus cannot
4344+
be cancelled).
42744345

42754346
The *first* check for `e.has_pending_event()` catches the case where the copy has
42764347
already racily finished, in which case we must *not* call `cancel()`. Calling
@@ -4435,9 +4506,13 @@ calling component.
44354506
```python
44364507
def canon_thread_suspend(cancellable, thread):
44374508
trap_if(not thread.task.inst.may_leave)
4509+
trap_if(not thread.task.may_suspend())
44384510
suspend_result = thread.task.suspend(thread, cancellable)
44394511
return [suspend_result]
44404512
```
4513+
A non-`async`-typed function export traps if it transitively attempts to call
4514+
`thread.suspend`.
4515+
44414516
If `cancellable` is set, then `thread.suspend` will return a `SuspendResult`
44424517
value to indicate whether the supertask has already or concurrently requested
44434518
cancellation. `thread.suspend` (and other cancellable operations) will only
@@ -4521,13 +4596,21 @@ other threads in a cooperative setting.
45214596
```python
45224597
def canon_thread_yield(cancellable, thread):
45234598
trap_if(not thread.task.inst.may_leave)
4599+
if not thread.task.may_suspend():
4600+
return [SuspendResult.NOT_CANCELLED]
45244601
event_code,_,_ = thread.task.yield_until(lambda: True, thread, cancellable)
45254602
match event_code:
45264603
case EventCode.NONE:
45274604
return [SuspendResult.NOT_CANCELLED]
45284605
case EventCode.TASK_CANCELLED:
45294606
return [SuspendResult.CANCELLED]
45304607
```
4608+
If a non-`async`-typed function export transitively calls `thread.yield`, the
4609+
operation is a no-op (instead of trapping, as with other possibly-suspending
4610+
operations like `waitable-set.poll`). This is because, unlike other built-ins,
4611+
`thread.yield` may be scattered liberally throughout code that might show up in
4612+
the transitive call tree of a synchronous function call.
4613+
45314614
Even though `yield_until` passes `lambda: True` as the condition it is waiting
45324615
for, `yield_until` does transitively peform a `Thread.suspend` which allows
45334616
the embedder to nondeterministically switch to executing another thread.

0 commit comments

Comments
 (0)