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
BASEand a 128-byte output ring. - All tasks share one dictionary and one data space with the foreground:
a
VARIABLEis 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
BASEof whoever spawned it. ChangingBASEinside a task (for example withHEX) 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. ThePAUSEafter)CRITICALabove just gives the other task its turn. - Sections nest up to 255 deep. A forgotten
)CRITICALcannot 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
MSorFRAME-WAITin 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
SPAWNandTASK-OUTwhile you develop a task; switch toSPAWN-CONonce its output is short and occasional.