Skip to content

Commit a90edd4

Browse files
committed
Remove num_subtasks from Task state and Task.exit guard
1 parent e819a61 commit a90edd4

File tree

3 files changed

+96
-101
lines changed

3 files changed

+96
-101
lines changed

design/mvp/Async.md

Lines changed: 80 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ summary of the motivation and animated sketch of the design in action.
1616
* [Task](#task)
1717
* [Current task](#current-task)
1818
* [Context-Local Storage](#context-local-storage)
19-
* [Subtask and Supertask](#subtask-and-supertask)
2019
* [Structured concurrency](#structured-concurrency)
2120
* [Streams and Futures](#streams-and-futures)
2221
* [Waiting](#waiting)
2322
* [Backpressure](#backpressure)
2423
* [Returning](#returning)
24+
* [Borrows](#borrows)
2525
* [Async ABI](#async-abi)
2626
* [Async Import ABI](#async-import-abi)
2727
* [Async Export ABI](#async-export-abi)
@@ -251,70 +251,64 @@ reason why "context-local" storage is not called "task-local" storage (where a
251251
For details, see [`context.get`] in the AST explainer and [`canon_context_get`]
252252
in the Canonical ABI explainer.
253253

254-
### Subtask and Supertask
255-
256-
Each component-to-component call necessarily creates a new task in the callee.
257-
The callee task is a **subtask** of the calling task (and, conversely, the
258-
calling task is a **supertask** of the callee task. This sub/super relationship
259-
is immutable and doesn't change over time (until the callee task completes and
260-
is destroyed).
261-
262-
The Canonical ABI's Python code represents the subtask relationship between a
263-
caller `Task` and a callee `Task` via the Python [`Subtask`] class. Whereas a
264-
`Task` object is created by each call to [`canon_lift`], a `Subtask` object is
265-
created by each call to [`canon_lower`]. This allows `Subtask`s to store the
266-
state that enforces the caller side of the Canonical ABI rules.
267-
268254
### Structured concurrency
269255

270-
To realize the above goals of always having a well-defined cross-component
271-
async callstack, the Component Model's Canonical ABI enforces [Structured
272-
Concurrency] by dynamically requiring that a task waits for all its subtasks to
273-
[return](#returning) before the task itself is allowed to finish. This means
274-
that a subtask cannot be orphaned and there will always be an async callstack
275-
rooted at an invocation of an export by the host. Moreover, at any one point in
276-
time, the set of tasks active in a linked component graph form a forest of
277-
async call trees which e.g., can be visualized using a traditional flamegraph.
278-
279-
The Canonical ABI's Python code enforces Structured Concurrency by incrementing
280-
a per-task "`num_subtasks`" counter when a subtask is created, decrementing
281-
when the subtask [returns](#returning), and trapping if `num_subtasks > 0` when
282-
a task attempts to exit.
283-
284-
There is a subtle nuance to these Structured Concurrency rules deriving from
285-
the fact that subtasks may continue execution after [returning](#returning)
286-
their value to their caller. The ability to execute after returning value is
287-
necessary for being able to do work off the caller's critical path. A concrete
288-
example is an HTTP service that does some logging or billing operations after
289-
finishing an HTTP response, where the HTTP response is the return value of the
290-
[`wasi:http/handler.handle`] function. Since the `num_subtasks` counter is
291-
decremented when a subtask *returns* (as opposed to *exits*), this means that
292-
subtasks may continue execution even once their supertask has exited. To
293-
maintain Structured Concurrency (for purposes of checking [reentrance],
294-
scheduler prioritization and debugging/observability), we can consider
295-
the supertask to still be alive but in the process of "asynchronously
296-
tail-calling" its still-executing subtasks. (For scenarios where one
297-
component wants to non-cooperatively bound the execution of another
298-
component, a separate "[blast zone]" feature is necessary in any
299-
case.)
300-
301-
This async call tree provided by Structured Concurrency interacts naturally
302-
with the `borrow` handle type and its associated dynamic rules for preventing
303-
use-after-free. When a caller initially lends an `own`ed or `borrow`ed handle
304-
to a callee, a "`num_lends`" counter on the lent handle is incremented when the
305-
subtask starts and decremented when the caller is notified that the subtask has
306-
[returned](#returning). If the caller tries to drop a handle while the handle's
307-
`num_lends` is greater than zero, it traps. Symmetrically, each `borrow` handle
308-
passed to a callee task increments a "`num_borrows`" counter on the task that
309-
is decremented when the `borrow` handle is dropped. With async calls, there can
310-
of course be multiple overlapping async tasks and thus `borrow` handles must
311-
remember which particular task's `num_borrows` counter to drop. If a task
312-
attempts to return (which, for `async` tasks, means calling `task.return`) when
313-
its `num_borrows` is greater than zero, it traps. These interlocking rules for
314-
the `num_lends` and `num_borrows` fields inductively ensure that nested async
315-
call trees that transitively propagate `borrow`ed handles maintain the
316-
essential invariant that dropping an `own`ed handle never destroys a resource
317-
while there is any `borrow` handle anywhere pointing to that resource.
256+
Calling *into* a component creates a `Task` to track ABI state related to the
257+
*callee* (like "number of outstanding borrows"). Calling *out* of a component
258+
creates a `Subtask` to track ABI state related to the *caller* (like "which
259+
handles have been lent"). When one component calls another, there is thus a
260+
`Subtask`+`Task` pair that collectively maintains the overall state of the call
261+
and enforces that both components uphold their end of the ABI contract. But
262+
when the host calls into a component, there is only a `Task` and,
263+
symmetrically, when a component calls into the host, there is only a `Subtask`.
264+
265+
Based on this, the call stack at any point in time when a component calls a
266+
host-defined import will have a callstack of the general form:
267+
```
268+
[Host caller] <- [Task] <- [Subtask+Task]* <- [Subtask] <- [Host callee]
269+
```
270+
Here, the `<-` arrow represents the `supertask` relationship that is immutably
271+
established when first making the call. A paired `Subtask` and `Task` have the
272+
same `supertask` and can thus be visualized as a single node in the callstack.
273+
274+
(These concepts are represented in the Canonical ABI Python code via the
275+
[`Task`] and [`Subtask`] classes.)
276+
277+
One semantically-observable use of this async call stack is to distinguish
278+
between hazardous **recursive reentrance**, in which a component instance is
279+
reentered when one of its tasks is already on the callstack, from
280+
business-as-usual **sibling reentrance**, in which a component instance is
281+
freshly reentered when its other tasks are suspended waiting on I/O. Recursive
282+
reentrance currently always traps, but may be allowed (and indicated to core
283+
wasm) in an opt-in manner in the [future](#TODO).
284+
285+
The async call stack is also useful for non-semantic purposes such as providing
286+
backtraces when debugging, profiling and distributed tracing. While particular
287+
languages can and do maintain their own async call stacks in core wasm state,
288+
without the Component Model's async call stack, linkage *between* different
289+
languages would be lost at component boundaries, leading to a loss of overall
290+
context in multi-component applications.
291+
292+
There is an important nuance to the Component Model's minimal form of
293+
Structured Concurrency compared to Structured Concurrency support that appears
294+
in popular source language features/libraries. Often, "Structured Concurrency"
295+
refers to an invariant that all "child" tasks finish or are cancelled before a
296+
"parent" task completes. However, the Component Model doesn't force subtasks to
297+
[return](#returning) or be cancelled before the supertask returns (this is left
298+
as an option to particular source langauges to enforce or not). The reason for
299+
not enforcing a stricter form of Structured Concurrency at the Component
300+
Model level is that there are important use cases where forcing a supertask to
301+
stay resident simply to wait for a subtask to finish would waste resources
302+
without tangible benefit. Instead, we can say that once the core wasm
303+
implementing a supertask finishes execution, the supertask semantically "tail
304+
calls" any still-live subtasks, staying technically-alive until they complete,
305+
but not consuming real resources. Concretely, this means that a supertask that
306+
finishes executing stays on the callstack of any still-executing subtasks for
307+
the abovementioned purposes until all transitive subtasks finish.
308+
309+
For scenarios where one component wants to *non-cooperatively* put an upper
310+
bound on execution of a call into another component, a separate "[blast zone]"
311+
feature is necessary in any case (due to iloops and traps).
318312

319313
### Streams and Futures
320314

@@ -486,6 +480,28 @@ A task may not call `task.return` unless it is in the "started" state. Once
486480
finish once it is in the "returned" state. See the [`canon_task_return`]
487481
function in the Canonical ABI explainer for more details.
488482

483+
### Borrows
484+
485+
Component Model async support is careful to ensure that `borrow`ed handles work
486+
as expected in an asynchronous setting, extending the dynamic enforcement used
487+
for synchronous code:
488+
489+
When a caller initially lends an `own`ed or `borrow`ed handle to a callee, a
490+
`num_lends` counter on the lent handle is incremented when the subtask starts
491+
and decremented when the caller is notified that the subtask has
492+
[returned](#returning). If the caller tries to drop a handle while the handle's
493+
`num_lends` is greater than zero, the caller traps. Symmetrically, each
494+
`borrow` handle passed to a callee increments a `num_borrows` counter on the
495+
callee task that is decremented when the `borrow` handle is dropped. If a
496+
callee task attempts to return when its `num_borrows` is greater than zero, the
497+
callee traps.
498+
499+
In an asynchronous setting, the only generalization necessary is that, since
500+
there can be multiple overlapping async tasks executing in a component
501+
instance, a borrowed handle must track *which* task's `num_borrow`s was
502+
incremented so that the correct counter can be decremented.
503+
504+
489505
## Async ABI
490506

491507
At an ABI level, native async in the Component Model defines for every WIT
@@ -962,6 +978,7 @@ comes after:
962978
[`Task.enter`]: CanonicalABI.md#task-state
963979
[`Task.wait`]: CanonicalABI.md#task-state
964980
[`Waitable`]: CanonicalABI.md#waitable-state
981+
[`Task`]: CanonicalABI.md#task-state
965982
[`Subtask`]: CanonicalABI.md#subtask-state
966983
[Stream State]: CanonicalABI.md#stream-state
967984
[Future State]: CanonicalABI.md#future-state

design/mvp/CanonicalABI.md

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -469,21 +469,19 @@ class Task:
469469
opts: CanonicalOptions
470470
inst: ComponentInstance
471471
ft: FuncType
472-
caller: Optional[Task]
472+
supertask: Optional[Task]
473473
on_return: Optional[Callable]
474474
on_block: Callable[[Awaitable], Awaitable]
475-
num_subtasks: int
476475
num_borrows: int
477476
context: ContextLocalStorage
478477

479-
def __init__(self, opts, inst, ft, caller, on_return, on_block):
478+
def __init__(self, opts, inst, ft, supertask, on_return, on_block):
480479
self.opts = opts
481480
self.inst = inst
482481
self.ft = ft
483-
self.caller = caller
482+
self.supertask = supertask
484483
self.on_return = on_return
485484
self.on_block = on_block
486-
self.num_subtasks = 0
487485
self.num_borrows = 0
488486
self.context = ContextLocalStorage()
489487
```
@@ -558,7 +556,7 @@ the given arguments into the callee's memory (possibly executing `realloc`)
558556
returning the final set of flat arguments to pass into the core wasm callee.
559557

560558
The `Task.trap_if_on_the_stack` method called by `enter` prevents reentrance
561-
using the `caller` field of `Task` which points to the task's supertask in the
559+
using the `supertask` field of `Task` which points to the task's supertask in the
562560
async call tree defined by [structured concurrency]. Structured concurrency
563561
is necessary to distinguish between the deadlock-hazardous kind of reentrance
564562
(where the new task is a transitive subtask of a task already running in the
@@ -569,10 +567,10 @@ function to opt in (via function type attribute) to the hazardous kind of
569567
reentrance, which will nuance this test.
570568
```python
571569
def trap_if_on_the_stack(self, inst):
572-
c = self.caller
570+
c = self.supertask
573571
while c is not None:
574572
trap_if(c.inst is inst)
575-
c = c.caller
573+
c = c.supertask
576574
```
577575
An optimizing implementation can avoid the O(n) loop in `trap_if_on_the_stack`
578576
in several ways:
@@ -791,7 +789,6 @@ may be a synchronous task unblocked by the clearing of `calling_sync_export`.
791789
```python
792790
def exit(self):
793791
assert(Task.current.locked())
794-
trap_if(self.num_subtasks > 0)
795792
trap_if(self.on_return)
796793
assert(self.num_borrows == 0)
797794
if self.opts.sync:
@@ -805,7 +802,7 @@ may be a synchronous task unblocked by the clearing of `calling_sync_export`.
805802

806803
A "waitable" is anything that can be stored in the component instance's
807804
`waitables` table. Currently, there are 5 different kinds of waitables:
808-
[subtasks](Async.md#subtask-and-supertask) and the 4 combinations of the
805+
[subtasks](Async.md#structured-concurrency) and the 4 combinations of the
809806
[readable and writable ends of futures and streams](Async.md#streams-and-futures).
810807

811808
Waitables deliver "events" which are values of the following `EventTuple` type.
@@ -963,18 +960,10 @@ delivery.
963960
#### Subtask State
964961

965962
While `canon_lift` creates `Task` objects when called, `canon_lower` creates
966-
`Subtask` objects when called. If the callee (being `canon_lower`ed) is another
967-
component's (`canon_lift`ed) function, there will thus be a `Subtask`+`Task`
968-
pair created. However, if the callee is a host-defined function, the `Subtask`
969-
will stand alone. Thus, in general, the call stack at any point in time when
970-
wasm calls a host-defined import will have the form:
971-
```
972-
[Host caller] -> [Task] -> [Subtask+Task]* -> [Subtask] -> [Host callee]
973-
```
974-
975-
The `Subtask` class is simpler than `Task` and only manages a few fields of
976-
state that are relevant to the caller. As with `Task`, this section will
977-
introduce `Subtask` incrementally, starting with its fields and initialization:
963+
`Subtask` objects when called. The `Subtask` class is simpler than `Task` and
964+
only manages a few fields of state that are relevant to the caller. As with
965+
`Task`, this section will introduce `Subtask` incrementally, starting with its
966+
fields and initialization:
978967
```python
979968
class Subtask(Waitable):
980969
state: CallState
@@ -1002,14 +991,9 @@ turn only happens if the call is `async` *and* blocks. In this case, the
1002991
def add_to_waitables(self, task):
1003992
assert(not self.supertask)
1004993
self.supertask = task
1005-
self.supertask.num_subtasks += 1
1006994
Waitable.__init__(self)
1007995
return task.inst.waitables.add(self)
1008996
```
1009-
The `num_subtasks` increment ensures that the parent `Task` cannot `exit`
1010-
without having waited for all its subtasks to return (or, in the
1011-
[future](Async.md#TODO) be cancelled), thereby preserving [structured
1012-
concurrency].
1013997

1014998
The `Subtask.add_lender` method is called by `lift_borrow` (below). This method
1015999
increments the `num_lends` counter on the handle being lifted, which is guarded
@@ -1040,7 +1024,6 @@ its value to the caller.
10401024
def drop(self):
10411025
trap_if(not self.finished)
10421026
assert(self.state == CallState.RETURNED)
1043-
self.supertask.num_subtasks -= 1
10441027
Waitable.drop(self)
10451028
```
10461029

design/mvp/canonical-abi/definitions.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,21 +378,19 @@ class Task:
378378
opts: CanonicalOptions
379379
inst: ComponentInstance
380380
ft: FuncType
381-
caller: Optional[Task]
381+
supertask: Optional[Task]
382382
on_return: Optional[Callable]
383383
on_block: Callable[[Awaitable], Awaitable]
384-
num_subtasks: int
385384
num_borrows: int
386385
context: ContextLocalStorage
387386

388-
def __init__(self, opts, inst, ft, caller, on_return, on_block):
387+
def __init__(self, opts, inst, ft, supertask, on_return, on_block):
389388
self.opts = opts
390389
self.inst = inst
391390
self.ft = ft
392-
self.caller = caller
391+
self.supertask = supertask
393392
self.on_return = on_return
394393
self.on_block = on_block
395-
self.num_subtasks = 0
396394
self.num_borrows = 0
397395
self.context = ContextLocalStorage()
398396

@@ -419,10 +417,10 @@ async def enter(self, on_start):
419417
return lower_flat_values(cx, MAX_FLAT_PARAMS, on_start(), self.ft.param_types())
420418

421419
def trap_if_on_the_stack(self, inst):
422-
c = self.caller
420+
c = self.supertask
423421
while c is not None:
424422
trap_if(c.inst is inst)
425-
c = c.caller
423+
c = c.supertask
426424

427425
def may_enter(self, pending_task):
428426
return not self.inst.backpressure and \
@@ -501,7 +499,6 @@ def return_(self, flat_results):
501499

502500
def exit(self):
503501
assert(Task.current.locked())
504-
trap_if(self.num_subtasks > 0)
505502
trap_if(self.on_return)
506503
assert(self.num_borrows == 0)
507504
if self.opts.sync:
@@ -620,7 +617,6 @@ def __init__(self):
620617
def add_to_waitables(self, task):
621618
assert(not self.supertask)
622619
self.supertask = task
623-
self.supertask.num_subtasks += 1
624620
Waitable.__init__(self)
625621
return task.inst.waitables.add(self)
626622

@@ -638,7 +634,6 @@ def finish(self):
638634
def drop(self):
639635
trap_if(not self.finished)
640636
assert(self.state == CallState.RETURNED)
641-
self.supertask.num_subtasks -= 1
642637
Waitable.drop(self)
643638

644639
#### Stream State

0 commit comments

Comments
 (0)