Skip to content

Actions and Action Schedulers in zeptoforth

tabemann edited this page Mar 31, 2023 · 6 revisions

Introduction

For many purposes multitasking is overly heavyweight in zeptoforth for simple asynchronous execution, as each task needs considerable space for its stacks and dictionary space. Furthermore, switching from one task to another has a non-negligible overhead. As a result an alternative means of executing multiple lightweight asynchronous actions within a single task is provided by zeptoforth. Each of these does not have any stacks or dictionary space of its own and are switched between with less overhead than tasks. Each of these actions belongs to a single action scheduler which executes on a single task. This is very well-suited for when very many elements of asynchronous execution are needed.

Actions are non-preemptive within a given action schedule, even though the task the action schedule executes on is preemptive. They do not have priorities, unlike tasks, and execute in a purely deterministic, round-robin fashion. They also are inherently single-core; there is no way to run an action schedule across multiple cores, so if multicore operation is needed multiple action schedules are needed (but this brings its own limitations, such as not being able to send messages between actions on different cores).

Actions may be added to or removed from action schedules at any time, with add-action and remove-action respectively, both when the action schedules are running when they are not running. On the other hand, an action schedule may only be run when it is not already running with run-schedule, which runs it on the current task. run-schedule will return, stopping execution of the action schedule in a safe fashion, when stop-schedule is run against the schedule.

Actions may send and receive messages, both with or without timeouts, and in the case of sending a message they may also catch send failure without timing out. Message-passing by actions is purely synchronous; a sending action blocks until it can successfully send a message or until it times out or sending the message becomes undeliverable, and likewise a receiving action blocks until it can successfully receive a message or until it times out. An action may only send or receive one message at a time. For these reasons, actions do not count as actors. Also note that only actions can send messages to other actions, and actions can only send messages to other actions within the same action schedule.

Each action must carry out one and only one action operation while it has control; if it does not, it will be removed from its action schedule, and if it attempts to do so more than once, x-operation-set will be raised. The various action operations that an action can carry out are to yield with yield-action, to delay with delay-action or delay-action-from-time, to send a message with send-action, send-action-fail, or send-action-timeout, or to receive a message with recv-action or recv-action-timeout. Note that these operations are only applied once an action relinquishes control. All action operations assume the current action schedule and the current action, and there is no way to execute them against any other action schedule or action; they also cannot be called outside of an action for this reason.

Each action has an associated cell of data which is set when the action is initialized, which is returned for the current action by current-data, and which can be gotten for another action with action-data@. This is very useful to associating an action with a structure containing action-local variables, since actions themselves lack dictionaries or stacks to store data in.

Action Pools

A convenient means to manage multiple actions is with an action pool, which are defined in the action-pool module. Action pools are memory pools dedicated to managing actions, which consist of any number of potential actions that may be allocated to start new actions in a schedule. An action pool is initialized with init-action-pool, which takes an action count and a pointer to a block of memory to initialize the action pool in; this block of memory must be of at least the size in bytes specified by action-pool-size, which takes an action count.

Allocating an action from an action pool is done with add-action-from-pool, which takes a schedule, any arbitrary data (e.g. a pointer to a data structure) to be used by the action, an execution token to first execute, and an action pool, and returns the newly allocated action. The action is immediately added to the specified schedule. The specified data is the data returned by action-data@ called against the action and by current-data within the action. Any free action may be allocated; actions that terminate for any reason, e.g. due to not specifying a continuation, are free to be allocated again from an action pool. If there are no free actions in an action pool when one calls add-action-from-pool against it, x-no-action-available will be raised.

Examples

For a simple example of actions in use, consider the following:

task import  ok
action import  ok
schedule-size buffer: my-schedule  ok
my-schedule init-schedule  ok
: delay. current-data . ;  ok
: do-delay ['] delay. current-data delay-action ;  ok
action-size buffer: my-action0  ok
action-size buffer: my-action1  ok
action-size buffer: my-action2  ok
action-size buffer: my-action3  ok
10000 ' do-delay my-action0 init-action  ok
20000 ' do-delay my-action1 init-action  ok
40000 ' do-delay my-action2 init-action  ok
80000 ' do-delay my-action3 init-action  ok
my-schedule my-action0 add-action  ok
my-schedule my-action1 add-action  ok
my-schedule my-action2 add-action  ok
my-schedule my-action3 add-action  ok
my-schedule 1 ' run-schedule 256 128 512 spawn run  ok
10000 20000 40000 80000 

In this example we create four actions that delay for a set amount of time, in ticks (normally 100 microseconds per tick), and then prints out how long it delayed for before exiting. We also create a task to run run-schedule is, with our schedule, my-schedule. When it was executed it waited one second before printing 10000, two seconds before printing 20000, four seconds before printing 40000, and eight seconds before printing 80000.

For an example of message-passing with actions, take the following:

task import  ok
action import  ok
schedule-size buffer: my-schedule  ok
action-size buffer: consumer-action  ok
action-size buffer: producer-action  ok
cell buffer: consumer-buf  ok
defer lockup  ok
:noname ['] lockup yield-action ; ' lockup defer!  ok
defer consumer  ok
:noname  ok
  [: 2drop @ dup . 9 < if ['] consumer else ['] lockup then yield-action ;]  ok
  consumer-buf cell recv-action  ok
; ' consumer defer!  ok
cell buffer: producer-buf  ok
defer producer  ok
:noname  ok
  [: ." * " 1 producer-buf +! ['] producer 2500 delay-action ;]  ok
  [: ." timed out" ;]  ok
  producer-buf cell consumer-action 10000 send-action-timeout  ok
; ' producer defer!  ok
0 producer-buf !  ok
my-schedule init-schedule  ok
0 ' consumer consumer-action init-action  ok
0 ' producer producer-action init-action  ok
my-schedule consumer-action add-action  ok
my-schedule producer-action add-action  ok
my-schedule 1 ' run-schedule 420 128 512 spawn run  ok
* 0 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * timed out

In this example we create a consumer action and a producer action, where the producer action sends successive integers starting from zero, with a timeout of one second, and where the consumer action prints the received integers to the console, but locks up after receiving a message with the value of nine. This causes the sending action to time out the next time it attempts to send a message, resulting in it printing timed out to the console.

For a practical example involving writing to an I2C SSD1306-based display connected to GPIO pins 14 and 15 on a Raspberry Pi Pico, first load the following source files into RAM (the following works under zeptocom.js or e4thcom):

#include extra/common/bitmap.fs
#include extra/common/ssd1306.fs
#include extra/common/font.fs
#include extra/common/simple_font.fs

Then, take the following:

oo import
bitmap import
ssd1306 import
rng import
systick import
task import
task-pool import
action import
action-pool import
pool import

Here we import all the modules we need into the current namespace.

\ The particle structure
begin-structure particle-size
  
  \ The last systick at which the particle was updated
  field: last-systick
  
  \ The current coordinates of the particle
  2field: particle-x
  2field: particle-y
  
  \ The current delta of the particle
  2field: particle-x-delta
  2field: particle-y-delta
  
end-structure

Here we define the structure containing the state of each particle.

\ The width and height of the display in pixels
128 constant my-width
64 constant my-height

\ The width and height of the sprite in pixels
4 constant my-sprite-width
4 constant my-sprite-height

\ The number of particles to create
60 constant my-particle-count

\ The speed of the particles
20,0 2constant my-particle-speed

Here we define a number of basic constants for controlling our particle system.

\ The size in bytes of the SSD1306 framebuffer
my-width my-height bitmap-buf-size constant my-buf-size

\ The SSD1306 framebuffer
my-buf-size 4 align buffer: my-buf

\ The SSD1306 display
<ssd1306> class-size buffer: my-ssd1306

Here we allot space for our <ssd1306> object for our SSD1306-based display and its framebuffer.

\ The size in bytes of the sprite buffer
my-sprite-width my-sprite-height bitmap-buf-size constant my-sprite-buf-size

\ The sprite buffer
my-sprite-buf-size 4 align buffer: my-sprite-buf

\ The sprite
<bitmap> class-size buffer: my-sprite

Here we allot space for our <bitmap> object for our sprite and its buffer.

\ The task pool, for running the particle system
1 task-pool-size buffer: my-task-pool

\ The schedule for the particles and the display updater
schedule-size buffer: my-schedule

\ The action pool for the particles and the display updater
my-particle-count 1+ action-pool-size buffer: my-action-pool

\ The memory pool for the particles
pool-size buffer: my-pool

\ The size in bytes of the memory for the memory pool
particle-size my-particle-count * constant my-pool-size

\ The memory for the memory pool
my-pool-size buffer: my-pool-data

Here we allot space for our task pool, our action schedule, our action pool for our particles' actions and the action for our display updater, and our pool for the states of each particle.

\ Whether the display, the sprite, and the particle system have been initialized
false value inited?

Here we have a flag for whether the particle system and its data structures have already been initialized.

\ How many particles are ready for display
variable particle-ready-count

Here we have a counter storing how many particles are ready to be displayed.

\ Initialize the display
: init-display ( -- )
  14 15 my-buf my-width my-height SSD1306_I2C_ADDR 1
  <ssd1306> my-ssd1306 init-object
;

\ Initialize the sprite
: init-sprite ( -- )
  my-sprite-buf my-sprite-width my-sprite-height
  <bitmap> my-sprite init-object
  my-sprite clear-bitmap
  $FF 1 2 0 1 op-set my-sprite draw-rect-const
  $FF 0 4 1 2 op-set my-sprite draw-rect-const
  $FF 1 2 3 1 op-set my-sprite draw-rect-const
;

Here we initialize our SSD1306-based display, on GPIO pins 14 and 15 of I2C peripheral 1 of our RP2040, and our sprite bitmap.

\ Initialize the test overall
: init-test ( -- )
  init-display
  init-sprite
  my-schedule init-schedule
  320 128 512 1 my-task-pool init-task-pool
  my-particle-count 1+ my-action-pool init-action-pool
  particle-size my-pool init-pool
  my-pool-data my-pool-size my-pool add-pool
  true to inited?
;

Here we do the bulk of initialization, including calling init-display and init-sprite and setting up the schedule, the task pool, the action pool, and the pool for our particles states.

\ Draw a particle to the display (or erase it, as it is using XOR)
: draw-particle ( -- )
  0 current-data particle-x 2@ nip my-sprite-width
  0 current-data particle-y 2@ nip my-sprite-height
  op-xor my-sprite my-ssd1306 draw-rect
;

Here we draw the sprite for a particle to the SSD1306-based display's framebuffer in XOR mode.

\ Bounce a particle off the edges of the display
: bounce-particle ( -- )
  current-data particle-x 2@ 0,0 d<= if
    current-data particle-x-delta 2@ dabs
    current-data particle-x-delta 2!
  else
    current-data particle-x 2@ 0 my-width my-sprite-width - d>= if
      current-data particle-x-delta 2@ dabs dnegate
      current-data particle-x-delta 2!
    then
  then
  current-data particle-y 2@ 0,0 d<= if
    current-data particle-y-delta 2@ dabs
    current-data particle-y-delta 2!
  else
    current-data particle-y 2@ 0 my-height my-sprite-height - d>= if
      current-data particle-y-delta 2@ dabs dnegate
      current-data particle-y-delta 2!
    then
  then
;

Here we handle bouncing particles off the edges of the display.

\ Move a particle
: move-particle ( -- )
  systick-counter { current-systick }
  current-systick current-data last-systick @ - { systick-diff }
  current-systick current-data last-systick !
  0 systick-diff 10000,0 f/ { fract-lo fract-hi }
  fract-lo fract-hi current-data particle-x-delta 2@ f*
  current-data particle-x 2+!
  fract-lo fract-hi current-data particle-y-delta 2@ f*
  current-data particle-y 2+!
  bounce-particle
;

Here we handle moving particles, depending on how much time (in seconds) has passed since the last time the particle has been updated, and we call bounce-particle to handle the case of particles bouncing off the edges of the display.

\ Carry out one cycle of a particle
defer particle-cycle
:noname
  particle-ready-count @ my-particle-count = if
    draw-particle
    move-particle
  else
    systick-counter current-data last-systick !
  then
  particle-ready-count @ 1+ my-particle-count min particle-ready-count !
  draw-particle
  ['] particle-cycle yield-action
; ' particle-cycle defer!

Here we implement the main cycle of each particle's action; we only erase or move particles if all particles have already been drawn to the SSD1306-based display's framebuffer, which we determine by the value of particle-ready-count, which we increment if it is not at its maximum value of my-particle-count.

\ Initialize a particle
: init-particle ( -- )
  my-pool allocate-pool { particle }
  systick-counter particle last-systick !
  random 0 2,0 f* { angle-lo angle-hi }
  random 0 0 my-width my-sprite-width - f* particle particle-x 2!
  random 0 0 my-height my-sprite-height - f* particle particle-y 2!
  angle-lo angle-hi cos my-particle-speed f* particle particle-x-delta 2!
  angle-lo angle-hi sin my-particle-speed f* particle particle-y-delta 2!
  my-schedule particle ['] particle-cycle
  my-action-pool add-action-from-pool drop
;

Here we initialize a new particle, including allocating the particle's action from the action pool and allocating the particle's state structure from the pool.

\ Update the display if all the particles are ready
defer display-update
:noname
  particle-ready-count @ my-particle-count = if
    my-ssd1306 update-display
  then
  ['] display-update yield-action
; ' display-update defer!

Here we have the loop of the display updater; we update the SSD1306-based display from its framebuffer if all the particles have already been drawn to the framebuffer.

\ Initialize the display updater
: init-updater ( -- )
  my-schedule 0 ['] display-update
  my-action-pool add-action-from-pool drop
;

Here we initialize the display updater's action, allocating an action from the action pool.

\ Begin bouncing particles
: run-bounce ( -- )
  inited? not if
    init-test
    my-particle-count 0 ?do init-particle loop
    init-updater
  then
  0 [:
    0 particle-ready-count !
    my-schedule run-schedule
    0 particle-ready-count !
    my-ssd1306 clear-bitmap
    my-ssd1306 update-display
  ;] my-task-pool spawn-from-task-pool run
;

Here we run the particle system, first initializing it if it has not already been initialized, then allocating a task from the task pool, which first sets particle-ready-count to 0 indicating that all the particles need to be drawn, then runs the schedule. Once the schedule is stopped, it sets particle-ready-count back to 0 and then it clears and re-displays the SSD1306-based display.

\ Stop bouncing particles
: stop-bounce ( -- )
  inited? if
    my-schedule stop-schedule
  then
;

Here we stop the particle system's schedule of the particle system has already been initialized.

When run-bounce is executed, the display, the sprite, and the particle system is initialized if it had not already been, and afterwards the particle system is run, with multiple bouncing sprites being displayed on the SSD1306-based display. Afterwards, when stop-bounce is executed, the particle system is paused and the display is cleared. If run-bounce were to be executed again, the particle system would pick up where it left off and begins being displayed again.

In this case, each particle is given its own action, and the display updater also has an action. Each particle's action updates its state since the last time it had been displayed depending on how long of a time interval there has been and redraws the particle in the display's framebuffer. The display updater action simply updates the SSD1306-based display with the contents of its framebuffer if all the particles had updated themselves already since last time the particle system had been started.