Another weekly post about deferred calls.
But this time there are also other news.
A consistent coding style was introduced with the help of
tests are now running faster, and
debugging may be easier due to enhanced run traces.
Deferred calls took a long time to implement - definitely longer than I expected - and proved not to be the most easy feature to implement. Proper support for this functionality required rearchitecture of how function returns work, and how stacks are unwound. This was because both returns and unwinding must be done in two phases in order to support deferred calls in a way that would be most useful.
Function returns and deferred calls
When a function returns its frame is popped off the stack. If a deferred call has been scheduled inside this frame it must be executed before the frame is destroyed. Before deferred calls were introduced returning was a simple, atomic action (and still is for frames which do not carry deferred calls). Now it is executed in phases.
Phase one is simple.
When the VM encounters a
return instruction it checks if any deferred calls have been registered in
the frame that is being popped.
If there are any, new stacks are registered in the process being executed (one stack per deferred call), the current stack
is suspended, and the VM switches execution to the stack of the last registered deferred call (deferred calls are executed in
the reverse order).
Phase two involves running stacks of deferred calls to completion. After all deferred calls are finished (which may in turn register their own deferred calls, and spawn even more stacks) the execution comes back to the main stack of a process.
Phase three is entered once control returns to the main stack of a process. It involves just popping the frame off the stack, and destroying its contents - local registers, unused parameters and return value.
Exceptions and deferred calls
After an exception is thrown it usually propagates a few frames through the call stack. Once a handler frame is found, the stack must be unwound to restore the context for the handler. This sounds easy, but deferred calls introduce a slight complication as they must be run before their frame is removed from the stack.
Again, this problem is solved by unwinding stack in phases. In phase one the handler frame is found. Then, deferred calls are registered for every frame that would be popped off the stack to restore the context for the handler frame, stack in which the exception was thrown is suspended, and the VM switches execution to the last registered deferred call. In phase two the deferred calls are run to completion, and in phase three the stack is finally unwound.
Tail calls and deferred calls
Deferred calls are run when the frame they were registered in is popped off the stack. Tail calls in Viua involve popping a frame off the stack, and then pushing the frame of the tail called function on it. This means that deferred calls must be invoked when a tail call is issued.
The mechanism supporting this is similar to the ones used in function returns and stack unwinding so it is not described here.
Coding style enforcement
Viua now generates slightly better run traces (enabled by setting the
New trace lines include frame address (so frame usage can be tracked), stack depth (useful when debugging exceptions), and
information about caught values when the caught-slot is not empty.
Faster tests under Valgrind
Runtime of test when Valgrind checking is enabled was cut in half by decreasing the number of loop iterations in the programs that slowed Valgrind down the most. These are the programs testing concurrency in Viua, and the long loops are needed to force the scheduler to preempt the processes.