Coroutines

Overview

Implementing simple no-frills coroutines is surprisingly easy, due to the way the Astrobe compiler creates code:

  • before calling a (non-function) procedure, any computed values in registers have been safely stored, either onto the stack at the location of the corresponding local variables (including value parameters), or into the variables passed as VAR parameters, or into module variables, and
  • the procedure prologue and epilogue can directly be used for the coroutine transfer logic.

Control between coroutines is passed using procedure Coroutines.Transfer. Due to the above, no data registers or computational state need to be preserved upon such a context switch.1

Here’s the most basic implementation. All functionality related to monitoring (stack monitoring, procedure call traces) is omitted for clarity.

TYPE Coroutine

Each coroutine is described by a RECORD:

TYPE
  Coroutine* = POINTER TO CoroutineDesc;
  CoroutineDesc* = RECORD
    sp: INTEGER;
    (* ... *)
  END;
  • SP: stored stack pointer register value

Transfer

CONST SP = 14;

PROCEDURE Transfer*(f, t: Coroutine);
BEGIN
  (* prologue *)
  f.sp := SYSTEM.REG(SP);
  SYSTEM.LDREG(SP, t.sp)
  (* epilogue *)
END Transfer;

Neglecting for now how to initially set up the coroutine to make this work, let’s assume we have two running coroutines, both have done transfers before (“steady state”), and we want to transfer control from f to t, that is, coroutine f calls Coroutine.Transfer(f, t): 2

  1. Upon entry, the stack of f is in use. The prologue pushes the LNK register value, and parameters f and t onto that stack. The stack pointer value is decreased by 12 (four bytes for LNK, f, and t each).
  2. The SP register value is saved as f.sp.
  3. The SP register gets set to t.sp. At this point, t is still accessible on the stack of f. t.sp has been set when coroutine t did its last transfer, ie. when it had the role of f, see the preceding step.
  4. Now, the stack of t is used.
  5. The epilogue restores the LNK register value from the stack of t, and adjusts the stack pointer value by +12. The LNK value has been set when coroutine t did its last transfer, ie. by the corresponding prologue.
  6. The epilogue branches to LNK, hence t continues right after at the point when it last called Transfer.

Note that any kind of coroutine status data could be pushed onto the stack before the switch if ever needed.

Initialisation

For the first invocation via Transfer, the coroutine stack needs to be set up, to a state as if a Coroutines.Transfer had been done before, in order for the first transfer to the new coroutine to work.

PROCEDURE Init*(cor: Coroutine; code: PROCEDURE; stackAdr, stackSize: INTEGER);
BEGIN
  (* ... *)
  cor.sp := stackAdr + stackSize;
  DEC(cor.sp, 12);
  SYSTEM.PUT(cor.sp, SYSTEM.VAL(INTEGER, code));
END Init;
  1. Set the stored stack pointer value cor.sp to the top of the stack memory area.
  2. Decrement cor.sp to allow the “+12” adjustment in the epilogue of the initial Transfer.
  3. Store the coroutine code address on the stack, ie. at the position of the LNK register value.

The stack of the new coroutine is now in a state to allow to be transferred-to from another coroutine.

The First Coroutine

All system and control processes are invoked by the scheduler coroutine, Processes.loop, using Coroutines.Transfer, initially for new coroutines (processes), and then in steady state. Hence we need to set up this first coroutine at the end of the start-up sequence.

Module Processes creates the coroutine RECORD for the scheduler:

VAR loop: Coroutines.Coroutine.
(* ... *)
  NEW(loop); ASSERT(loop # NIL);
(* ... *)

Module Oberon calls Processes.Go at the end of the start-up sequence:3

PROCEDURE Go*;
  VAR jump: Coroutines.Coroutine;
BEGIN
  SYSTEM.LDREG(SP, SchedulerStackTop - 32);
  NEW(jump);
  Coroutines.Init(loop, Loop, SchedulerStackBottom, SchedulerStackSize);
  Coroutines.Transfer(jump, loop)
END Go;

jump is just a temporary data structure to allow this initial transfer. It does not need to be initialised, as we’ll never transfer back to jump, in fact we could not, as it will be collected.

Furthermore, as described here, the stack used by the startup process, including the body of module Oberon, is being reallocated to the scheduler coroutine and the commands handling process. Hence, the stack used by Processes.Go will be overwritten by Coroutines.Init. Therefore, the stack pointer is set to not use the top part of the scheduler stack.


  1. With Astrobe for the Cortex processors, this would not be true if ‘Coroutines.Transfer’ were declared a leaf procedure. Hence in the Cortex version there is a NOTE!, saying “No, this cannot be a leaf procedure!”, since one could be tempted… :) ↩︎

  2. Remember that coroutine transfers are always between explicitly known coroutines. For “anonymous” yielding, a scheduler is required. ↩︎

  3. As does module Errors at the end of the error-recovery procedure. ↩︎