-
Notifications
You must be signed in to change notification settings - Fork 23
Actions and Action Schedulers in zeptoforth
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.
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.
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.