Skip to content

Commit 0ab6048

Browse files
committed
Disallow task.yield under callback, provide callback way to yield
1 parent 979a36c commit 0ab6048

File tree

3 files changed

+108
-60
lines changed

3 files changed

+108
-60
lines changed

design/mvp/CanonicalABI.md

+53-26
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,27 @@ created by `canon_lift` and `Subtask`, which is created by `canon_lower`.
444444
Additional sync-/async-specialized mutable state is added by the `SyncTask`,
445445
`AsyncTask` and `AsyncSubtask` subclasses.
446446

447+
The `Task` class and its subclasses depend on the following two enums:
448+
```python
449+
class AsyncCallState(IntEnum):
450+
STARTING = 0
451+
STARTED = 1
452+
RETURNED = 2
453+
DONE = 3
454+
455+
class EventCode(IntEnum):
456+
CALL_STARTING = AsyncCallState.STARTING
457+
CALL_STARTED = AsyncCallState.STARTED
458+
CALL_RETURNED = AsyncCallState.RETURNED
459+
CALL_DONE = AsyncCallState.DONE
460+
YIELDED = 4
461+
```
462+
The `AsyncCallState` enum describes the linear sequence of states that an async
463+
call necessarily transitions through: [`STARTING`](Async.md#starting),
464+
`STARTED`, [`RETURNING`](Async.md#returning) and `DONE`. The `EventCode` enum
465+
shares common code values with `AsyncCallState` to define the set of integer
466+
event codes that are delivered to [waiting](Async.md#waiting) or polling tasks.
467+
447468
A `Task` object is created for each call to `canon_lift` and is implicitly
448469
threaded through all core function calls. This implicit `Task` parameter
449470
specifies a concept of [the current task](Async.md#current-task) and inherently
@@ -520,8 +541,7 @@ All `Task`s (whether lifted `async` or not) are allowed to call `async`-lowered
520541
imports. Calling an `async`-lowered import creates an `AsyncSubtask` (defined
521542
below) which is stored in the current component instance's `async_subtasks`
522543
table and tracked by the current task's `num_async_subtasks` counter, which is
523-
guarded to be `0` in `Task.exit` (below) to ensure the
524-
tree-structured-concurrency [component invariant].
544+
guarded to be `0` in `Task.exit` (below) to ensure [structured concurrency].
525545
```python
526546
def add_async_subtask(self, subtask):
527547
assert(subtask.supertask is None and subtask.index is None)
@@ -549,7 +569,7 @@ tree-structured-concurrency [component invariant].
549569
if subtask.state == AsyncCallState.DONE:
550570
self.inst.async_subtasks.remove(subtask.index)
551571
self.num_async_subtasks -= 1
552-
return (subtask.state, subtask.index)
572+
return (EventCode(subtask.state), subtask.index)
553573
```
554574
While a task is running, it may call `wait` (via `canon task.wait` or, when a
555575
`callback` is present, by returning to the event loop) to block until there is
@@ -573,6 +593,16 @@ another task:
573593
return self.process_event(self.events.get_nowait())
574594
```
575595

596+
A task may also cooperatively yield the current thread, explicitly allowing
597+
the runtime to switch to another ready task, but without blocking on I/O (as
598+
emulated in the Python code here by awaiting a `sleep(0)`).
599+
```python
600+
async def yield_(self):
601+
self.inst.thread.release()
602+
await asyncio.sleep(0)
603+
await self.inst.thread.acquire()
604+
```
605+
576606
Lastly, when a task exists, the runtime enforces the guard conditions mentioned
577607
above and releases the `thread` lock, allowing other tasks to start or make
578608
progress.
@@ -641,17 +671,6 @@ implementation should be able to avoid separately allocating
641671
`pending_sync_tasks` by instead embedding a "next pending" linked list in the
642672
`Subtask` table element of the caller.
643673

644-
The `AsyncTask` class dynamically checks that the task calls the
645-
`canon_task_start` and `canon_task_return` (defined below) in the right order
646-
before finishing the task. "The right order" is defined in terms of a simple
647-
linear state machine that progresses through the following 4 states:
648-
```python
649-
class AsyncCallState(IntEnum):
650-
STARTING = 0
651-
STARTED = 1
652-
RETURNED = 2
653-
DONE = 3
654-
```
655674
The first 3 fields of `AsyncTask` are simply immutable copies of
656675
arguments/immediates passed to `canon_lift` that are used later on. The last 2
657676
fields are used to check the above-mentioned state machine transitions and also
@@ -1952,10 +1971,16 @@ async def canon_lift(opts, inst, callee, ft, caller, start_thunk, return_thunk):
19521971
if not opts.callback:
19531972
[] = await call_and_trap_on_throw(callee, task, [])
19541973
else:
1955-
[ctx] = await call_and_trap_on_throw(callee, task, [])
1956-
while ctx != 0:
1957-
event, payload = await task.wait()
1958-
[ctx] = await call_and_trap_on_throw(opts.callback, task, [ctx, event, payload])
1974+
[packed_ctx] = await call_and_trap_on_throw(callee, task, [])
1975+
while packed_ctx != 0:
1976+
is_yield = bool(packed_ctx & 1)
1977+
ctx = packed_ctx & ~1
1978+
if is_yield:
1979+
await task.yield_()
1980+
event, payload = (EventCode.YIELDED, 0)
1981+
else:
1982+
event, payload = await task.wait()
1983+
[packed_ctx] = await call_and_trap_on_throw(opts.callback, task, [ctx, event, payload])
19591984

19601985
assert(opts.post_return is None)
19611986
task.exit()
@@ -1983,11 +2008,13 @@ allow the callee to reclaim any memory. An async call doesn't need a
19832008

19842009
Within the async case, there are two sub-cases depending on whether the
19852010
`callback` `canonopt` was set. When `callback` is present, waiting happens in
1986-
an "event loop" inside `canon_lift`. Otherwise, waiting must happen by calling
1987-
`task.wait` (defined below), which potentially requires the runtime
1988-
implementation to use a fiber (aka. stackful coroutine) to switch to another
1989-
task. Thus, `callback` is an optimization for avoiding fiber creation for async
1990-
languages that don't need it (e.g., JS, Python, C# and Rust).
2011+
an "event loop" inside `canon_lift` which also allows yielding (i.e., allowing
2012+
other tasks to run without blocking) by setting the LSB of the returned `i32`.
2013+
Otherwise, waiting must happen by calling `task.wait` (defined below), which
2014+
potentially requires the runtime implementation to use a fiber (aka. stackful
2015+
coroutine) to switch to another task. Thus, `callback` is an optimization for
2016+
avoiding fiber creation for async languages that don't need it (e.g., JS,
2017+
Python, C# and Rust).
19912018

19922019
Uncaught Core WebAssembly [exceptions] result in a trap at component
19932020
boundaries. Thus, if a component wishes to signal an error, it must use some
@@ -2332,9 +2359,8 @@ Python `asyncio.sleep(0)` in the middle to make it clear that other
23322359
coroutines are allowed to acquire the `lock` and execute.
23332360
```python
23342361
async def canon_task_yield(task):
2335-
task.inst.thread.release()
2336-
await asyncio.sleep(0)
2337-
await task.inst.thread.acquire()
2362+
trap_if(task.opts.callback is not None)
2363+
await task.yield_()
23382364
return []
23392365
```
23402366

@@ -2415,6 +2441,7 @@ def canon_thread_hw_concurrency():
24152441
[JavaScript Embedding]: Explainer.md#JavaScript-embedding
24162442
[Adapter Functions]: FutureFeatures.md#custom-abis-via-adapter-functions
24172443
[Shared-Everything Dynamic Linking]: examples/SharedEverythingDynamicLinking.md
2444+
[Structured Concurrency]: Async.md#structured-concurrency
24182445

24192446
[Administrative Instructions]: https://webassembly.github.io/spec/core/exec/runtime.html#syntax-instr-admin
24202447
[Implementation Limits]: https://webassembly.github.io/spec/core/appendix/implementation.html

design/mvp/canonical-abi/definitions.py

+31-14
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,19 @@ def __init__(self, rep, own, scope = None):
384384
self.scope = scope
385385
self.lend_count = 0
386386

387+
class AsyncCallState(IntEnum):
388+
STARTING = 0
389+
STARTED = 1
390+
RETURNED = 2
391+
DONE = 3
392+
393+
class EventCode(IntEnum):
394+
CALL_STARTING = AsyncCallState.STARTING
395+
CALL_STARTED = AsyncCallState.STARTED
396+
CALL_RETURNED = AsyncCallState.RETURNED
397+
CALL_DONE = AsyncCallState.DONE
398+
YIELDED = 4
399+
387400
class Task(CallContext):
388401
caller: Optional[Task]
389402
borrow_count: int
@@ -440,13 +453,18 @@ def process_event(self, subtask):
440453
if subtask.state == AsyncCallState.DONE:
441454
self.inst.async_subtasks.remove(subtask.index)
442455
self.num_async_subtasks -= 1
443-
return (subtask.state, subtask.index)
456+
return (EventCode(subtask.state), subtask.index)
444457

445458
def poll(self):
446459
if self.events.empty():
447460
return None
448461
return self.process_event(self.events.get_nowait())
449462

463+
async def yield_(self):
464+
self.inst.thread.release()
465+
await asyncio.sleep(0)
466+
await self.inst.thread.acquire()
467+
450468
def exit(self):
451469
assert(self.events.empty())
452470
trap_if(self.borrow_count != 0)
@@ -486,12 +504,6 @@ def exit(self):
486504
if self.inst.pending_sync_tasks:
487505
self.inst.pending_sync_tasks.pop(0).set_result(None)
488506

489-
class AsyncCallState(IntEnum):
490-
STARTING = 0
491-
STARTED = 1
492-
RETURNED = 2
493-
DONE = 3
494-
495507
class AsyncTask(Task):
496508
ft: FuncType
497509
start_thunk: Callable
@@ -1367,10 +1379,16 @@ async def canon_lift(opts, inst, callee, ft, caller, start_thunk, return_thunk):
13671379
if not opts.callback:
13681380
[] = await call_and_trap_on_throw(callee, task, [])
13691381
else:
1370-
[ctx] = await call_and_trap_on_throw(callee, task, [])
1371-
while ctx != 0:
1372-
event, payload = await task.wait()
1373-
[ctx] = await call_and_trap_on_throw(opts.callback, task, [ctx, event, payload])
1382+
[packed_ctx] = await call_and_trap_on_throw(callee, task, [])
1383+
while packed_ctx != 0:
1384+
is_yield = bool(packed_ctx & 1)
1385+
ctx = packed_ctx & ~1
1386+
if is_yield:
1387+
await task.yield_()
1388+
event, payload = (EventCode.YIELDED, 0)
1389+
else:
1390+
event, payload = await task.wait()
1391+
[packed_ctx] = await call_and_trap_on_throw(opts.callback, task, [ctx, event, payload])
13741392

13751393
assert(opts.post_return is None)
13761394
task.exit()
@@ -1512,7 +1530,6 @@ async def canon_task_poll(task, ptr):
15121530
### `canon task.yield`
15131531

15141532
async def canon_task_yield(task):
1515-
task.inst.thread.release()
1516-
await asyncio.sleep(0)
1517-
await task.inst.thread.acquire()
1533+
trap_if(task.opts.callback is not None)
1534+
await task.yield_()
15181535
return []

design/mvp/canonical-abi/run_tests.py

+24-20
Original file line numberDiff line numberDiff line change
@@ -534,19 +534,19 @@ async def consumer(task, args):
534534
consumer_heap.memory[argp] = 83
535535
consumer_heap.memory[argp+1] = 84
536536
fut1.set_result(None)
537-
state, callidx = await task.wait()
538-
assert(state == AsyncCallState.STARTED)
537+
event, callidx = await task.wait()
538+
assert(event == EventCode.CALL_STARTED)
539539
assert(callidx == 1)
540540
assert(consumer_heap.memory[retp] == 15)
541541
fut2.set_result(None)
542-
state, callidx = await task.wait()
543-
assert(state == AsyncCallState.RETURNED)
542+
event, callidx = await task.wait()
543+
assert(event == EventCode.CALL_RETURNED)
544544
assert(callidx == 1)
545545
assert(consumer_heap.memory[retp] == 44)
546546
fut3.set_result(None)
547547
assert(task.num_async_subtasks == 1)
548-
state, callidx = await task.wait()
549-
assert(state == AsyncCallState.DONE)
548+
event, callidx = await task.wait()
549+
assert(event == EventCode.CALL_DONE)
550550
assert(callidx == 1)
551551
assert(task.num_async_subtasks == 0)
552552

@@ -567,8 +567,8 @@ async def dtor(task, args):
567567
assert(task.num_async_subtasks == 1)
568568
assert(dtor_value is None)
569569
dtor_fut.set_result(None)
570-
state, callidx = await task.wait()
571-
assert(state == AsyncCallState.DONE)
570+
event, callidx = await task.wait()
571+
assert(event == AsyncCallState.DONE)
572572
assert(callidx == 1)
573573
assert(task.num_async_subtasks == 0)
574574

@@ -623,13 +623,17 @@ async def consumer(task, args):
623623
async def callback(task, args):
624624
assert(len(args) == 3)
625625
if args[0] == 42:
626-
assert(args[1] == AsyncCallState.DONE)
626+
assert(args[1] == EventCode.CALL_DONE)
627627
assert(args[2] == 1)
628+
return [53]
629+
elif args[0] == 52:
630+
assert(args[1] == EventCode.YIELDED)
631+
assert(args[2] == 0)
628632
fut2.set_result(None)
629-
return [43]
633+
return [62]
630634
else:
631-
assert(args[0] == 43)
632-
assert(args[1] == AsyncCallState.DONE)
635+
assert(args[0] == 62)
636+
assert(args[1] == EventCode.CALL_DONE)
633637
assert(args[2] == 2)
634638
[] = await canon_task_return(task, CoreFuncType(['i32'],[]), [83])
635639
return [0]
@@ -693,16 +697,16 @@ async def consumer(task, args):
693697

694698
fut.set_result(None)
695699
assert(producer1_done == False)
696-
state, callidx = await task.wait()
697-
assert(state == AsyncCallState.DONE)
700+
event, callidx = await task.wait()
701+
assert(event == EventCode.CALL_DONE)
698702
assert(callidx == 1)
699703
assert(producer1_done == True)
700704

701705
assert(producer2_done == False)
702706
await canon_task_yield(task)
703707
assert(producer2_done == True)
704-
state, callidx = task.poll()
705-
assert(state == AsyncCallState.DONE)
708+
event, callidx = task.poll()
709+
assert(event == EventCode.CALL_DONE)
706710
assert(callidx == 2)
707711
assert(producer2_done == True)
708712

@@ -748,12 +752,12 @@ async def core_func(task, args):
748752
assert(ret == (2 | (AsyncCallState.STARTED << 30)))
749753

750754
fut1.set_result(None)
751-
state, callidx = await task.wait()
752-
assert(state == AsyncCallState.DONE)
755+
event, callidx = await task.wait()
756+
assert(event == EventCode.CALL_DONE)
753757
assert(callidx == 1)
754758
fut2.set_result(None)
755-
state, callidx = await task.wait()
756-
assert(state == AsyncCallState.DONE)
759+
event, callidx = await task.wait()
760+
assert(event == EventCode.CALL_DONE)
757761
assert(callidx == 2)
758762
return []
759763

0 commit comments

Comments
 (0)