Skip to content

Commit ebdcc93

Browse files
committed
Trigger backpressure with explicit task.backpressure
1 parent f802a7f commit ebdcc93

File tree

6 files changed

+252
-128
lines changed

6 files changed

+252
-128
lines changed

design/mvp/Async.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ summary of the motivation and animated sketch of the design in action.
1818
* [Subtask and Supertask](#subtask-and-supertask)
1919
* [Structured concurrency](#structured-concurrency)
2020
* [Waiting](#waiting)
21+
* [Backpressure](#backpressure)
2122
* [Starting](#starting)
2223
* [Returning](#returning)
2324
* [Examples](#examples)
@@ -209,6 +210,20 @@ same thing which, in the Canonical ABI Python code, is factored out into
209210
[`Task`]'s `wait` method. Thus, the difference between `callback` and
210211
non-`callback` is mostly one of optimization, not expressivity.
211212

213+
### Backpressure
214+
215+
Once a component exports asynchronously-lifted functions, multiple concurrent
216+
export calls can start piling up, each consuming some of the component's finite
217+
private resources (like linear memory), requiring the component to be able to
218+
exert *backpressure* to allow some tasks to finish (and release private
219+
resources) before admitting new async export calls. To do this, a component may
220+
call the `task.backpressure` built-in to set a "backpressure" flag that causes
221+
subsequent export calls to immediately return in the [starting](#starting)
222+
state without calling the component's Core WebAssembly code.
223+
224+
See the [`canon_task_backpressure`] function and [`Task.enter`] method in the
225+
Canonical ABI explainer for the setting and implementation of backpressure.
226+
212227
### Starting
213228

214229
When a component asynchronously lifts a function, instead of the function
@@ -217,12 +232,10 @@ asynchronously-lifted Core WebAssembly function is passed an empty list of
217232
arguments and must instead call an imported [`task.start`] built-in to lower
218233
and receive its arguments.
219234

220-
The main reason to have `task.start` is so that an overloaded component
221-
instance can [wait](#waiting) for existing tasks to finish before starting a
222-
new task. Once a component instance waits to start a task, the Component Model
223-
will automatically apply backpressure, preventing new tasks from starting.
224-
See the [`AsyncTask`] class and [`canon_task_start`] function in the Canonical
225-
ABI explainer for more details.
235+
The main reason to have `task.start` is so that an overloaded component can
236+
[wait](#waiting) and/or exert [backpressure](#backpressure) before accepting
237+
the arguments to an export call. See the [`canon_task_start`] function in the
238+
Canonical ABI explainer for more details.
226239

227240
Before a task has called `task.start`, it is considered in the "starting"
228241
state. After calling `task.start`, the task is in a "started" state.
@@ -469,9 +482,11 @@ features will be added in future chunks to complete "async" in Preview 3:
469482
[`canon_lower`]: CanonicalABI.md#canon-lower
470483
[`canon_lower`]: CanonicalABI.md#canon-task-wait
471484
[`canon_task_wait`]: CanonicalABI.md#-canon-taskwait
485+
[`canon_task_backpressure`]: CanonicalABI.md#-canon-taskbackpressure
472486
[`canon_task_start`]: CanonicalABI.md#-canon-taskstart
473487
[`canon_task_return`]: CanonicalABI.md#-canon-taskreturn
474488
[`Task`]: CanonicalABI.md#runtime-state
489+
[`Task.enter`]: CanonicalABI.md#runtime-state
475490
[`Subtask`]: CanonicalABI.md#runtime-state
476491
[`AsyncTask`]: CanonicalABI.md#runtime-state
477492

design/mvp/Binary.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,12 @@ canon ::= 0x00 0x00 f:<core:funcidx> opts:<opts> ft:<typeidx> => (canon lift
275275
| 0x04 rt:<typeidx> => (canon resource.rep rt (core func))
276276
| 0x05 ft:<typeidx> => (canon thread.spawn ft (core func))
277277
| 0x06 => (canon thread.hw_concurrency (core func))
278-
| 0x08 ft:<core:typeidx> => (canon task.start ft (core func))
279-
| 0x09 ft:<core:typeidx> => (canon task.return ft (core func))
280-
| 0x0a => (canon task.wait (core func))
281-
| 0x0b => (canon task.poll (core func))
282-
| 0x0c => (canon task.yield (core func))
278+
| 0x08 => (canon task.backpressure (core func))
279+
| 0x09 ft:<core:typeidx> => (canon task.start ft (core func))
280+
| 0x0a ft:<core:typeidx> => (canon task.return ft (core func))
281+
| 0x0b => (canon task.wait (core func))
282+
| 0x0c => (canon task.poll (core func))
283+
| 0x0d => (canon task.yield (core func))
283284
opts ::= opt*:vec(<canonopt>) => opt*
284285
canonopt ::= 0x00 => string-encoding=utf8
285286
| 0x01 => string-encoding=utf16

design/mvp/CanonicalABI.md

Lines changed: 91 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ being specified here.
2424
* [`canon resource.new`](#canon-resourcenew)
2525
* [`canon resource.drop`](#canon-resourcedrop)
2626
* [`canon resource.rep`](#canon-resourcerep)
27+
* [`canon task.backpressure`](#-canon-taskbackpressure) 🔀
2728
* [`canon task.start`](#-canon-taskstart) 🔀
2829
* [`canon task.return`](#-canon-taskreturn) 🔀
2930
* [`canon task.wait`](#-canon-taskwait) 🔀
@@ -255,36 +256,44 @@ Canonical ABI:
255256
class ComponentInstance:
256257
# core module instance state
257258
may_leave: bool
258-
may_enter_sync: bool
259-
may_enter_async: bool
260-
pending_sync_tasks: list[asyncio.Future]
261-
pending_async_tasks: list[asyncio.Future]
262259
handles: HandleTables
260+
num_tasks: int
261+
backpressure: bool
262+
pending_tasks: list[asyncio.Future]
263+
active_sync_task: bool
264+
pending_sync_tasks: list[asyncio.Future]
263265
async_subtasks: Table[AsyncSubtask]
264266
fiber: asyncio.Lock
265267

266268
def __init__(self):
267269
self.may_leave = True
268-
self.may_enter_sync = True
269-
self.may_enter_async = True
270-
self.pending_sync_tasks = []
271-
self.pending_async_tasks = []
272270
self.handles = HandleTables()
271+
self.num_tasks = 0
272+
self.backpressure = False
273+
self.pending_tasks = []
274+
self.active_sync_task = False
275+
self.pending_sync_tasks = []
273276
self.async_subtasks = Table[AsyncSubtask]()
274277
self.fiber = asyncio.Lock()
275278
```
276279
The `may_leave` field is used below to track whether the instance may call a
277280
lowered import to prevent optimization-breaking cases of reentrance during
278281
lowering.
279282

280-
The `may_enter_(sync|async)` and `pending_(sync|async)_tasks` fields
281-
are used below to implement backpressure that is applied when new
282-
sync|async-lifted export calls try to enter this `ComponentInstance`.
283-
284283
The `handles` field contains a mapping from `ResourceType` to `Table`s of
285284
`HandleElem`s (defined next), establishing a separate `i32`-indexed array per
286285
resource type.
287286

287+
The `backpressure` and `pending_tasks` fields are used below to implement
288+
backpressure that is applied when new export calls create new `Task`s in this
289+
`ComponentInstance`. The `num_tasks` field tracks the number of live `Task`s in
290+
this `ComponentInstance` and is primarily used to guard that a component
291+
doesn't enter an invalid state where `backpressure` enabled but there are no
292+
live tasks to disable it.
293+
294+
The `active_sync_task` and `pending_sync_tasks` fields are similarly used to
295+
serialize synchronously-lifted calls into this component instance.
296+
288297
The `async_subtasks` field is used below to track and assign an `i32` index to
289298
each active async-lowered call in progress that has been made by this
290299
`ComponentInstance`.
@@ -492,17 +501,30 @@ the particular feature (`borrow` handles, `async` imports) is not used.
492501

493502
The `caller` field is immutable and is either `None`, when a `Task` is created
494503
for a component export called directly by the host, or else the current task
495-
when the calling component called into this component. The `caller` field is
496-
used by the following two methods to prevent a component from being reentered
497-
(enforcing the [component invariant]) in a way that is well-defined even in the
498-
presence of async calls). (The `fiber.acquire()` call in `enter()` is
504+
when the calling component called into this component.
505+
506+
The `enter()` method is called immediately after constructing the `Task` and is
507+
responsible for implementing backpressure that has been signalled by guest code
508+
via the `task.backpressure` built-in. (The `fiber.acquire()` call in `enter()` is
499509
described above and here ensures that concurrent export calls do not
500510
arbitrarily interleave.)
501511
```python
502512
async def enter(self):
503513
await self.inst.fiber.acquire()
504514
self.trap_if_on_the_stack(self.inst)
505-
515+
self.inst.num_tasks += 1
516+
if self.inst.backpressure or self.inst.pending_tasks:
517+
f = asyncio.Future()
518+
self.inst.pending_tasks.append(f)
519+
await f
520+
assert(not self.inst.backpressure)
521+
```
522+
The `caller` field mentioned above is used by `trap_if_on_the_stack` (called by
523+
`enter` above) to prevent a component from being unexpectedly reentered
524+
(enforcing the [component invariant]) in a way that is well-defined even in the
525+
presence of async calls). This definition depends on having an async call tree
526+
which in turn depends on maintaining [structured concurrency].
527+
```python
506528
def trap_if_on_the_stack(self, inst):
507529
c = self.caller
508530
while c is not None:
@@ -522,6 +544,16 @@ O(n) loop in `trap_if_on_the_stack`:
522544
a packed bit-vector (assigning each potentially-reenterable async component
523545
instance a static bit position) that is passed by copy from caller to callee.
524546

547+
The `pending_tasks` queue (appended to by `enter` above) is emptied one at a
548+
time when backpressure is disabled, ensuring that each popped tasks gets a
549+
chance to start and possibly re-enable backpressure before the next pending
550+
task is started:
551+
```python
552+
def maybe_start_pending_task(self):
553+
if self.inst.pending_tasks and not self.inst.backpressure:
554+
self.inst.pending_tasks.pop(0).set_result(None)
555+
```
556+
525557
The `borrow_count` field is used by the following methods to track the number
526558
of borrowed handles that were passed as parameters to the export that have not
527559
yet been dropped (and thus might dangle if the caller destroys the resource
@@ -559,6 +591,7 @@ guarded to be `0` in `Task.exit` (below) to ensure [structured concurrency].
559591

560592
async def wait(self):
561593
self.inst.fiber.release()
594+
self.maybe_start_pending_task()
562595
subtask = await self.events.get()
563596
await self.inst.fiber.acquire()
564597
return self.process_event(subtask)
@@ -599,6 +632,7 @@ emulated in the Python code here by awaiting a `sleep(0)`).
599632
```python
600633
async def yield_(self):
601634
self.inst.fiber.release()
635+
self.maybe_start_pending_task()
602636
await asyncio.sleep(0)
603637
await self.inst.fiber.acquire()
604638
```
@@ -609,9 +643,13 @@ progress.
609643
```python
610644
def exit(self):
611645
assert(self.events.empty())
646+
assert(self.inst.num_tasks >= 1)
647+
trap_if(self.inst.backpressure and self.inst.num_tasks == 1)
612648
trap_if(self.borrow_count != 0)
613649
trap_if(self.num_async_subtasks != 0)
650+
self.inst.num_tasks -= 1
614651
self.inst.fiber.release()
652+
self.maybe_start_pending_task()
615653
```
616654

617655
While `canon_lift` creates `Task`s, `canon_lower` creates `Subtask` objects:
@@ -649,20 +687,23 @@ given component instance at a given time.
649687
```python
650688
class SyncTask(Task):
651689
async def enter(self):
652-
if not self.inst.may_enter_sync:
690+
await super().enter()
691+
if self.inst.active_sync_task:
653692
f = asyncio.Future()
654693
self.inst.pending_sync_tasks.append(f)
694+
self.inst.fiber.release()
695+
self.maybe_start_pending_task()
655696
await f
656-
assert(self.inst.may_enter_sync)
657-
self.inst.may_enter_sync = False
658-
await super().enter()
697+
await self.inst.fiber.acquire()
698+
assert(not self.inst.active_sync_task)
699+
self.inst.active_sync_task = True
659700

660701
def exit(self):
661-
super().exit()
662-
assert(not self.inst.may_enter_sync)
663-
self.inst.may_enter_sync = True
702+
assert(self.inst.active_sync_task)
703+
self.inst.active_sync_task = False
664704
if self.inst.pending_sync_tasks:
665705
self.inst.pending_sync_tasks.pop(0).set_result(None)
706+
super().exit()
666707
```
667708
Thus, after one sync task starts running, any subsequent attempts to call into
668709
the same component instance before the first sync task finishes will wait in a
@@ -672,74 +713,37 @@ implementation should be able to avoid separately allocating
672713
`Subtask` table element of the caller.
673714

674715
The first 3 fields of `AsyncTask` are simply immutable copies of
675-
arguments/immediates passed to `canon_lift` that are used later on. The last 2
676-
fields are used to check the above-mentioned state machine transitions and also
677-
specify an async version of backpressure. In particular, the rules apply
678-
backpressure if a task blocks (calling `wait`) while still in the `STARTING`
679-
state, which signals that the component instance isn't ready to take on any new
680-
async calls (until some active calls finish):
716+
arguments/immediates passed to `canon_lift` and are used by the `task.start`
717+
and `task.return` built-ins below. The last field is used to check the
718+
above-mentioned state machine transitions from methods that are called by
719+
`task.start`, `task.return` and `canon_lift` below.
681720
```python
682721
class AsyncTask(Task):
683722
ft: FuncType
684723
on_start: Callable
685724
on_return: Callable
686725
state: AsyncCallState
687-
unblock_next_pending: bool
688726

689727
def __init__(self, opts, inst, caller, ft, on_start, on_return):
690728
super().__init__(opts, inst, caller)
691729
self.ft = ft
692730
self.on_start = on_start
693731
self.on_return = on_return
694732
self.state = AsyncCallState.STARTING
695-
self.unblock_next_pending = False
696-
697-
async def enter(self):
698-
if not self.inst.may_enter_async or self.inst.pending_async_tasks:
699-
f = asyncio.Future()
700-
self.inst.pending_async_tasks.append(f)
701-
await f
702-
assert(self.inst.may_enter_async)
703-
self.unblock_next_pending = len(self.inst.pending_async_tasks) > 0
704-
await super().enter()
705-
706-
async def wait(self):
707-
if self.state == AsyncCallState.STARTING:
708-
self.inst.may_enter_async = False
709-
else:
710-
self.maybe_unblock_next_pending()
711-
return await super().wait()
712-
713-
def maybe_unblock_next_pending(self):
714-
if self.unblock_next_pending:
715-
self.unblock_next_pending = False
716-
assert(self.inst.may_enter_async)
717-
self.inst.pending_async_tasks.pop(0).set_result(None)
718733

719734
def start(self):
720735
trap_if(self.state != AsyncCallState.STARTING)
721736
self.state = AsyncCallState.STARTED
722-
if not self.inst.may_enter_async:
723-
self.inst.may_enter_async = True
724737

725738
def return_(self):
726739
trap_if(self.state != AsyncCallState.STARTED)
727740
self.state = AsyncCallState.RETURNED
728741

729742
def exit(self):
730-
super().exit()
731743
trap_if(self.state != AsyncCallState.RETURNED)
732744
self.state = AsyncCallState.DONE
733-
self.maybe_unblock_next_pending()
745+
super().exit()
734746
```
735-
The above rules are careful to release pending async calls from the queue one
736-
at a time (rather than unblocking all of them at once). This ensures that, in
737-
all cases, every new task has a chance to apply backpressure before the next
738-
new task starts.
739-
740-
Note that the backpressure rules described above apply independently to sync
741-
and async tasks and thus if a component exports both sync- *and* async-lifted
742-
functions, async functions may execute concurrently with sync functions.
743747

744748
Finally, the `AsyncSubtask` class extends `Subtask` with fields that are used
745749
by the methods of `Task`, as shown above. `AsyncSubtask`s have the same linear
@@ -2222,6 +2226,27 @@ async def canon_resource_rep(rt, task, i):
22222226
Note that the "locally-defined" requirement above ensures that only the
22232227
component instance defining a resource can access its representation.
22242228

2229+
### 🔀 `canon task.backpressure`
2230+
2231+
For a canonical definition:
2232+
```wasm
2233+
(canon task.backpressure (core func $f))
2234+
```
2235+
validation specifies:
2236+
* `$f` is given type `[i32] -> []`
2237+
2238+
Calling `$f` invokes the following function, which sets the `backpressure`
2239+
flag on the current `ComponentInstance`:
2240+
```python
2241+
async def canon_task_backpressure(task, flat_args):
2242+
trap_if(task.opts.sync)
2243+
task.inst.backpressure = bool(flat_args[0])
2244+
return []
2245+
```
2246+
The `backpressure` flag is read by `Task.enter` (defined above) to prevent new
2247+
tasks from entering the component instance and forcing the guest code to
2248+
consume resources.
2249+
22252250
### 🔀 `canon task.start`
22262251

22272252
For a canonical definition:

design/mvp/Explainer.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,7 @@ canon ::= ...
13131313
| (canon resource.new <typeidx> (core func <id>?))
13141314
| (canon resource.drop <typeidx> async? (core func <id>?))
13151315
| (canon resource.rep <typeidx> (core func <id>?))
1316+
| (canon task.backpressure (core func <id>?)) 🔀
13161317
| (canon task.start <core:typeidx> (core func <id>?)) 🔀
13171318
| (canon task.return <core:typeidx> (core func <id>?)) 🔀
13181319
| (canon task.wait (core func <id>?)) 🔀
@@ -1374,15 +1375,19 @@ transferring ownership of the newly-created resource to the export's caller.
13741375
See the [async explainer](Async.md) for high-level context and terminology
13751376
and the [Canonical ABI explainer] for detailed runtime semantics.
13761377

1378+
The `task.backpressure` built-in has type `[i32] -> []` and allows the
1379+
async-lifted callee to toggle a per-component-instance flag that, when set,
1380+
prevents new incoming export calls to the component (until the flag is unset).
1381+
This allows the component to exert [backpressure](Async.md#backpressure).
1382+
13771383
The `task.start` built-in returns the arguments to the currently-executing
13781384
task. This built-in must be called from an `async`-lifted export exactly once
1379-
per export activation. Delaying the call to `task.start` allows the async
1380-
callee to exert *backpressure* on the caller. The `canon task.start` definition
1381-
takes the type index of a core function type and produces a core function with
1382-
exactly that type. When called, the declared core function type is checked
1383-
to match the lowered function type of a component-level function returning the
1384-
parameter types of the current task. (See also [Starting](Async.md#starting) in
1385-
the async explainer and [`canon_task_start`] in the Canonical ABI explainer.)
1385+
per export activation. The `canon task.start` definition takes the type index
1386+
of a core function type and produces a core function with exactly that type.
1387+
When called, the declared core function type is checked to match the lowered
1388+
function type of a component-level function returning the parameter types of
1389+
the current task. (See also [Starting](Async.md#starting) in the async
1390+
explainer and [`canon_task_start`] in the Canonical ABI explainer.)
13861391

13871392
The `task.return` built-in takes as parameters the result values of the
13881393
currently-executing task. This built-in must be called from an `async`-lifted

0 commit comments

Comments
 (0)