Skip to content

Commit 5cb57ad

Browse files
committed
CABI: simplify how async lowering works
1 parent ab4ca42 commit 5cb57ad

File tree

3 files changed

+94
-127
lines changed

3 files changed

+94
-127
lines changed

design/mvp/CanonicalABI.md

Lines changed: 67 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ created by `canon_lift` and `Subtask`, which is created by `canon_lower`.
444444
Additional sync-/async-specialized mutable state is added by the `AsyncTask`
445445
and `AsyncSubtask` subclasses.
446446

447-
The `Task` class and its subclasses depend on the following two enums:
447+
The `Task` class and its subclasses depend on the following three enums:
448448
```python
449449
class AsyncCallState(IntEnum):
450450
STARTING = 0
@@ -458,12 +458,18 @@ class EventCode(IntEnum):
458458
CALL_RETURNED = AsyncCallState.RETURNED
459459
CALL_DONE = AsyncCallState.DONE
460460
YIELDED = 4
461+
462+
class OnBlockResult(IntEnum):
463+
BLOCKED = 0
464+
COMPLETED = 1
461465
```
462466
The `AsyncCallState` enum describes the linear sequence of states that an async
463467
call necessarily transitions through: [`STARTING`](Async.md#starting),
464468
`STARTED`, [`RETURNING`](Async.md#returning) and `DONE`. The `EventCode` enum
465469
shares common code values with `AsyncCallState` to define the set of integer
466470
event codes that are delivered to [waiting](Async.md#waiting) or polling tasks.
471+
The `OnBlockResult` enum conveys the two possible results of the `on_block`
472+
future used to tell callers whether or not the callee blocked.
467473

468474
The `current_Task` global holds an `asyncio.Lock` that is used to prevent the
469475
Python runtime from arbitrarily switching between Python coroutines (`async
@@ -481,27 +487,24 @@ current_task = asyncio.Lock()
481487

482488
A `Task` object is created for each call to `canon_lift` and is implicitly
483489
threaded through all core function calls. This implicit `Task` parameter
484-
specifies a concept of [the current task](Async.md#current-task) and inherently
485-
scopes execution of all core wasm (including `canon`-defined core functions) to
486-
a `Task`.
490+
represents "[the current task](Async.md#current-task)".
487491
```python
488492
class Task(CallContext):
489493
caller: Optional[Task]
490-
on_block: Optional[Callable]
494+
on_block: Optional[asyncio.Future[OnBlockResult]]
491495
borrow_count: int
492496
events: asyncio.Queue[AsyncSubtask]
493497
num_async_subtasks: int
494498

495499
def __init__(self, opts, inst, caller, on_block):
496500
super().__init__(opts, inst)
497-
assert(on_block is not None)
498501
self.caller = caller
499502
self.on_block = on_block
500503
self.borrow_count = 0
501504
self.events = asyncio.Queue[AsyncSubtask]()
502505
self.num_async_subtasks = 0
503506
```
504-
The fields of `Task` are introduced in groups of related `Task`-methods next.
507+
The fields of `Task` are introduced in groups of related `Task` methods next.
505508
Using a conservative syntactic analysis of the component-level definitions of a
506509
linked component DAG, an optimizing implementation can statically eliminate
507510
these fields when the particular feature (`borrow` handles, `async` imports) is
@@ -531,16 +534,15 @@ O(n) loop in `trap_if_on_the_stack`:
531534
instance a static bit position) that is passed by copy from caller to callee.
532535

533536
The `enter` method is called immediately after constructing a `Task` and, along
534-
with the `may_enter` and `may_start_pending_task` helper functions, implements
535-
backpressure. If a `Task` tries to `enter` when `may_enter` is false, the
536-
`Task` suspends itself (via `suspend`, shown next) and goes into a
537-
`pending_tasks` queue, waiting to be unblocked when `may_enter` is true by
538-
another task calling `maybe_start_pending_task`. One key property of this
539-
backpressure scheme is that `pending_tasks` are only dequeued one at a time,
540-
ensuring that if an overloaded component instance enables backpressure (via
541-
`task.backpressure`) and then disables it, there will not be an unstoppable
542-
thundering herd of pending tasks started all at once that OOM the component
543-
before it can re-enable backpressure.
537+
with `may_enter` and `may_start_pending_task`, implements backpressure. If a
538+
`Task` tries to `enter` when `may_enter` is false, the `Task` suspends itself
539+
(via `suspend`, shown next) and goes into a `pending_tasks` queue, waiting to
540+
be unblocked by another task calling `maybe_start_pending_task`. One key
541+
property of this backpressure scheme is that `pending_tasks` are only dequeued
542+
one at a time, ensuring that if an overloaded component instance enables
543+
backpressure (via `task.backpressure`) and then disables it, there will not be
544+
an unstoppable thundering herd of pending tasks started all at once that OOM
545+
the component before it can re-enable backpressure.
544546
```python
545547
async def enter(self):
546548
assert(current_task.locked())
@@ -569,36 +571,29 @@ before it can re-enable backpressure.
569571
The rules shown above also ensure that synchronously-lifted exports only
570572
execute when no other (sync or async) tasks are executing concurrently.
571573

572-
The `suspend` method, used by `enter`, `wait` and `yield_`, takes an
573-
`asyncio.Future` to `await` and allows other tasks to make progress in the
574-
meantime. When suspending, there are two cases to consider:
575-
* This is the first time the current `Task` has blocked and thus there may be
576-
an `async`-lowered caller waiting to find out that the callee blocked (which
577-
is signalled by calling the `on_block` handler that the caller passed to
578-
`canon_lift`).
579-
* This task has already blocked in the past (signalled by `on_block` being
580-
`None`) and thus there is no `async`-lowered caller to switch to and so we
581-
let Python's `asyncio` scheduler non-deterministically pick some other task
582-
that is ready to go by releasing the `current_task` lock.
583-
584-
In either case, once the given future is resolved, this `Task` has to
585-
re-`acquire` the `current_stack` lock to run again.
574+
The `suspend` method, called by `enter`, `wait` and `yield_`, takes a future to
575+
`await` and allows other tasks to make progress in the meantime. Once this
576+
future is resolved, the current task must reacquire the `current_task` lock to
577+
wait for any other task that is currently executing to suspend or exit.
586578
```python
587579
async def suspend(self, future):
588-
assert(current_task.locked())
589-
if self.on_block:
590-
self.on_block()
591-
self.on_block = None
580+
if self.on_block and not self.on_block.done():
581+
self.on_block.set_result(OnBlockResult.BLOCKED)
592582
else:
593583
current_task.release()
594-
r = await future
584+
v = await future
595585
await current_task.acquire()
596-
return r
586+
return v
597587
```
598-
As a side note: the `suspend` method is so named because it could be
599-
reimplemented using the [`suspend`] instruction of the [typed continuations]
600-
proposal, removing the need for `on_block` and the subtle calling contract
601-
between `suspend` and `canon_lift`.
588+
When there is an `async`-lowered caller waiting on the stack, the `on_block`
589+
field will point to an unresolved future. In this case, `suspend` sets the
590+
result of `on_block` and leaves `current_task` locked so that control flow
591+
transfers deterministically to `async`-lowered caller (in `canon_lower`,
592+
defined below). The `suspend` method is so named because this delicate use of
593+
Python's async functionality is essentially emulating the `suspend`/`resume`
594+
instructions of the [stack-switching] proposal. Thus, once stack-switching is
595+
implemented, a valid implementation technique would be to compile Canonical ABI
596+
adapters to Core WebAssembly using `suspend` and `resume`.
602597

603598
While a task is running, it may call `wait` (via `canon task.wait` or, when a
604599
`callback` is present, by returning to the event loop) to block until there is
@@ -672,13 +667,7 @@ after this export call finishes):
672667
```
673668

674669
Lastly, when a task exits, the runtime enforces the guard conditions mentioned
675-
above and allows other tasks to start or make progress. If the exiting `Task`
676-
has not yet blocked, there is an active `async`-lowered caller on the stack, so
677-
we don't release the `current_task` lock and instead just let the `Task`'s
678-
Python coroutine return directly to the `await`ing caller without any possible
679-
task switch. The net effect is that when a cross-component async starts and
680-
finishes without blocking, there doesn't need to be stack switching or async
681-
resource allocation.
670+
above and allows pending tasks to start.
682671
```python
683672
def exit(self):
684673
assert(current_task.locked())
@@ -690,8 +679,6 @@ resource allocation.
690679
if self.opts.sync:
691680
self.inst.may_not_enter_bc_sync_export = False
692681
self.maybe_start_pending_task()
693-
if not self.on_block:
694-
current_task.release()
695682
```
696683

697684
While `canon_lift` creates `Task`s, `canon_lower` creates `Subtask` objects:
@@ -1906,8 +1893,8 @@ When instantiating component instance `$inst`:
19061893
The resulting function `$f` takes 4 runtime arguments:
19071894
* `caller`: the caller's `Task` or, if this lifted function is being called by
19081895
the host, `None`
1909-
* `on_block`: a nullary function that must be called at most once by the callee
1910-
before blocking the first time
1896+
* `on_block`: an optional `asyncio.Future` that must be resolved with
1897+
`OnBlockResult.BLOCKED` if the callee blocks on I/O
19111898
* `on_start`: a nullary function that must be called to return the caller's
19121899
arguments as a list of component-level values
19131900
* `on_return`: a unary function that must be called after `on_start`,
@@ -2021,31 +2008,26 @@ The resulting function `$f` takes 2 runtime arguments:
20212008
Given this, `canon_lower` is defined:
20222009
```python
20232010
async def canon_lower(opts, callee, ft, task, flat_args):
2011+
assert(current_task.locked())
20242012
trap_if(task.inst.may_not_leave)
20252013

20262014
flat_args = CoreValueIter(flat_args)
20272015
flat_results = None
20282016
if opts.sync:
20292017
subtask = Subtask(opts, task.inst)
20302018
task.inst.may_not_enter_bc_sync_import = True
2031-
def on_block():
2032-
if task.on_block:
2033-
task.on_block()
2034-
task.on_block = None
20352019
def on_start():
20362020
return lift_flat_values(subtask, MAX_FLAT_PARAMS, flat_args, ft.param_types())
20372021
def on_return(results):
20382022
nonlocal flat_results
20392023
flat_results = lower_flat_values(subtask, MAX_FLAT_RESULTS, results, ft.result_types(), flat_args)
2040-
await callee(task, on_block, on_start, on_return)
2024+
await callee(task, task.on_block, on_start, on_return)
20412025
task.inst.may_not_enter_bc_sync_import = False
20422026
subtask.finish()
20432027
else:
20442028
subtask = AsyncSubtask(opts, task.inst)
2045-
eager_result = asyncio.Future()
2029+
on_block = asyncio.Future()
20462030
async def do_call():
2047-
def on_block():
2048-
eager_result.set_result('block')
20492031
def on_start():
20502032
subtask.start()
20512033
return lift_flat_values(subtask, 1, flat_args, ft.param_types())
@@ -2054,41 +2036,39 @@ async def canon_lower(opts, callee, ft, task, flat_args):
20542036
lower_flat_values(subtask, 0, results, ft.result_types(), flat_args)
20552037
await callee(task, on_block, on_start, on_return)
20562038
subtask.finish()
2057-
if not eager_result.done():
2058-
eager_result.set_result('complete')
2039+
if on_block.done():
2040+
current_task.release()
2041+
else:
2042+
on_block.set_result(OnBlockResult.COMPLETED)
20592043
asyncio.create_task(do_call())
2060-
match await eager_result:
2061-
case 'complete':
2062-
flat_results = [0]
2063-
case 'block':
2044+
match await on_block:
2045+
case OnBlockResult.BLOCKED:
20642046
i = task.add_async_subtask(subtask)
20652047
flat_results = [pack_async_result(i, subtask.state)]
2048+
case OnBlockResult.COMPLETED:
2049+
flat_results = [0]
20662050

2051+
assert(current_task.locked())
20672052
return flat_results
20682053
```
20692054
In the synchronous case, the import call is bracketed by setting
2070-
`calling_sync_import` to prevent reentrance into the current component instance
2071-
if the `callee` blocks and the caller gets control flow (via `on_block`). Like
2072-
`Task.suspend` above, `canon_lift` clears the `on_block` handler after calling
2073-
to signal that the current `Task` has already released any waiting
2074-
`async`-lowered callers.
2075-
2076-
In the asynchronous case, we finally see the whole point of `on_block` which is
2077-
to allow us to wait for one of two outcomes: the callee blocks or the callee
2078-
finishes without blocking. Whichever happens first resolves the `eager_result`
2079-
future. After calling `asyncio.create_task`, `canon_lift` immediately `await`s
2080-
`eager_result` so that there is no allowed interleaving between the caller and
2081-
callee's Python coroutines. This overall behavior resembles the [`resume`]
2082-
instruction of the [typed continuations] proposal (handling a `block` effect)
2083-
which could be used to more-directly implement the Python control flow here.
2055+
`may_not_enter_bc_sync_import` to prevent reentrance into the current component
2056+
instance if the `callee` blocks and the caller gets control flow (via
2057+
`on_block`).
2058+
2059+
In the asynchronous case, the `on_block` future allows the caller to `await` to
2060+
see if `callee` blocks on I/O before returning. Because `Task.suspend` (defined
2061+
above) does not release the `current_task` lock when it resolves `on_block` to
2062+
`BLOCKED`, control flow deterministically returns to the caller (without
2063+
executing other tasks) when `callee` blocks. This is analogous to how the
2064+
`resume` instruction of the [stack-switching] proposal would work if given
2065+
`(cont.new $callee)` and handling an `on_block` event.
20842066

20852067
Whether or not the `callee` blocks, the `on_start` and `on_return` handlers
2086-
must be called before the `callee` completes (either by `canon_lift` in the
2087-
synchronous case or the `task.start`/`task.return` built-ins in the
2088-
asynchronous case). Note that, when `async`-lowering, lifting and lowering
2089-
can happen after `canon_lower` returns and thus the caller must `task.wait`
2090-
for `EventCode`s to know when the supplied linear memory pointers can be
2091-
reused.
2068+
will be called before the `callee` completes. Note that, in an `async`-lowered
2069+
call, `on_start` and `on_return` can happen after `canon_lower` returns and
2070+
thus the caller must `task.wait` for progress events to know when the supplied
2071+
linear memory pointers can be reclaimed by the caller.
20922072

20932073
If an `async`-lowered call blocks, the `AsyncSubtask` is added to the component
20942074
instance's `async_subtasks` table, and the index and state are returned to the
@@ -2473,9 +2453,7 @@ def canon_thread_hw_concurrency():
24732453
[Exceptions]: https://github.com/WebAssembly/exception-handling/blob/main/proposals/exception-handling/Exceptions.md
24742454
[WASI]: https://github.com/webassembly/wasi
24752455
[Deterministic Profile]: https://github.com/WebAssembly/profiles/blob/main/proposals/profiles/Overview.md
2476-
[Typed Continuations]: https://github.com/WebAssembly/stack-switching/blob/main/proposals/continuations/Explainer.md
2477-
[`suspend`]: https://github.com/WebAssembly/stack-switching/blob/main/proposals/continuations/Explainer.md#suspending-continuations
2478-
[`resume`]: https://github.com/WebAssembly/stack-switching/blob/main/proposals/continuations/Explainer.md#invoking-continuations
2456+
[stack-switching]: https://github.com/WebAssembly/stack-switching
24792457

24802458
[Alignment]: https://en.wikipedia.org/wiki/Data_structure_alignment
24812459
[UTF-8]: https://en.wikipedia.org/wiki/UTF-8

design/mvp/canonical-abi/definitions.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -398,18 +398,21 @@ class EventCode(IntEnum):
398398
CALL_DONE = AsyncCallState.DONE
399399
YIELDED = 4
400400

401+
class OnBlockResult(IntEnum):
402+
BLOCKED = 0
403+
COMPLETED = 1
404+
401405
current_task = asyncio.Lock()
402406

403407
class Task(CallContext):
404408
caller: Optional[Task]
405-
on_block: Optional[Callable]
409+
on_block: Optional[asyncio.Future[OnBlockResult]]
406410
borrow_count: int
407411
events: asyncio.Queue[AsyncSubtask]
408412
num_async_subtasks: int
409413

410414
def __init__(self, opts, inst, caller, on_block):
411415
super().__init__(opts, inst)
412-
assert(on_block is not None)
413416
self.caller = caller
414417
self.on_block = on_block
415418
self.borrow_count = 0
@@ -447,15 +450,13 @@ def maybe_start_pending_task(self):
447450
future.set_result(None)
448451

449452
async def suspend(self, future):
450-
assert(current_task.locked())
451-
if self.on_block:
452-
self.on_block()
453-
self.on_block = None
453+
if self.on_block and not self.on_block.done():
454+
self.on_block.set_result(OnBlockResult.BLOCKED)
454455
else:
455456
current_task.release()
456-
r = await future
457+
v = await future
457458
await current_task.acquire()
458-
return r
459+
return v
459460

460461
async def wait(self):
461462
self.maybe_start_pending_task()
@@ -507,8 +508,6 @@ def exit(self):
507508
if self.opts.sync:
508509
self.inst.may_not_enter_bc_sync_export = False
509510
self.maybe_start_pending_task()
510-
if not self.on_block:
511-
current_task.release()
512511

513512
class Subtask(CallContext):
514513
lenders: list[HandleElem]
@@ -1365,31 +1364,26 @@ async def call_and_trap_on_throw(callee, task, args):
13651364
### `canon lower`
13661365

13671366
async def canon_lower(opts, callee, ft, task, flat_args):
1367+
assert(current_task.locked())
13681368
trap_if(task.inst.may_not_leave)
13691369

13701370
flat_args = CoreValueIter(flat_args)
13711371
flat_results = None
13721372
if opts.sync:
13731373
subtask = Subtask(opts, task.inst)
13741374
task.inst.may_not_enter_bc_sync_import = True
1375-
def on_block():
1376-
if task.on_block:
1377-
task.on_block()
1378-
task.on_block = None
13791375
def on_start():
13801376
return lift_flat_values(subtask, MAX_FLAT_PARAMS, flat_args, ft.param_types())
13811377
def on_return(results):
13821378
nonlocal flat_results
13831379
flat_results = lower_flat_values(subtask, MAX_FLAT_RESULTS, results, ft.result_types(), flat_args)
1384-
await callee(task, on_block, on_start, on_return)
1380+
await callee(task, task.on_block, on_start, on_return)
13851381
task.inst.may_not_enter_bc_sync_import = False
13861382
subtask.finish()
13871383
else:
13881384
subtask = AsyncSubtask(opts, task.inst)
1389-
eager_result = asyncio.Future()
1385+
on_block = asyncio.Future()
13901386
async def do_call():
1391-
def on_block():
1392-
eager_result.set_result('block')
13931387
def on_start():
13941388
subtask.start()
13951389
return lift_flat_values(subtask, 1, flat_args, ft.param_types())
@@ -1398,16 +1392,19 @@ def on_return(results):
13981392
lower_flat_values(subtask, 0, results, ft.result_types(), flat_args)
13991393
await callee(task, on_block, on_start, on_return)
14001394
subtask.finish()
1401-
if not eager_result.done():
1402-
eager_result.set_result('complete')
1395+
if on_block.done():
1396+
current_task.release()
1397+
else:
1398+
on_block.set_result(OnBlockResult.COMPLETED)
14031399
asyncio.create_task(do_call())
1404-
match await eager_result:
1405-
case 'complete':
1406-
flat_results = [0]
1407-
case 'block':
1400+
match await on_block:
1401+
case OnBlockResult.BLOCKED:
14081402
i = task.add_async_subtask(subtask)
14091403
flat_results = [pack_async_result(i, subtask.state)]
1404+
case OnBlockResult.COMPLETED:
1405+
flat_results = [0]
14101406

1407+
assert(current_task.locked())
14111408
return flat_results
14121409

14131410
def pack_async_result(i, state):

0 commit comments

Comments
 (0)