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)