Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tasks and messages

narju schedules like the BEAM. tasks have their own heap, a mailbox, a turn budget and an address. the scheduler is src/sched.rs; the object language gets six primitives and builds the rest in the prelude.

(spawn f)          start a task running (f addr), answer its address
(spawn-monitor f)  the same, and watch it, in one step
(send to msg)      put a message in a mailbox
(receive)          take the next one, blocking the task if empty
(monitor to)       ask to be told when `to` ends
(limit-turns t n)  cap how long one of t's turns may run
self               this task's address, bound by the scheduler

self is a name the scheduler puts in scope, not a procedure:

narju> self
#<task 0>

The raw loop

receive blocks the task, not a thread. an empty mailbox is the only reason a task ever blocks, which is what makes deadlock analysis tractable.

(define echo
  (spawn (lambda (me)
           (task-loop me
                      (lambda h (n msg)
                        (if (eq? msg 'done)
                            (cons 'stop n)
                            (begin (say (list 'got msg)) (+ n 1))))
                      0))))
(send echo 'hello)
(send echo '(structured 1 2))
(send echo 'done)
('got 'hello)
('got ('structured 1 2))

task-loop is the prelude’s dispatch loop. it takes the task’s own address, a handler and an initial state. the handler answers with the next state, or with one of:

('stop . v)          end the task with v
(become mut state)   go on with the semantics (mut I) instead
(hand handler state) go on with a different handler entirely

the loop owns the only prompt in a task, which is what makes call work. see Calls and replies.

Messages are data

a message must be a value the scheduler can copy between heaps. a closure is not:

(define r (attempt (lambda () (send self (lambda (x) x)))))
(say (list (car r) (verb (cdr r))))
('throw 'not-data)

do not say that raise value in full. it carries the offending closure, and printing a closure prints its captured environment, which is the entire prelude.

an address is copyable and a closure is not, so behaviour crosses a task boundary as a name to send to, never as code to run. it is the same restriction that lets an address be rewritten when it crosses a network link.

Deaths

monitor asks to be told, and the telling is a message:

(define kid
  (spawn-monitor (lambda (me)
                   (task-loop me (lambda h (s msg) (cons 'stop 'finished)) '()))))
(send kid 'go)
(say (receive))
('task-down #<task 1> 'ok . 'finished)

a task that raised says so instead:

('task-down #<task 2> 'throw . 'oops)

('ok . v) or ('throw . v), the same pair attempt answers with. a task is the outermost protected region and its exit is that region’s result.

spawn and monitor as separate steps would leave a window where the child dies before the watch lands, and the monitor then reports noproc about a task that had something to say. spawn-monitor is one scheduler step for that reason.

Turn budgets

a turn that never ends is not a hang, it is a ('throw . 'unresponsive):

(define greedy
  (spawn-monitor (lambda (me) ((lambda spin (n) (spin (+ n 1))) 0))))
(limit-turns greedy 1)
(send greedy 'go)
(say (receive))
('task-down #<task 1> 'throw . 'unresponsive)

the limit is imposed by whoever spawned the task rather than chosen by it, since what it catches is a task that cannot be trusted to report on itself.

Two tasks, two languages

a task’s semantics is a field of the closure it runs, so spawn-with starts one under altered semantics without touching the spawner’s:

(define (body me)
  (task-loop me
             (lambda h (n msg)
               (if (eq? (verb (req-body msg)) 'stop)
                   (begin (reply msg n) (cons 'stop n))
                   (begin (reply msg (+ n 1)) (+ n 1))))
             0))

(define (shifted i)
  (with-handler i 'eval-lit
    (lambda h (m l e r k) (apply-cont k (+ e 10)))))

(define plain (spawn body))
(define odd (spawn-with shifted body))
(say (call plain '(add)))
(say (call odd '(add)))
1
21

same source, two languages. the odd one starts at 10 because its 0 is a 10, and adds 11 because its 1 is an 11.

an alteration this blunt needs care. case desugars to (if (eq? ...) 1 0), and under shifted that 0 is a 10, which is true. the odd task above uses if for that reason.

When a world ends

a world runs until no task can make progress. a script that spawns a server and never stops it does not exit, it parks, so examples that terminate all stop what they started.

a task that ended by raising is reported at exit and makes naj exit 1, even if somebody was monitoring it and dealt with it:

naj: task 1 failed: ('throw . 'oops)