Scarlet Industries

Processes

Concurrency is lightweight processes. A process costs a few hundred bytes and about 1 µs to start; spawn freely, one per connection or task.

Spawning

The runtime schedules processes across every core, preemptively, on a reduction budget. Blocking I/O parks only the process that blocks. Processes share no mutable state: values are immutable, and a value crossing between processes behaves as a copy. The program exits once every process has finished. There is no join.

_ = process.spawn(fn() worker(1, 40))
_ = process.spawn(fn() worker(2, 10))

spawn is the only way to start a process. Where it runs is the runtime's business: it places processes across every core and moves them between cores to keep the load level, and a connection stays on the core that accepted it without the program asking. net.serve and http.serve fan their acceptors out across cores for you. spawn returns the child's Pid, its identity for the life of the program, and process.self() gives a process its own. A Pid is a name, not a mailbox: it is what you use to tell processes apart, such as keying a map of workers, monitoring one, or killing one. A process started with spawn is linked to the process that spawned it, and spawn_unlinked starts one that is not. Failure covers what a link does.

Subjects

Processes communicate through typed mailboxes called subjects. The process that calls subject() owns it and is the only one that may receive on it. The handle itself is a value: capture it in a spawned closure or send it inside a message as a reply address, and any process holding it may send.

pub type Subject(msg)

pub fn subject() Subject(msg)
pub fn send(s Subject(msg), m msg) Nil
pub fn receive(s Subject(msg)) msg
pub fn receive_within(s Subject(msg), timeout_ms Int) Result(msg, Nil)
pub fn receive_until(s Subject(msg), deadline Instant) Result(msg, Nil)

send is fire-and-forget: it never blocks, and a message to a finished process is dropped. receive parks the owner until a message arrives. Messages on one subject arrive in the order they were sent. A server is a process that owns its inbox, so it creates the inbox itself and hands the handle back through a bootstrap subject.

type Msg {
	Add(n Int, reply Subject(Int))
	Stop(done Subject(String))
}

fn serve(inbox Subject(Msg), total Int) Nil {
	match process.receive(inbox) {
		Add(n, reply) -> {
			process.send(reply, total + n)
			serve(inbox, total + n)
		}
		Stop(done) -> process.send(done, 'served ${total}')
	}
}

fn talk_to_server() Nil {
	boot = process.subject()
	_ = process.spawn(fn() {
		server_inbox = process.subject()
		process.send(boot, server_inbox)
		serve(server_inbox, 0)
	})
	server = process.receive(boot)

	reply = process.subject()
	process.send(server, Add(5, reply))
	println('add 5 -> ${process.receive(reply)}')

	done = process.subject()
	process.send(server, Stop(done))
	println('stop -> ${process.receive(done)}')
}

The state lives in the tail call: serve recurses with the new total, and tail-call optimisation keeps the loop flat. The reply subject in each message is the return path. Arrival order across different senders is unspecified; the sum is not.

Failure

A process ends in one of 3 ways: it returns, its own code raises a runtime error, or it is killed. Scarlet has no exceptions, and its arithmetic and pattern matching are total, so the list of runtime errors is short: an index or a slice outside its array, a receive on a subject another process owns, and a type mismatch the compiler should have caught. A crash is reported on stderr as it happens.

spawn links the child to the process that spawned it. A link is symmetric: if either end crashes or is killed, so is the other, and so on through everything linked to those. A failure nobody planned for stops the program instead of leaving part of it silently dead. Returning does nothing to a linked process. spawn_unlinked starts a process that fails alone, which is how net.serve and http.serve start connection handlers: one bad request ends one connection.

kill(pid) ends a process wherever it is and whatever it is doing. It stops between 2 instructions, its sockets and ports close, its monitors report Killed, and everything linked to it is killed in turn. kill is asynchronous, like a send: monitor the process to know when it has gone.

Monitors

A monitor is how a process is told that another process ended. monitor(pid, notify, wrap) sends wrap(Down(pid, reason)) to notify when pid ends, so the notice arrives in the same mailbox, and as the same type, as everything else the process receives. One receive loop handles both.

pub type ExitReason {
	Normal
	NoProcess
	Killed
	Crashed(crash Crash)
}

pub type Down {
	pid Pid
	reason ExitReason
}

pub fn spawn(f fn() Nil) Pid
pub fn spawn_unlinked(f fn() Nil) Pid
pub fn kill(pid Pid) Nil
pub fn monitor(pid Pid, notify Subject(msg), wrap fn(Down) msg) Monitor
pub fn demonitor(m Monitor) Nil

A supervisor is a process that starts its children with spawn_unlinked, monitors each of them into its own inbox, and matches the down notices next to its ordinary traffic.

type Event {
	Finished(id Int)
	WorkerDown(id Int, down Down)
}

fn watch(events Subject(Event), id Int, work fn() Nil) Pid {
	worker = process.spawn_unlinked(work)
	_ = process.monitor(worker, events, fn(down) WorkerDown(id, down))
	worker
}

fn collect(events Subject(Event), ends Int) Nil {
	if ends > 0 {
		match process.receive(events) {
			Finished(id) -> {
				println('worker ${id} finished')
				collect(events, ends)
			}
			WorkerDown(id, down) -> {
				println('worker ${id} ended: ${string.inspect(down.reason)}')
				collect(events, ends - 1)
			}
		}
	} else {
		Nil
	}
}

fn watch_two() Nil {
	events = process.subject()
	_ = watch(events, 1, fn() process.send(events, Finished(1)))
	stuck = watch(events, 2, fn() process.sleep(60000))
	process.kill(stuck)
	collect(events, 2)
}

Normal is a process that returned. Killed is kill, on it or on something linked to it. Crashed carries the runtime error. NoProcess is what a monitor placed after the process had already ended reports: the runtime keeps nothing about a finished process, so monitor a process before it can end to be sure of the real reason.

Monitoring the same process twice delivers 2 notices. A monitor is released when it fires, when it is passed to demonitor, or when the monitoring process ends. A notice already on its way still arrives after demonitor, exactly as any message sent just before the receiver stopped caring does.