13 Tasks

Builds on: Chapter 4 (blocks), chapter 6 (queues), chapter 8 (faults).

Questions this chapter answers

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.

TokenStack after itWhat happened
do "ping" 3 count_to endblockA block that takes and gives nothing goes on as a value; the literals inside belong to it.
workers spawntaskMakes one task in the set and gives it. Fails when no place is free.
or_else (e) return endtaskSuccess: 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.

TurnTaskPrintsThen
1pingping 1yield: gives the turn up
2pongpong 1yield
3pingping 2yield
4pongpong 2yield
5pingping 3yield
6pong(nothing)i is 3: until leaves the loop and the task ends
7ping(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

The block given to 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