Multitasking and Multiprocessing with zeptoforth - tabemann/zeptoforth GitHub Wiki

Introduction

zeptoforth is a priority-scheduled preemptive multitasking Forth, and on the RP2040 also a symmetric multiprocessing Forth, which is able to make full use of both processor cores on the RP2040. It provides a range of intertask synchronization/communication constructs, including locks, semaphores, queue channels, rendezvous channels, bidirectional channels (like rendezvous channels, but also with a built-in synchronous reply), byte streams (like queue channels, but specialized for the efficient transfer of sequences of bytes), "simple" channels (simplified channels designed for use with interrupt service routines), and "simple" locks (simplified locks designed for use with very short lock acquisitions where latency is more important than ensuring order). It also has an "action" scheduler, which provides lightweight asynchronous execution with message-passing within a single task.

Spawning and Running Tasks

Tasks are created on the current task's core with spawn ( xn ... x0 argument-count xt dictionary-size stack-size return-stack-size -- task ). On the RP2040 task are created on an arbitrary core with spawn-on-core ( xn ... x0 argument-count xt dictionary-size stack-size return-stack-size core -- task ). The task is initialized to execute xt and argument-count arguments x0 through xn are placed on its stack such that x0 is on the top of its stack. core is simply the core index of the core the task is to execute on, which on the RP2040 may be 0 or 1. Note that tasks are not executing when they are initially created. On the RP2040 the main task, the task created on bootup, runs on core 0. No special actions are needed to boot core 1 on the RP2040; all that is needed is to spawn a task on it with spawn-on-core.

When using spawn or spawn-on-core it is a good idea to set return-stack-size to 512 in order to give the zeptoforth kernel, the multitasker, and all other interrupt handlers enough space to use, because each task must share its return stack with those. A good value for dictionary-size is 128; I have had no issue with this size. For tasks that are not using pad (e.g. words that do not use pictured numeric output) along with other words that make significant use of the dictionary, 256 appears to be sufficient. However, I have found there to be cases where greater values were needed when combining words that make use of the dictionary space (e.g. words using rendezvous channels) combined with pictured numeric output.

Tasks are started and resumed with run ( task -- ), suspended with stop ( task -- ), and killed with kill ( task -- ). Tasks may be started/resumed and suspended any number of times, and resuming a task n number of times will require suspending a task n number of times to suspend it, and the opposite is of true of suspending a task m number of times and resuming the task m number of times. Killing a task will kill it immediately, and must be done with care, because it may cause undefined results when a task is concurrently making use of task communication/synchronization constructs. In general, it is only truly safe to kill the current task, and then only in a case where the current task is not holding any locks, is not in a critical section, or otherwise has any task waiting on it. (The current task will be killed if an uncaught exception is raised within it, where then the relevant message is displayed on the console first, or if the execution token passed for it to spawn or spawn-on-core returns.)

Tasks, including their data and return stacks and dictionary spaces, are alloted from the top of the main task (the task created on bootup)'s dictionary space. Each task has user variables, which are task-local variables, which are alloted from the bottom of its dictionary space.

For a basic example of multitasking under zeptoforth, consider the following:

task import
: do-task 25 0 ?do i . 1000 ms loop ;
: test
  0 ['] do-task 256 128 512 spawn run
  4000 ms
  0 ['] do-task 256 128 512 spawn run
  4000 ms
  0 ['] do-task 256 128 512 spawn run
;

Afterwards, execute:

test 0 1 2 3 4 0 5 1 6 2 7 3  ok
4 8 0 5 9 1 6 10 2 7 11 3 8 12 4 9 13 5 10 14 6 11 15 7 16 12 8 13 17 9 14 18 10 15 19 11 16 20 12 17 21 13 18 22 14 19 23 15 20 24 16 21 17 22 18 23 19 24 20 21 22 23 24

Here we initialize and then start three tasks at four second intervals, each of which prints numbers 0 through 24 at one second intervals. The tasks then terminate once they print out 24.

Priorities and Scheduling

Being priority-scheduled, each task has a priority. Priorities may be from -32768 to 32767, with lower values indicating lower priorities and higher values indicating higher priorities. At all times, except during an interrupt service routine or a critical section, the tasks with the highest priority that are ready to execute will execute.

Within that set of tasks multitasking is not round robin, but rather the highest priority task closest to the head of a given processor core's task schedule priority queue will execute until it either executes pause or it exhausts its time slice, where then it is placed at the rear of the tasks of the same priority in said priority queue. Tasks that are blocked do not give up their place in the task priority queue, so tasks that are blocked and then become unblocked are favored over other tasks of the same priority to execute. Note that tasks which do not have anything to do at a given point in time should execute pause so as to relinquish their place in the task schedule to another task rather than taking up processor time until it finally exhausts its time slice.

For an example of code that uses multiple priority levels, take the following:

task import                          
variable high-task                   
variable low-task                    
: do-high                            
  1 current-task task-priority!
  begin
    1000 ms 4 0 do ." *" 1000000 0 do loop loop
  again ;
: do-low
  0 current-task task-priority!
  begin ." ." 1000000 0 do loop again ;
0 ' do-high 256 128 512 spawn high-task !
0 ' do-low 256 128 512 spawn low-task !

Afterwards, execute:

high-task @ run low-task @ run  ok         
......****.....****.....****.....****......****.....****reboot

Here we have two task, one of high priority that prints stars and one of low priority that prints dots. While printing both stars and dots they busy-loop to attempt to monopolize the CPU. However, the star-printing task regularly uses ms to wait a second at a time, during which it relinquishes control of the CPU, allowing the dot-printing task to print dots. When the star-printing task is printing stars, however, as it is busy-looping and of a higher priority than the dot-printing task, only stars will be printed, because the dot-printing task will not have any control over the CPU for that period due to being of a lower priority.

Multiprocessing

For a basic example of multiprocessing on the RP2040, take the following:

task import                      
: stars 25 0 ?do ." *" 1000 ms loop ;
: dots 100 0 ?do ." ." 250 ms loop ;
: test                           
  0 ['] stars 256 128 512 0 spawn-on-core run
  0 ['] dots 256 128 512 1 spawn-on-core run
;

Afterwards, execute:

test  ok
.*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*....*...

Here we initialize and then start two tasks, one on core 0 which prints a star every second 25 times, and one on core 1 that prints a dot every quarter second 100 times.

Time

The current time since bootup can be gotten by executing systick-counter in the systick module. This is the number of ticks, which are typically 100 microseconds in length, since bootup. Note that turning off interrupts (with disable-int or even when writing to or erasing flash) for extended periods of time may cause systick-counter to fail to increment properly for those periods of time.

There are two primary words used to suspend a task for a set period of time. The simplest is ms ( milliseconds -- ), which takes a number of milliseconds (which are normally equivalent to 10 ticks) and suspends the current task for that period of time. More complex is delay ( delay-ticks start-tick task -- ), which delays task for delay-ticks after start-tick. Note that there is last-delay ( task -- delay-ticks start-ticks ), which returns the starting tick and the number of delay ticks for which task was last delayed (including through the use of ms).

Timeouts

All blocking operations other than ms and delay may have a timeout set for them in ticks through setting the user variable timeout, where then after the start of the operation if a time greater than timeout ticks passes, interrupts being disabled aside, the exception x-timed-out is raised.

task import            
fchan import
fchan-size buffer: my-fchan
my-fchan init-fchan
: consumer
  100000 timeout !
  begin [: my-fchan recv-fchan ;] extract-allot-cell . again ;

Afterwards, execute the following:

0 ' consumer 256 128 512 spawn run  ok
block timed out

Here we create a consumer that first sets a timeout of ten seconds and then attempts to receive from a rendezvous channel, but no task sends to the rendezvous channel, so after ten seconds pass x-timed-out is raised.

Locks

Locks (also known as mutexes) enforce mutual exclusion, typically over a structure or resource of one kind or another, and provide queues of tasks waiting for the locks in question to be released. In zeptoforth recursive locking is not allowed; when it does occur an exception is raised.

Note that locks in zeptoforth provide protection against a situation known has priority inversion, where a higher-priority task is waiting for a lock to be released by a lower-priority task. This is achieved by an algorithm known as priority inheritance, that when a higher-priority task attempts to claim a lock held by a lower-priority task, the lower-priority task is temporarily elevated in priority such that it matches the higher-priority task in priority until the lower-priority task releases the lock in question.

For a basic example of the use of locks, take the following:

task import
lock import
variable foo-task
variable bar-task
lock-size buffer: my-lock
: foo ( -- ) begin [: ." foo" ;] my-lock with-lock 1000 ms again ;
: bar ( -- ) begin [: ." bar" ;] my-lock with-lock 1000 ms again ;
my-lock init-lock
0 ' foo 256 128 512 spawn foo-task !
0 ' bar 256 128 512 spawn bar-task !

Afterwards, execute:

foo-task @ run bar-task @ run foo ok
barfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobar

Here with-lock takes an execution token to execute with the lock it is passed being locked, and afterwards releases the lock, even if an exception is raised by the execution token, which is caught and then re-raised. The lock ensures that both tasks do not attempt to write to the console simultaneous, which has the potential to cause garbled output. The same can be achieved with the use of claim-lock and release-lock, but one has to be careful to catch any raised exceptions, release the lock, and then re-raise the exception in question.

Semaphores

Semaphores in zeptoforth are typical semaphores, and may be unlimited or have a limit on how high a value they may have (a semaphore with a limit of one is a binary semaphore). They can also be initialized with a starting value. Any number of tasks may wait on a semaphore, and these tasks form a priority queue sorted first by priority, with higher priorities being closer to the head of the priority queue, and then by order of taking the semaphore, with tasks that took the semaphore earlier being closer to the head of the priority queue. Giving a semaphore increments the value of the semaphore up to the limit of the semaphore, if a limit is set, and if the value of the semaphore is less than zero wakes up the task at the head of the priority queue and removes the task from it. Taking a semaphore decrements the value of the semaphore, and if the value of the semaphore is then less than zero, the task doing the taking is blocked until a task readies it through a matching give.

For an example of semaphores in action, take the following example:

task import                                                    
sema import                                                    
sema-size buffer: my-sema                                      
no-sema-limit 0 my-sema init-sema                              
0 :noname begin my-sema take ." *** " again ; 256 128 512 spawn run
0 :noname begin my-sema take ." xxx " again ; 256 128 512 spawn run

Afterwards, execute:

my-sema give  ok
*** my-sema give  ok
xxx my-sema give  ok
*** my-sema give  ok
xxx my-sema broadcast  ok
*** xxx 

Here we allot a semaphore and initialize it with no limit and an initial value of zero. Then we create two tasks which repeatedly take the semaphore, the first which after being unblocked prints out *** to the console and the second which after being unblocked prints out xxx to the console. Then we give the semaphore four times, after which ***, xxx, ***, and xxx are printed, in that order, showing how the ordering of queued tasks is maintained. Finally, we broadcast on the semaphore, which unblocks all tasks waiting on the semaphore and sets the value of the semaphore to zero if it had been less than that; after this *** and xxx are both printed.

Task Notifications

Task notifications provide a lightweight way of signaling to individual tasks, with higher performance than semaphores and the ability to set cell-sized values in up to 32 mailboxes but with the limitations that only one task may be signaled at a time and that multiple notifications before a task waits for notifications will be treated as single notification except with regard to its effect on the mailboxes in question. Notifications always concern particular mailboxes and may either leave the value of the mailboxes in question as is, may set them to fixed values, or may use an execution token to update the value of the mailbox in question relative to its previous value. Before task notifications may be used, a task must be initialized to use a buffer containing a set number, up to 32, of cell-sized mailboxes.

For an example of task notifications in action, take the following:

task import                                                     
2 constant mailbox-count                                        
mailbox-count cells buffer: mailboxes                           
mailboxes mailbox-count cells 0 fill                            
variable consumer                                               
0 :noname begin 0 wait-notify . again ; 256 128 512 spawn consumer !
mailboxes mailbox-count consumer @ config-notify

Afterwards, execute:

consumer @ run  ok
0 consumer @ notify 0  ok
0 consumer @ notify 0  ok
1 consumer @ notify  ok
1 0 consumer @ notify-set 1  ok
2 0 consumer @ notify-set 2  ok
3 1 consumer @ notify-set  ok
' 1+ 0 consumer @ notify-update 3  ok
' 1+ 0 consumer @ notify-update 4  ok
' 1+ 1 consumer @ notify-update  ok

Here we first initialize space for two mailboxes, then we create a task which waits on mailbox 0 with wait-notify, which returns the new value of the mailbox once the task is notified, and prints out its said value then, after which we initialize mailboxes for that task and subsequently run it. Then we use notify to simply notify mailbox 0 on the task twice, causing it to wake up and print out the contents of mailbox 0 (which we had initialized to zero). When we notify mailbox 1 on the task nothing happens, because the task is not waiting on mailbox 1. Then we notify mailbox 0 while setting its value to 1 and then 2, which wakes up the task each time and causes it to print out the new values of mailbox 0. When we notify mailbox 0 while setting its value to 3, nothing happens on the surface even though the value of the mailbox is set to 3 behind the scenes. Afterwards, we notify mailbox 0 while incrementing its value twice, which wakes up the task each time and cause it to print out the new values of mailbox 0, 3 and 4. Finally, we notify mailbox 1 while incrementing its value, which does nothing except increment the value of mailbox 1 to 4.

Task Signaling

Exceptions can be sent to tasks with signal, which regardless of the execution state of a task triggers a specific exception within it. Take the following:

task import  ok
variable my-task  ok
: x-test ." this is a test exception" cr ;  ok
0 :noname 0 begin dup . 1+ 500 ms again ; 320 128 512 spawn dup my-task ! run 0  ok
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18   ok
' x-test my-task @ signal  ok
this is a test exception

Here we create an exception named x-test, then create a new task which counts upward once each 500 milliseconds, then after 9 seconds we signal the task with x-test, which raises the exception within the task, ending its upward counting.

Queue Channels

For a basic example of message-passing using queue channels, take the following:

chan import
task import
cell constant element-size
4 constant element-count
element-size element-count chan-size buffer: my-chan
element-size element-count my-chan init-chan
variable value
0 value !
: put value @ [: my-chan send-chan ;] provide-allot-cell 1 value +! ;
: get [: my-chan recv-chan ;] extract-allot-cell . ;
: producer 256 0 ?do put loop ;
: consumer begin get again ;

Afterwards, execute:

0 ' producer 256 128 512 spawn run  ok
0 ' consumer 256 128 512 spawn run  ok
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255

This example prints successive integers one second at a time by generating the integers in a producer task, and then using a queue channel to send the integer to a consumer task which prints it. send-chan and recv-chan are self-explanatory; they send data in a buffer to a queue channel and receives data from a queuue channel and places it in a buffer, returning the length of the message received or the size of the receive buffer, whichever is smaller.

provide-allot-cell and extract-allot-cell are slightly less self-explanatory. provide-allot-cell allots an aligned cell of space on the current task's dictionary, takes a value from the stack and writes it into that buffer, and calls an execution token, passing the address the space and its size (one cell) to the called execution token, and after the execution token returns or an exception is raised it frees the alloted space, re-raising any exception afterwards. extract-allot-cell is similar, except instead of taking a value off the stack and placing it in the buffer, after the execution token returns it takes the length returned by the execution token and checks if it is one cell, where then it reads the cell in the buffer and places it on the stack, otherwise it raises an exception.

Rendezvous Channels

Rendezvous channels are synchronous channels with no buffer that involve a handshake between the sending and the receiving tasks; if a task attempts to send on one and there is no task waiting to receive on it, it will wait until a task receives on it, and likewise if a task attempts to receive on one and there is no task waiting to send on it, it will wait until a task sends on it. Tasks waiting to send or receive on rendezvous channels are stored in priority queues ordered first by the priorities of the task in question, with tasks of higher priority sorted closer to the head of the priority queue in question, and then by the order in which they attempted to send or receive, with tasks waiting longer sorted closer to the head of the priority queue in question.

For a basic example of message-passing using rendezvous channels (aka "fchannels"), take the following:

task import
fchan import
variable producer-task
variable consumer-task
fchan-size buffer: my-fchan
: producer ( -- )
  0 begin dup [: my-fchan send-fchan ;] provide-allot-cell 1+ 1000 ms again ;
: consumer ( -- ) begin [: my-fchan recv-fchan ;] extract-allot-cell . again ;
my-fchan init-fchan
0 ' producer 256 128 512 spawn producer-task !
0 ' consumer 256 128 512 spawn consumer-task !

Afterwards, execute:

producer-task @ run  ok                                                       
consumer-task @ run  ok                                                       
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 consumer-task @ stop  ok

This example prints successive integers one second at a time by generating the integers in a producer task, and then using a rendezvous channel to send the integer to a consumer task which prints it. send-fchan and recv-fchan are similar to send-chan and recv-chan; they send data in a buffer to a rendezvous channel and receives data from a rendezvous channel and places it in a buffer, returning the length of the message received or the size of the receive buffer, whichever is smaller.

The main difference between send-fchan and recv-fchan on one hand and send-chan and recv-chan on the other hand is that send-fchan and recv-fchan are synchronous and involve a handshake between the sending and receiving tasks, whereas send-chan blocks until there is room in the queue channel and the places the message at the end of the queue channel and recv-chan blocks until there is a message in the queue channel and then takes the message off the head of the queue channel.

Bidirectional Channels

Bidirectional channels are very similar to rendezvous channels, but instead of being unidirectional they always incorporate a reply from the receiving message. They have better performance than using two rendezvous channels separately, and ensure that for any given message sent the reply will always come next, before any other messages are sent. The queues for sending and receiving tasks are in priority queues sorted just like those for rendezvous channels, but only one task may wait to receive a reply at a time. Note that tasks may not send or receive on bidirectional channels until a currently pending reply is sent; rather they are always enqueued in this case.

For a simple example of bidirectional channels in action, take the following:

task import
rchan import
rchan-size buffer: my-rchan
my-rchan init-rchan
: consumer
  begin
    [: my-rchan recv-rchan ;] extract-allot-cell
    [: my-rchan reply-rchan ;] provide-allot-cell
  again
;
: producer
  32 0 ?do
    i [: [: my-rchan send-rchan ;] extract-allot-cell ;]
    provide-allot-cell .
  loop
;

Afterwards, execute:

0 ' consumer 256 128 512 spawn run  ok
0 ' producer 256 128 512 spawn run  ok
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 

This example involves a producer task sending numbers 0 through 31 to a consumer task via a bidirectional channel, which then receives the values and replies the same values back, which are then received by the sending task which then prints them to the console.

Byte Streams

Byte streams are like queue channels, except they involve arbitrary sequences of bytes rather than discrete messages which may be sent or received in any number up to the size of the byte stream's internal buffer.

Take the following example of byte streams in use:

task import
stream import
32 constant stream-buf-size
16 constant send-buf-size
4 constant recv-buf-size
stream-buf-size stream-size buffer: my-stream
send-buf-size buffer: send-buf
recv-buf-size buffer: recv-buf
s" 0123456789ABCDEF" send-buf swap move
stream-buf-size my-stream init-stream
: consumer begin recv-buf dup recv-buf-size my-stream recv-stream type again ;
: producer
  4 0 ?do
    cr 4 0 ?do send-buf send-buf-size my-stream send-stream loop 1000 ms
  loop
;

Afterwards, execute the following:

0 ' consumer 256 128 512 spawn run  ok
0 ' producer 256 128 512 spawn run  ok

0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF

Here we allot and initialize a byte stream with a buffer of size 32 along with the necessary send and receive buffers, which are 16 bytes and 4 bytes in size respectively, with the send buffer being initialized to the string 0123456789ABCDEF. Then we start a consumer task which continually receives from the byte stream and prints what it receives to the console. After that we start a producer task which every second prints a newline and then sends four repeats of 0123456789ABCDEF to the byte stream (which in total are larger than the buffer of the byte stream) for a total of four times, which in turn are received and printed by the consumer task. In the process of this, the consumer task blocks when the byte stream is empty, and the producer task blocks when it cannot send its full send buffer to the byte stream due to there not being enough room its buffer for it.

Interrupt Service Routine-Safe "Simple" Channels

A "simple" channel is a queue channel that is interrupt service routine-safe as long as non-blocking operations are used within the interrupt service routines in question. Other than that they are roughly the same externally as normal queue channel except that no ordering is maintained for tasks waiting on them, so lower-priority tasks may be serviced before higher-priority tasks, and tasks of the same priority are not treated in a first-come-first-served fashion. When used with tasks they generally get poorer performance than normal queue channels, so it is recommended that one use normal queue channels except when using them from within interrupt service routines is necessary.

Take the following example:

task import
schan import
8 constant element-count
cell constant element-size
element-size element-count schan-size buffer: my-schan
element-size element-count my-schan init-schan
variable producer0-task
variable producer1-task
variable consumer-task
: producer ( n -- )
  dup 32 + swap begin 2dup > while
    [: dup [: my-schan send-schan-no-block ;] provide-allot-cell ;] try 0= if
      2 +
    then
  repeat 2drop
;
: consumer ( -- ) begin [: my-schan recv-schan ;] extract-allot-cell . again ;
0 1 ' producer 256 128 512 spawn producer0-task !
1 1 ' producer 256 128 512 spawn producer1-task !
0 ' consumer 256 128 512 spawn consumer-task !

Afterwards, execute:

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

Here a "simple" channel with an element count of eight (note that it is internally increased to nine, so one element is always wasted) and an element size of one cell is alloted and initialized. Then two producers are spawned, one which counts even numbers from 0 to 30 and one which counts odd numbers from 1 to 31. Then a consumer is spawned, which receives messages from the simple channel and prints their values to the console.

Note that these producers use the non-blocking send-schan-no-block, which raises x-would-block if it were called and would have to block were it to send a message to the "simple" channel were it a blocking operation, either due to the "simple" channel being full or due to another task simultaneously attempting to send a message to the "simple" channel. To safely use "simple" channel within an interrupt service routine one must use such non-blocking operations. On the other hand, tasks can use blocking operations such as recv-schan.

"Simple" Locks

"Simple" Locks are externally like ordinary locks, except that they are lower-overhead than ordinary locks, that they do not maintain an orderly queue of waiting tasks, and they do not handle priority inversion. They are used primarily for the construction of other multitasking synchronization and communication mechanisms, including ordinary locks. They are not recommended for maintaining mutual exclusion for any period of time but rather are meant to be used only momentarily, where longer blocking is provided by other means.

Task Termination Hooks

Tasks can have termination hooks associated with them, which are called when the tasks terminate. Task termination hooks have the stack signature ( data reason -- ). They are executed in the context of the terminated task, after it has been reinitialized; note that doing so requires internally switching to another task first, so if all tasks are blocked, this will be delayed until a task becomes unblocked. If there are no other tasks on a given core, an "extra" task will be automatically created to enable a task termination hook to be called. Also, data can be associated with tasks which is passed to the termination hooks when they are executed. Additionally, there are task termination reasons which indicate why a task terminated. These are terminated-normally (1), indicating normal task exit; terminated-killed (2), indicating that a task was specifically terminated with kill; terminated-crashed (3), indicating a hardware exception triggered task termination; or a software exception's execution token, indicating that there was an uncaught software exception within the task (except in the main task, where uncaught software exceptions return control to the REPL). Take the following:

task import  ok
variable my-task  ok
0 :noname 10 0 do i . 500 ms loop ; 320 128 512 spawn my-task !  ok
:noname space ." REASON: " h.8 space ." DATA: " h.8 space ; my-task @ task-terminate-hook!  ok
$DEADBEEF my-task @ task-terminate-data!  ok
my-task @ run  ok
0 1 2 3 4 5 6 7 8 9  REASON: 00000001 DATA: DEADBEEF

Here we create a task that counts from 0 to 9 and then exits normally, and set a task termination hook for it which outputs the task termination reason and the associates data we have set ($DEADBEEF). Then we run the task, which executes the task termination hook when it exits; the task termination reason $00000001 here indicates normal exit.

Here is another example:

task import  ok
variable my-task  ok
0 :noname 10 0 do i . 500 ms loop current-task kill ; 320 128 512 spawn my-task !  ok
:noname space ." REASON: " h.8 space ." DATA: " h.8 space ; my-task @ task-terminate-hook!  ok
$DEADBEEF my-task @ task-terminate-data!  ok
my-task @ run  ok
0 1 2 3 4 5 6 7 8 9  REASON: 00000002 DATA: DEADBEEF

This is a similar example, instead of the task we create exiting normally, we call kill against it. Note the task termination reason, $00000002, which indicates that the task has been killed.

Here is yet another example:

Welcome to zeptoforth
Built for rp2040, version 0.55.0, on Mon Jan 2 03:05:43 PM CST 2023
zeptoforth comes with ABSOLUTELY NO WARRANTY: for details type `license'
 ok
task import  ok
variable my-task  ok
0 :noname 10 0 do i . 500 ms loop 1 @ ; 320 128 512 spawn my-task !  ok
:noname space ." REASON: " h.8 space ." DATA: " h.8 space ; my-task @ task-terminate-hook!  ok
$DEADBEEF my-task @ task-terminate-data!  ok
my-task @ run 0  ok
1 2 3 4 5 6 7 8 9 
*** HARD FAULT *** 

Exception state: 
    IPSR:           00000003
    XPSR:           21000000
    Return address: 20015B2E <no name> 20015ADE +00000050
    LR:             10015387 ICSR_PENDSVSET! 10015374 +00000012
    SP:             20041B74
    R12:            DEADBEEF
    R11:            00000000
    R10:            DEADBEEF
    R9:             DEADBEEF
    R8:             DEADBEEF
    R7:             20041984
    R6:             00000001
    R5:             DEADBEEF
    R4:             DEADBEEF
    R3:             00008000
    R2:             20041B4C
    R1:             0000000A
    R0:             0000000A

Data stack:
    TOS:      00000001
    20041984: FEDCBA98

Return stack:
    20041B80: 20041984
    20041B84: 10029265 task-entry 1002925C +00000008

Terminating task
 REASON: 00000003 DATA: DEADBEEF

This example is similar to the above examples, except that we deliberately trigger a hardware exception by carrying out an unaligned access with 1 @ (as the RP2040 does not allow unaligned accesses), resulting in a hardware exception dump and task termination, with a hardware exception being indicated by a reason of $00000003.

Last but not least, take the following:

task import  ok
variable my-task  ok
: x-test ." this is a test" cr ;  ok
0 :noname 10 0 do i . 500 ms loop ['] x-test ?raise ; 320 128 512 spawn my-task !  ok
:noname space ." REASON: " h.8 space ." DATA: " h.8 space ; my-task @ task-terminate-hook!  ok
$DEADBEEF my-task @ task-terminate-data!  ok
my-task @ run  ok
0 ' x-test h.8 space 20015AE4  ok
1 2 3 4 5 6 7 8 9 this is a test
 REASON: 20015AE4 DATA: DEADBEEF

This is another example like the others, but here we raise the software exception x-test, whose execution token, $20015AE4, is reported as the reason for the task's termination.

⚠️ **GitHub.com Fallback** ⚠️