Description
Motivation
Wasm currently lacks any support for multiple stacks of execution and switching between them.
That prevents it from efficiently representing most control flow abstractions that appear in many modern programming languages, such as
- lightweight (“green”) threads
- coroutines
- generators
- async/await
- effect handlers
- call/cc
- …
In the spirit of Wasm as a low-level engine, it's highly undesirable to add a multitude of ad-hoc features for each of this mechanisms individually.
Instead, it's preferable to identify a sufficiently general mechanism for managing execution stacks that allows expressing all of them.
More concretely, Wasm needs a way to represent, create, and switch between stacks.
Challenges
-
In order to be able to express features like green threads and others, switching stacks must be possible at any stack depth, not just at the bottom of the stack (“deep coroutines”).
-
In order to be able to express features like generators, it must be possible to pass values (back and forth!) when switching to a different stack.
-
In order to be able to validate the usage of stacks, both stacks and the values being passed when switching must be properly typed.
-
In order to inform the semantics of the interaction with other control flow, esp exceptions, it is desirable to have a semantic framework that can explain all such constructs.
-
In order for multiple of the above features to be usable at the same time, they must be expressible in a composable fashion that allows arbitrary nesting.
-
The mechanism must not require copying or moving stack frames, since some engines could not easily do that due to the heterogeneous nature of their stacks.
-
It must not be possible to create cyclic references to stacks, since that would require GC.
Proposal
One known way to address the above challenges is to view stacks slightly more abstractly, through the notion of a delimited continuation.
Broadly speaking, that represents “the rest of” a computation (a continuation) “up to” a certain point (delimited).
Concretely, that certain point is the bottom of a specific stack, while the stack frames on the stack essentially describe the remaining tasks to be completed.
In particular, we can apply known ways to type such continuations, which is not a problem with an immediately obvious solution otherwise.
One specific way of typing continuations and the values communicated back and forth is by following the approach taken by so-called effect handlers, one modern way of representing delimited continuations, which essentially are exception handlers with resumption, where both throw and resume can carry along arguments.
Like with asymmetric coroutines, switching between stacks then occurs through a pair of resume and suspend instructions.
The suspend instruction “throws” a value akin to an exception — we call it an event below — which the resume handles.
The event's tag, like a regular exception tag, determines the types of values passed from suspend to resume point, but also those passed back.
Details
The first central addition is a new form of resumable exception, a.k.a. event.
Such an event is declared with a respective new form of exception definition
(in the binary format this is an exception definition with a certain flag bit):
(event $evt (param tp*) (result tr*))
Unlike a regular exception, events can have return values.
The other central addition is a new (reference) type of continuations:
(cont $ft)
where $ft
is a type index denoting a function type [t1*] -> [t2*]
.
Intuitively, this describes a suspended stack that is resumable with values of types t1*
and will ultimately terminate with values of types t2*
.
A new stack is created with the instruction
cont.new : [(ref $ft)] -> [(cont $ft)]
which takes a function reference to run on the new stack.
Execution isn’t started before resuming the continuation.
The instruction
cont.resume (event $evt $handler)* : [t1* (cont $ft)] -> [t2*]
resumes such a continuation, by passing it the expected arguments t1*
.
Once the computation terminates, it returns with t2*
.
If the computation suspends before termination (see next instruction), with one of the event tags listed, then execution branches to the respective label $handler
.
This happens without unwinding the stack.
The label receives the event arguments tp*
and a continuation of type (cont $ft')
, where $ft'
is the function type [tr*] -> [t2*]
of the next continuation.
Continuations are single-shot, that is, they cannot be resumed more than once.
An attempt to resume an already resumed continuation traps.
A computation running on its own stack (i.e., initiated as a continuation), can suspend itself with the following instruction:
cont.suspend $evt : [tp*] -> [tr*]
where $evt
is an event of respective type [tp*] -> [tr*]
.
Essentially, this passes control back to the parent stack.
That is, like throw
the exception takes arguments tp*
, and transfers control to the innermost cont.resume
, or rather, its handler label, respectively.
But unlike regular exceptions, execution can also be resumed, which returns here with values of type tr*
.
As described above, the innermost active handler matching the event tag receives the event's tp*
and a new continuation.
Resuming this continuation (with cont.resume
) will therefore pass back tr*
to the originating cont.suspend
.
Resumption can nest, i.e., a continuation may itself resume another continuation.
Consequently, an event may generally pass through multiple parent stacks before hitting a matching resume handler.
The same chain of stacks is maintained when resuming.
Note how the exception tag ties together the types of the values passed in both direction between a matching suspend and resume.
Different suspension points within the same computation can use different exception tags and thereby pass different types of values back and forth between parent and child stack.
Continuations are first-class values that are allowed to escape a handler.
That way, a stack can be kept live to be resumed at a later point.
The stack’s lifetime ends when the final resume terminates regularly (or with an exception, see below).
Managing the lifetime of continuations/stacks and evtrefs generally requires reference counting in the VM.
However, it’s impossible for them to form cycles, so general garbage collection is not needed.
Finally,
cont.throw $exn : [te* (cont $ft)] -> [t2*]
aborts a continuation by injecting the (regular) exception $exn
with arguments of type te*
at the suspension point.
This is a way to explicitly unwind the stack associated with the continuation.
In practice, a compiler should make sure that every continuation is used linearly, i.e., either resumed or aborted with a throw.
However, there is no simple way to enforce this in a type system, so Wasm validation won’t be able to check this constraint.
Example: A simple generator
To see how these mechanisms compose, consider encoding a generator enumerating i64 integers from 0 up until a certain condition is met.
The consumer can signal back this condition by returning a bool (i32) to the generator.
This can be expressed by the following event.
(event $enum-yield (param i64) (result i32))
Here is the actual generator:
(func $enum-until
(param $b i32)
(local $n i64)
(local.set $n (i64.const -1))
(br_if 0 (local.get $b))
(loop $l
(local.set $n (i64.add (local.get $n) (i64.const 1)))
(cont.suspend $enum-yield (local.get $n))
(br_if $l)
)
)
Here is one possible consumer, which runs the generator until a given max value is reached:
(func $run-upto (param $max i64)
(local $n i64)
(local $cont (cont (param i32)))
(local.set $cont (cont.new $enum-until))
(loop $l
(block $h (result i64 (cont (param i32)))
(cont.resume (event $enum-yield $h)
(i64.ge_u (local.get $n) (local.get $max))
(local.get $cont)
)
(return)
)
(local.set $cont)
(local.set $n)
;; ...process $n...
(br $l)
)
)
Note how the handler $h
takes both the i64 argument produced by the generator and the next continuation.
Example: A simple thread scheduler
For a more interesting example, consider a simple scheduler for green threads.
We define two events with which a thread can signal the scheduler to either yield or spawn a new thread:
(event $yield)
(event $spawn (param (ref $proc)))
where
(type $proc (func))
We further assume that there is a global variable encoding a thread queue in form of a list of coninuations:
(global $queue (list-of (cont $proc)) ...)
The exact representation does not matter, so list-of
is just pseudo code.
All we need is three obvious operations on this queue, ignoring their details:
(func $enqueue (param (cont $proc)) …)
(func $dequeue (result (cont $proc)) …)
(func $queue_empty (result i32) …)
Given these prerequisites, a simple scheduler loop can be implemented as follows:
(func $scheduler (param $main (ref $proc))
(cont.new (local.get $main)) (call $enqueue)
(loop $l
(if (call $queue_empty) (then (return))) ;; program is done
(block $on_yield (result (cont $proc))
(block $on_spawn (result (ref $proc) (cont $proc))
(call $dequeue)
(cont.resume (event $yield $on_yield) (event $spawn $on_spawn))
(br $l) ;; thread terminated
)
;; on $spawn, proc and cont on stack
(call $enqueue) ;; continuation of old thread
(cont.new) (call $enqueue) ;; new thread
(br $l)
)
;; on $yield, cont on stack
(call $enqueue)
(br $l)
)
)
TODO: More examples.
Interaction with regular exceptions
Regular exceptions and events are disjoint.
An exception handler will not catch events and vice versa.
If resuming a continuation throws a regular exception that is uncaught on its stack then it propagates through the active resume, unwinding the stack encapsulated in the continuation and ending its lifetime.
Yielding an event on the other hand does not trigger exception handlers between the suspend and the resume point.
They become active again when the continuation resumes and execution thereby switches back to their stack.
Note that this dichotomy could, in principle, be used to implement exceptions with two-phase unwind: the first phase is represented by an event, whose handler then injects back a proper exception to unwind. However, such a an implementation would be fairly expensive, since every exception handler would have to run on its own stack.
It is still useful as a semantic explanation of what ought to happen, and how two-phase unwind would interact with single-phase exceptions.