Tasks and Preemption

VIOS runs up to four background tasks beside the console. A task is any Forth word started with SPAWN or SPAWN-CON: a clock in the corner, a music player, a filter in a pipeline, a game loop. The console (the foreground) stays usable while tasks run, and on the board a timer tick shares the CPU between them even when nobody yields.

The model

  • There are 4 task slots, numbered 1 to 4. A task id is its slot number.
  • Each task has its own data stack (64 cells), return stack (64 cells), loop stack (16 nested loops), its own BASE and a 128-byte output ring.
  • All tasks share one dictionary and one data space with the foreground: a VARIABLE is visible to everybody.
  • The foreground is whatever the console is running. It is not a slot and cannot be killed; Ctrl-C interrupts it.
  • A new task starts with an empty stack and the BASE of whoever spawned it. Changing BASE inside a task (for example with HEX) does not affect the console.

A task's life:

State Number Meaning
free 0 slot never used
ready 1 waiting for its next slice
sleeping 2 in MS or FRAME-WAIT until its wake time
done 3 the word returned
failed 4 an error stopped it (the message is kept)
killed 5 stopped by TASK-KILL

TASK-STATE ( id -- n ) returns the number. A slot is reused once its task is no longer ready or sleeping.

Starting a task

SPAWN ( xt -- id ) starts a word as a task and returns its id. Its output goes to its private ring, which you read with TASK-OUT:

: COUNTDOWN 5 0 DO 5 I - . 20 MS LOOP ." liftoff" CR ;
' COUNTDOWN SPAWN CONSTANT T1
T1 .
200 MS
T1 TASK-STATE .
TASK-OUT 1
1 3 task-out 1 hex 352034203320322031206C6966746F66660D0A dropped 0

TASK-OUT prints the bytes in hexadecimal (35 20 34 ... is 5 4 3 2 1 liftoff) and empties the ring, so it is meant for tools. If the task prints more than 128 bytes before you collect them, the rest is counted as dropped.

SPAWN-CON ( xt -- id ) is the same, but the task's output goes to the console. To keep background text from landing in the middle of your typing or of a command's reply, it waits in the ring and is printed once the console has been idle for half a second, on a fresh line, like a shell's background-job messages:

: TICK BEGIN 1000 MS ." tick" CR AGAIN ;
' TICK SPAWN-CON .
vios> ' TICK SPAWN-CON .
2 ok
vios>
tick
tick

Two consequences of the 128-byte ring: a console task that prints more than 128 bytes while the console is busy (or within half a second) loses the rest (the dropped count in TASKS shows it), and a slot whose console text has not been printed yet is not reused.

If a task needs more than four slots' worth of work, combine the jobs in one word; the slot limit is fixed in the resident.

Yielding and sleeping

Word In a task In the foreground
PAUSE end this slice now give every ready task one slice
MS ( u -- ) sleep u ms without using the CPU wait u ms, running tasks meanwhile
FRAME-WAIT sleep to the next 60 Hz frame wait for the next frame
KEY read the task's input handle; wait while it is empty read the keyboard, running tasks meanwhile

The task clock counts the eZ80's 288 kHz timer, so MS needs no round trip to the ESP. FRAME-WAIT wakes on multiples of 50/3 ms, which gives a render loop a steady 60 frames per second whatever its drawing took. Measured on the board: 60.0 frames per second idle, beside a busy task, and while the foreground computes.

In the foreground, Ctrl-C interrupts a long MS or FRAME-WAIT like any other command.

Preemption

On the board, the timer interrupt preempts tasks at safe points between Forth instructions, so a task that never calls PAUSE cannot freeze the others or the console:

  • A background slice lasts at most 4 ms.
  • While a task is ready, the foreground is interrupted every 4 ms for one scheduler round, even in the middle of a long computation.
  • When only sleeping tasks exist, the foreground runs undisturbed until the next wake time.
  • While a command runs, the console is checked for Ctrl-C every 50 ms.

The cost of the tick itself is measured at about 0.35 % of CPU time. A long computation beside a busy task takes roughly twice as long, because both share the CPU; with preemption off, the task would starve until the computation ends.

Each slice also has an instruction budget, set with TASK-SLICE n (64 to 65535, default 1024). With preemption on, the 4 ms tick usually ends a slice first. The budget is the only limit when preemption is off and on host builds.

PREEMPT ( flag -- ) turns tick preemption off (0) or on again (non-zero). With it off, tasks switch only at PAUSE, MS, FRAME-WAIT, a waiting KEY, the end of a slice budget, or while the console is idle: cooperative multitasking, fully deterministic. PREEMPT-STATS shows the state:

0 PREEMPT  1 PREEMPT
PREEMPT-STATS
preempt on switches 3244 ignored 0 critical 0

switches counts tick switches; ignored counts ticks that arrived while switching was not allowed (inside CRITICAL(, while compiling, during a module transaction or TRACE); critical is the current CRITICAL( depth. On a host build the first word is none: there is no tick source.

Critical sections

Because a tick can switch tasks between any two instructions, a read-modify-write of a shared variable such as COUNTER @ 1+ COUNTER ! can lose updates when two tasks do it at once. Wrap it in CRITICAL( ... )CRITICAL: no tick switches tasks in between.

VARIABLE COUNTER
: BUMP 1000 0 DO CRITICAL( COUNTER @ 1+ COUNTER ! )CRITICAL PAUSE LOOP ;
' BUMP SPAWN DROP  ' BUMP SPAWN DROP
: BOTH-DONE? 1 TASK-STATE 3 =  2 TASK-STATE 3 =  AND ;
: WAIT-BOTH BEGIN 10 MS BOTH-DONE? UNTIL ;
WAIT-BOTH COUNTER @ .
2000

Rules for critical sections:

  • Keep them short: a few words. While one task is inside, no tick switches anything.
  • Each task (and the foreground) has its own depth, and a background slice whose instruction budget (TASK-SLICE) runs out inside a section continues until )CRITICAL, so a section is never split. The PAUSE after )CRITICAL above just gives the other task its turn.
  • Sections nest up to 255 deep. A forgotten )CRITICAL cannot hang the system: the normal execution budget still applies, and a command that fails resets the depth.
  • Use them also around multi-part VDU sequences that must reach the display unbroken.

Watching and stopping tasks

TASKS lists all four slots:

TASKS
task-slice 1024
task 1 state done xt 255 name 42554D50 slices 1001 steps 18004 depth 0 rdepth 1 ds-peak 2 rs-peak 1 out 0 dropped 0 error - base 10
task 2 state done xt 255 name 42554D50 slices 1001 steps 18004 depth 0 rdepth 1 ds-peak 2 rs-peak 1 out 0 dropped 0 error - base 10
task 3 state free xt 0 name - slices 0 steps 0 depth 0 rdepth 0 ds-peak 0 rs-peak 0 out 0 dropped 0 error - base 0
task 4 state free xt 0 name - slices 0 steps 0 depth 0 rdepth 0 ds-peak 0 rs-peak 0 out 0 dropped 0 error - base 0

Both BUMP tasks are done; slot 1, where COUNTDOWN ran, was reused. Names and error texts are printed as hex bytes (42554D50 is BUMP) so that tools can parse every line. For a readable list, read /dev/tasks (see Handles, Pipes and Devices): it prints id state name slices steps error in plain text.

TASK-KILL ( id -- ) stops a task. It moves to state 5, the rest of its output is delivered where possible, and every handle it owns is closed:

: FOREVER BEGIN 5 MS AGAIN ;
' FOREVER SPAWN CONSTANT F
50 MS  F TASK-STATE .
F TASK-KILL  F TASK-STATE .
2 5

A task that fails keeps its error message. Typical ones:

Error Cause
KEY unavailable in background task KEY in a task without an input handle (TASK-INPUT)
native service unavailable in background task a service that only the foreground may call, such as REBOOT
task stack exceeds saved capacity more than 64 data or return stack cells, or 16 nested loops
broken pipe the task's output pipe has no reader any more
killed TASK-KILL

Device services (VDU, file words, sound, handles) are safe to call from tasks. LOAD, INCLUDE, the module commands and the other console commands are not words, so a task cannot run them.

Ctrl-C interrupts the foreground command (prints error: interrupted), including a runaway BEGIN AGAIN typed at the console, but it does not stop tasks. Use TASKS and TASK-KILL for those. At the prompt, Ctrl-C clears the line.

Task output and input

Where a task's output goes depends on how it was started:

Started with EMIT, TYPE, ., CR go to
SPAWN the private ring; read it with TASK-OUT id
SPAWN-CON the console, after half a second of console idle
SPAWN + TASK-OUTPUT a handle: a pipe, a file, /dev/cons, ...

TASK-OUTPUT ( id h -- ) and TASK-INPUT ( id h -- ) attach a handle as a task's standard output or input; the task takes ownership of the handle and it is closed when the task ends. A task's KEY then reads from the input handle, waits while a pipe is empty, and returns -1 at end of input. This is how PIPELINE joins two tasks; see Handles, Pipes and Devices.

Attach handles right after SPAWN, inside CRITICAL( ... )CRITICAL, so the new task cannot run before it is wired up:

: SHOUT ." to a file" CR ;
S" /VIOS/SHOUT.TXT" 2 OPEN CONSTANT OUTH
CRITICAL( ' SHOUT SPAWN DUP OUTH TASK-OUTPUT )CRITICAL
CONSTANT T3
100 MS  T3 TASK-STATE .
S" /VIOS/SHOUT.TXT" FILE-REMOVE .

AUTORUN: an application at boot

If, after boot, a word named AUTORUN exists (normally defined in a module of the confirmed boot slot), VIOS starts it with ' AUTORUN SPAWN-CON and prints autorun task N. It runs as a task, not in the foreground, so the console stays usable and you can stop a misbehaving application with TASK-KILL. A failure to start is reported as autorun failed: and boot continues. The boot order is: boot slot modules, the console, /VIOS/USER.FTH, then AUTORUN.

A module for a clock that runs from power-on might contain:

VARIABLE SECONDS
: AUTORUN BEGIN 1000 MS 1 SECONDS +! AGAIN ;

Tips

  • Prefer MS or FRAME-WAIT in loops: a sleeping task costs nothing.
  • Give every endless task a way out, or remember its id for TASK-KILL.
  • Keep the data stack balanced in task loops: a slow leak ends in task stack exceeds saved capacity.
  • Use SPAWN and TASK-OUT while you develop a task; switch to SPAWN-CON once its output is short and occasional.