Skip to content

More on Multitasking in zeptoscript

tabemann edited this page Jun 26, 2024 · 10 revisions

Introduction

zeptoscript has support for cooperative multitasking, also known as symmetric coroutines. These are found in the zscript-task module defined in src/common/task.fs. This is dependent upon queues in the zscript-queue module defined in src/common/queue.fs.

Cooperative multitasking involves one or more tasks in a queue of ready tasks, along with zero or more blocked tasks in other queues. A single operation is used to switch between tasks, and switching between tasks always switches to the task at the head of the queue of ready tasks.

zeptoscript also has support for asymmetric coroutines. These are found in the zscript-coroutine module defined in src/common/coroutine.fs.

Asymmetric coroutines involves resuming suspended coroutines, passing them values, and suspending running coroutines, passing values to that which had resumed them. In many ways they are similar to generators in languages such as Python, but they are more general than them.

Symmetric coroutines

One important note about symmetric coroutines in zeptoscript is that there is no concept of a "task object". Tasks are simply saved states, which are similar but not identical to continuations à la Scheme, which are created each time they are yielded.

To start executing scheduled tasks one simply calls start ( -- ). This will only return when the last task terminates.

To yield the current task, to execute the next task in the task queue, one executes yield ( -- ). Note that this returns immediately if there are no queued tasks. An example of this in action is:

zscript-task import

: run-test ( -- )
  fork not if 16 0 ?do ." FOO " yield loop terminate then
  fork not if 16 0 ?do ." BAR " yield loop terminate then
  start
;

Executing run-test outputs:

FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR FOO BAR  ok

Here is the same code but without yield:

zscript-task import

: run-test ( -- )
  fork not if 16 0 ?do ." FOO " loop terminate then
  fork not if 16 0 ?do ." BAR " loop terminate then
  start
;

Executing run-test outputs:

FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO FOO BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR BAR  ok

There are two ways to create a task. The first is to pass a thunk to spawn ( xt -- ), which places the new task at the rear of the task queue.

An example of this is:

zscript-task import

: run-test ( -- )
  [: 16 0 ?do i . 250 ms loop terminate ;] spawn
  start
;

Executing run-test outputs:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15  ok

The second is to call fork ( -- parent? ), which creates a new task which will continue executing at the point that fork would return false, and which immediately returns true in the parent task. This can be seen here:

zscript-task import

: run-test ( -- )
  fork not if
    fork not if
      cr ." In child's child! " terminate
    else
      cr ." In child! " terminate
    then
  else
    cr ." In parent! "
    start
  then
;

Executing run-test outputs:

In parent! 
In child! 
In child's child!  ok

To terminate the current task one calls terminate ( -- ), as seen above. An example of the control flow provided by fork, start, and terminate is as follows:

zscript-task import

: run-test ( -- )
  fork not if ." BAR! " terminate then
  ." FOO! "
  start
  ." BAZ! "
;

Executing run-test outputs:

FOO! BAR! BAZ!  ok

Each task has task-local storage, which it inherits from its parent task. It can be gotten with task-local@ ( -- task-local ) and set with task-local! ( task-local -- ). An example of this in action is:

zscript-task import
zscript-map import

symbol foo

: make-task-local ( -- )
  16 ['] symbol>integral ['] = make-map task-local!
;

: run-test ( -- )
  make-task-local
  0 foo task-local@ insert-map
  fork not if
    10 0 ?do foo task-local@ map@ . yield loop
    terminate
  then
  make-task-local
  16 foo task-local@ insert-map
  fork not if
    10 0 ?do foo task-local@ map@ . yield loop
    terminate
  then
  make-task-local
  256 foo task-local@ insert-map
  fork not if
    10 0 ?do foo task-local@ map@ . yield loop
    terminate
  then
  start
;

Executing run-test outputs:

0 16 256 0 16 256 0 16 256 0 16 256 0 16 256 0 16 256 0 16 256 0 16 256 0 16 256 0 16 256  ok

Bounded message channels

Bounded message channels (which we will call simply message channels from here out), defined in zscript-chan, are simple first-in-first-out (FIFO) queues permitting both blocking and non-blocking accesses, and also permitting "peeking" where the message at the head of the queue can be fetched without dequeuing it. Message channels have a fixed number of elements set when they are created, and receiving or peeking on an empty message queue and sending on a full message queue will block unless one is using non-blocking operations.

Bounded message channels are created with make-chan ( size -- channel ), where size is the maximum number of permitted elements. Newly-created message channels are empty.

Message channels are sent to with send ( message channel -- ) in a blocking fashion, or with send-non-block ( message channel -- success? ) in a non-blocking fashion. Note that send-non-block returns true for success? when a message was successfully sent, and false for success? when no message was sent.

Message channels are received from with recv ( channel -- message ) in a blocking fashion, or with recv-non-block ( channel -- message success? ) in a non-blocking fashion. Note that recv-non-block returns true for success? when a message can be received, and false for success? along with 0 for message when no message is available.

Message channels are peeked, i.e. fetching the message at the head of the queue without dequeuing it, with peek ( channel -- message ) in a blocking fashion, or with peek-non-block ( channel -- message success? ) in a non-blocking fashion. Note that peek-non-block returns true for success? when a message can be peeked, and false for success? along with 0 for message when no message is available.

Unbounded message channels

Unbounded message channels, defined in zscript-uchan, are a special type of message channel that has no limit on the number of elements. Consequently, sends on unbounded message channels always return immediately, and never transfer control by themselves. This is important for things such as proper implementation of actors, but at the same time means that when sending messages it is important to at points call zscript-task::yield or another word that will call it such as zscript-task::ms, as the sent messages will otherwise never be handled, and memory usage will increase until the space in the heap is exhausted. On the other hand, receiving on unbounded message channels is blocking by default just like for bounded message channels.

Unbounded message channels are created with make-uchan ( -- uchannel ). Newly created unbounded message channels are empty.

Unbounded message channels are sent to with send ( message uchannel -- ). Unlike zscript-chan::send this does not block, as it immediately enqueues a message in an unbounded message channel's queue and returns control to its caller. There is no separate blocking and non-blocking words for this as a result.

Unbounded message channels are received from with recv ( uchannel -- message ) in a blocking fashion, or with recv-non-block ( uchannel -- message success? ) in a non-blocking fashion. Note that recv-non-block returns true for success? when a message can be received, and false for success? along with 0 for message when no message is available.

Unbounded message channels are peeked, i.e. fetching the message at the head of the queue without dequeuing it, with peek ( uchannel -- message ) in a blocking fashion, or with peek-non-block ( uchannel -- message success? ) in a non-blocking fashion. Note that peek-non-block returns true for success? when a message can be peeked, and false for success? along with 0 for message when no message is available.

Producers and consumers

A common use of message channels is for connecting producers with consumers. Any number of producers may send messages to message channels, and any number of consumers may receive messages from message channels. An example of this is as follows:

zscript-task import
zscript-chan import

symbol done

: run-test ( -- )
  1 make-chan { my-chan }
  fork not if 32 0 ?do i my-chan send 1 +loop done my-chan send terminate then
  fork not if -32 -1 ?do i my-chan send -1 +loop done my-chan send terminate then
  fork not if
    0 { done-count }
    begin done-count 2 < while
      my-chan recv dup done <> if . else drop 1 +to done-count then
    repeat
    terminate
  then
  start
;

Executing run-test outputs:

0 1 -1 2 -2 3 -3 4 -4 5 -5 6 -6 7 -7 8 -8 9 -9 10 -10 11 -11 12 -12 13 -13 14 -14 15 -15 16 -16 17 -17 18 -18 19 -19 20 -20 21 -21 22 -22 23 -23 24 -24 25 -25 26 -26 27 -27 28 -28 29 -29 30 -30 31 -31 -32  ok

Blocking waits

A simple sort of blocking wait is the time delay. This is implemented through wait-delay ( start-time delay -- ), where start-time is the starting time in ticks, as given by systick-counter ( -- ticks ), and delay is the delay from that time in ticks. Note that one tick is equivalent to 100 microseconds. Note that time delays involve the current task being repeatedly yielded until the time represented by start-time plus delay is reached.

However, other kinds of blocking waits are possible. Unlike time delays, they do not involve repeatedly testing for a condition, but rather placing the current task in a queue with block ( queue -- ) and then removing a task from the queue and scheduling it with wake ( queue -- ).

A good example of this sort of blocking wait is those provided by message channels. When one sends a message to a message channel and the message channel is full, the current task is blocked and placed at the rear of the queue referred to by chan-send-wait@. Likewise, when one attempts to receive a message from a message channel and the message channel is empty, the current task is blocked and placed at the rear of the queue referred to by chan-recv-wait@. This is shown by the following in src/common/channel.fs:

\ Wait on sending a message on a channel
: wait-send { msg chan -- }
  begin
    chan chan-send-wait@ block
    chan chan-messages@ queue-size chan chan-max-size@ < if
      msg chan actual-send true
    else
      false
    then
  until
;

\ Wait on receiving a message from a channel
: wait-recv { chan -- msg }
  begin
    chan chan-recv-wait@ block
    chan chan-messages@ queue-empty? not if
      chan actual-recv true
    else
      false
    then
  until
;

Conversely, when a message channel with a blocked receiving task is sent to, or when a message channel with a blocked sending task is received from, the blocked receiving task or the blocked sending task is dequeued and then scheduled with wake, as illustrated by the following:

\ Wake a sending task for a channel
: wake-send { chan -- }
  chan chan-send-wait@ queue-empty? not
  chan chan-send-pending@ not and if
    true chan chan-send-pending!
    chan chan-send-wait@ wake
  then
;

\ Wake a receiving task for a channel
: wake-recv { chan -- }
  chan chan-recv-wait@ queue-empty? not
  chan chan-recv-pending@ not and if
    true chan chan-recv-pending!
    chan chan-recv-wait@ wake
  then
;

\ Actually send a message on a channel
: actual-send { msg chan -- }
  false chan chan-send-pending!
  msg chan chan-messages@ enqueue
  chan chan-send-count@ 1- chan chan-send-count!
  chan wake-recv
;

\ ...

\ Actually receive a message on a channel
: actual-recv { chan -- msg }
  chan chan-messages@ dequeue if
    false chan chan-recv-pending!
    chan chan-recv-count@ 1- chan chan-recv-count!
    chan wake-send
  else
    [: ." should not happen!" cr ;] ?raise
  then
;

Joining

So you are probably wondering how on Earth you "join" a task when there is no such thing as a "task object". This is accomplished by creating a message channel shared by both the task to be waited for and the waiting task, where the waiting task will receive on the message channel for each task to be waited for. Note that the "size" of each message channel must be at least as large as the number of tasks being waited for, so they will not block when they are about to terminate.

This is demonstrated by the following:

zscript-task import
zscript-chan import

3 constant join-count

: task-a { join-chan -- }
  fork not if
    16 0 ?do ." A: " i . 125 ms loop
    0 join-chan send
    terminate
  then
;

: task-b { join-chan -- }
  fork not if
    16 0 ?do ." B: " i . 250 ms loop
    0 join-chan send
    terminate
  then
;

: task-c { join-chan -- }
  fork not if
    16 0 ?do ." C: " i . 500 ms loop
    0 join-chan send
    terminate
  then
;

: done-task { join-chan -- }
  fork not if
    join-count 0 ?do join-chan recv drop loop
    ." DONE! "
    terminate
  then
;

: run-test ( -- )
  join-count make-chan { join-chan }
  join-chan task-a
  join-chan task-b
  join-chan task-c
  join-chan done-task
  start
;

When executed this outputs the following:

A: 0 B: 0 C: 0 A: 1 B: 1 A: 2 A: 3 B: 2 C: 1 A: 4 A: 5 B: 3 A: 6 A: 7 B: 4 C: 2 A: 8 A: 9 B: 5 A: 10 A: 11 B: 6 C: 3 A: 12 A: 13 B: 7 A: 14 A: 15 B: 8 C: 4 B: 9 B: 10 C: 5 B: 11 B: 12 C: 6 B: 13 B: 14 C: 7 B: 15 C: 8 C: 9 C: 10 C: 11 C: 12 C: 13 C: 14 C: 15 DONE!  ok

Here the tasks created by task-a, task-b, and task-c send messages on join-chan just before they call terminate, and the task created by join-task receives three messages, one for each of the tasks being joined, prior to printing DONE!.

Broadcasting

The message channels we have seen are ones for single tasks to receive messages at a time. However, it might be desired to send messages to multiple tasks at a time. This can be achieved through creating one task which receives commands, and uses them to send messages to multiple other tasks. An example of this is below:

zscript-task import
zscript-chan import
zscript-queue import

symbol done
symbol do-send
symbol do-subscribe
symbol do-terminate

global wait-count

: relay-task ( -- cmd-chan )
  1 make-chan { cmd-chan }
  fork not if
    make-queue ref { send-queue }
    begin
      cmd-chan recv pair> over do-send = if
        nip { msg }
        make-queue { new-queue }
        begin send-queue ref@ dequeue while
          dup new-queue enqueue
          msg swap send
        repeat
        drop
        new-queue send-queue ref!
      else
        over do-subscribe = if
          send-queue ref@ enqueue drop
        else
          swap do-terminate = if
            drop terminate
          else
            drop
          then
        then
      then
    again
  then
  cmd-chan
;

: subscribe { subscribe-chan cmd-chan -- }
  do-subscribe subscribe-chan >pair cmd-chan send
;

: broadcast { msg cmd-chan -- }
  do-send msg >pair cmd-chan send
;

: send-task { end-index start-index incr cmd-chan -- }
  fork not if
    end-index start-index ?do i cmd-chan broadcast incr +loop
    wait-count@ 1- wait-count!
    done cmd-chan broadcast
    terminate
  then
;

: recv-task { label cmd-chan -- }
  fork not if
    1 make-chan { recv-chan }
    recv-chan cmd-chan subscribe
    begin wait-count@ 0> while
      recv-chan recv dup done <> if label type . else drop then
    repeat
    terminate
  then
;

: terminate-task { cmd-chan -- }
  fork not if
    1 make-chan { recv-chan }
    recv-chan cmd-chan subscribe
    begin wait-count@ 0> while
      recv-chan recv drop
    repeat
    do-terminate 0 >pair cmd-chan send
    terminate
  then
;

: run-test ( -- )
  relay-task { cmd-chan }
  s" A: " cmd-chan recv-task
  s" B: " cmd-chan recv-task
  s" C: " cmd-chan recv-task
  cmd-chan terminate-task
  10 0 1 cmd-chan send-task
  -10 -1 -1 cmd-chan send-task
  3 wait-count!
  start
;

Executing run-test outputs:

A: 0 B: 0 C: 0 A: -1 B: -1 C: -1 A: 1 B: 1 C: 1 A: -2 B: -2 C: -2 A: 2 B: 2 C: 2 A: -3 B: -3 C: -3 A: 3 B: 3 C: 3 A: -4 B: -4 C: -4 A: 4 B: 4 C: 4 A: -5 B: -5 C: -5 A: 5 B: 5 C: 5 A: -6 B: -6 C: -6 A: 6 B: 6 C: 6 A: -7 B: -7 C: -7 A: 7 B: 7 C: 7 A: -8 B: -8 C: -8 A: 8 B: 8 C: 8 A: -9 B: -9 C: -9 A: 9 B: 9 C: 9 A: -10 B: -10 C: -10  ok

Here we have a task acting as an intermediary, receiving messages on one message channel, and then sending them to each of the destination message channels per its internal queue of message channels. This intermediary task acts on commands sent to its command message channel, which take the form of a pair of a command type and an associated value, including messages to subscribe a message channel to it, to send a message to the subscribed message channels, and to terminate the intermediary task. Note that a done message is used to wake up tasks waiting on messages from the intermediary task without requiring them to spin-wait in a non-blocking fashion.

Asymmetric coroutines

Asymmetric coroutines are started with make-coroutine ( xt -- coroutine ), which takes an execution token and returns a coroutine in a suspended state that will execute that execution token, passing it the value passed to resume, when it is first resumed. Note that when the execution token is done executing, the last value on the stack is passed to the last caller of resume and after that point the coroutine is dead.

Asymmetric coroutines are resumed with resume ( x coroutine -- x' ), passing control to the coroutine. The value x is passed to the coroutine when it executes, either being returned by the coroutine's call to suspend, or if the coroutine had not been executed before being passed as an argument to the execution token which was passed into make-coroutine.

Asymmetric coroutines are suspended with suspend ( x -- x' ), passing control to that which had last resumed the coroutine. The value x is returned by the last call to resume which had resumed the coroutine. The next time resume is called for the coroutine, the argument passed to resume is returned by suspend.

The state of an a coroutine can be gotten with coroutine-state@ ( coroutine -- state ), where state may be one of suspended, running, or dead.

The current coroutine can be gotten with current-coroutine ( -- coroutine ).

Coroutines have coroutine-local state, that can be gotten with coroutine-local@ ( -- x ) and set with coroutine-local! ( x -- ). Coroutines inherit the coroutine-local state from when they were created. Note that there is a coroutine-local state that exists outside any coroutine which is used for initializing coroutines' coroutine-local states.

There are the following exceptions related to asymmetric coroutines:

x-not-in-coroutine is raised if one attempts to call suspend outside of a coroutine.

x-dead-coroutine is raised if one attempts to resume a dead coroutine.

x-running-coroutine is raised if one attempts to resume a running coroutine.

A simple example of asymmetric coroutines in action is generating a Fibonacci sequence and printing it out, as shown below:

zscript-coroutine import

: fibonacci-coroutine ( -- coroutine )
  [:
    drop
    0 1 { x y }
    x suspend drop
    y suspend drop
    begin
      x y +
      y to x
      to y
      y suspend drop
    again
  ;] make-coroutine
;

: run-test ( -- )
  fibonacci-coroutine { co }
  25 0 ?do 0 co resume . loop
;

Executing run-test outputs:

0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368  ok

Another example of asymmetric coroutines, this time involving a coroutine being resumed by another coroutine, is as follows:

zscript-coroutine import

: run-test
  [:
    .
    [:
      .
      -1 suspend .
      -2 suspend .
      -3 suspend .
      -4
    ;] make-coroutine { co }
    256 co resume .
    -256 suspend .
    257 co resume .
    -257 suspend .
    258 co resume .
    -258 suspend .
    -259
  ;] make-coroutine { co }
  0 co resume .
  1 co resume .
  2 co resume .
  3 co resume .
  4 co resume .
;

Executing run-test outputs:

0 256 -1 -256 1 257 -2 -257 2 258 -3 -258 3 -259 coroutine is dead
Clone this wiki locally