13 Tasks
Builds on: Chapter 4 (blocks), chapter 6 (queues), chapter 8 (faults).
Questions this chapter answers
- How does one state take turns at several jobs?
- When do tasks change, and how do they pass data?
- What happens when one task has a fault?
13.1 Taking turns
A task is a flow of execution inside one state. It is not a thread: tasks change only where a task yields on its own. So nothing comes in between two yields, and no locks are needed.
tasks NAME count declares a task group, block GROUP spawn makes a task, and GROUP run_all runs them until all have ended.
13-turns.pnx
use io
tasks workers 2
word count_to (name str, n int)
1 to i
loop
i n gt until
cat (name, " ", i) io.println
yield or_return rem give the other task a turn
i 1 add to i
end
end
word demo ()
do "ping" 3 count_to end workers spawn or_else (e) return end drop rem spawn gives the task; not needed here
do "pong" 2 count_to end workers spawn or_else (e) return end drop rem nothing runs yet: both wait for a turn
workers run_all rem now they run, until both are done
end
demo
Output
$ pnx 13-turns.pnx
ping 1
pong 1
ping 2
pong 2
ping 3
One spawn line, followed, shows what a task takes and gives.
| Token | Stack after it | What happened |
|---|---|---|
do "ping" 3 count_to end | block | A block that takes and gives nothing goes on as a value; the literals inside belong to it. |
workers spawn | task | Makes one task in the set and gives it. Fails when no place is free. |
or_else (e) return end | task | Success: skipped. On failure the word is left. |
drop | — | The task value serves resume and cancel; here run_all runs it, so it is dropped. |
spawn only makes a task; it does not run it. run_all gives each task of the set one turn after another, and a task gives its turn up at yield. Turn by turn, this is why the output alternates.
| Turn | Task | Prints | Then |
|---|---|---|---|
| 1 | ping | ping 1 | yield: gives the turn up |
| 2 | pong | pong 1 | yield |
| 3 | ping | ping 2 | yield |
| 4 | pong | pong 2 | yield |
| 5 | ping | ping 3 | yield |
| 6 | pong | (nothing) | i is 3: until leaves the loop and the task ends |
| 7 | ping | (nothing) | i is 4: the task ends. All have ended, so run_all returns |
Each task has its own locals (name, n, i) and its own stack. So after going away at yield and coming back, it goes on exactly where it was.
13.2 Linking with queues
Tasks pass data through declared storage, most often queues. q wait_take takes from a queue and v q wait_put puts into one; while the queue is empty (or full) the task yields and looks again on its next turn.
13-pipeline.pnx
use io
rem Three stages linked by queues: numbers -> squares -> total.
tasks stages 3
queue numbers int 2
queue squares int 2
global total int
word produce ()
1 to i
loop
i 5 gt until
i numbers wait_put or_return rem waits while the queue is full
i 1 add to i
end
-1 numbers wait_put or_return
end
word square ()
loop
numbers wait_take or_return to n rem waits while the queue is empty
n 0 lt if -1 squares wait_put or_return return end
n n mul squares wait_put or_return
end
end
word add_up ()
loop
squares wait_take or_return to n
n 0 lt until
total n add set total
end
end
word demo ()
do produce end stages spawn or_else (e) return end drop
do square end stages spawn or_else (e) return end drop
do add_up end stages spawn or_else (e) return end drop
stages run_all
cat ("1 + 4 + 9 + 16 + 25 = ", total) io.println
end
demo
Output
$ pnx 13-pipeline.pnx
1 + 4 + 9 + 16 + 25 = 55
Each of the three stages is a task, and the queues hold only two values each. The stages wait for each other and take turns. The end is announced by sending -1.
When every task waits for a queue that none of them will fill, nothing can go on. run_all sees that and ends the run with the fault deadlock, instead of turning forever. In the main run, which is not a task, waiting for a queue is that fault at once.
13-deadlock.pnx
use io
tasks stages 2
queue numbers int 2
queue squares int 2
word square ()
numbers wait_take or_return to n
n n mul squares wait_put or_return
end
word show ()
squares wait_take or_return to n
cat ("square: ", n) io.println
end
word demo ()
do square end stages spawn or_else (e) return end drop
do show end stages spawn or_else (e) return end drop
stages run_all rem no one puts into numbers
end
demo
Fault
$ pnx 13-deadlock.pnx
13-deadlock.pnx:16: deadlock: waiting for a queue that nothing can change
Only the two waiting words are watched. A task that waits in a loop of its own (loop … yield … end) is not, so a host that runs such code gives it a budget (chapter 14).
Common mistake: The block of spawn sees the locals around it
spawn is a block value and does not see the locals around it, because the task may live on after that word has ended. To give a task values, use globals or queues, or — as in the examples above — call a word inside the block with literals.13-no-locals.pnx
tasks workers 2
word start (n int) do n drop end workers spawn or_else (e) return end drop end
Refused
$ pnx 13-no-locals.pnx
13-no-locals.pnx:2:23: this name is not declared before this place
13.3 Faults and cancelling
A fault in one task ends the whole run — the other tasks stop too. The state remains and takes the next run.
13-fault.pnx
use io
tasks workers 2
global zero int
word demo ()
do "the healthy task starts" io.println yield or_return "and never finishes" io.println end workers spawn or_else (e) return end drop
do 1 zero div drop end workers spawn or_else (e) return end drop
workers run_all
end
demo
Fault
$ pnx 13-fault.pnx
the healthy task starts
13-fault.pnx:6: division by zero
When the tasks do work that must not take the others down with it (requests of a server, jobs from a queue), declare the group apart. Then a fault ends only the task that has it; the others go on, and so does the run.
13-apart.pnx
use io
tasks workers 2 apart
global zero int
word demo ()
do "the healthy task starts" io.println yield or_return "and finishes" io.println end workers spawn or_else (e) return end drop
do 1 zero div drop end workers spawn or_else (e) return end drop
workers run_all
"the run goes on" io.println
end
demo
Output
$ pnx 13-apart.pnx
the healthy task starts
and finishes
the run goes on
TASK cancel makes that task’s next yield, or its wait for a queue, fail with cancelled. The task takes the failure with or_else, tidies up and ends. Cancelling does not stop a task by force.
Q&A
Q. How many tasks can there be?
A. As many as the count of tasks NAME count, at most 4096. The place of a task that ended is used again. Each task has a call depth and a region of its own, so a large count needs a large block.
Q&A
Q. What does yield do in the main run?
A. Nothing, and it succeeds. The main run is not a task and has nobody to give a turn to. So the same word serves inside a task and outside.
Summary
- Tasks change only where they
yield(standard 14.2). spawnmakes a task from a block that takes and gives nothing;run_allruns them (14.1).- Tasks share data through globals and queues;
wait_takeandwait_putwait for a queue, and a run where every task waits for one is the faultdeadlock(14.3). - Blocks given to
spawndo not see the locals around them (14.4). - A fault in a task ends the whole run (14.4), or only that task when its group is declared
apart(14.4 (3a)).