Concurrency model
Processes
Section titled “Processes”The fundamental unit of concurrency is the process, similar to Erlang, but compiled natively. Each process has:
- An isolated private heap
- A lifecycle managed by the runtime
- Contained failures (a process’s death does not corrupt global state)
Processes are created via the spawn keyword, similar to Go’s go. The supervision strategy emerges from the structure of the code, not from a separately declared module or behaviour:
// Individual process: no catch, fire and forgetspawn worker(data)
// Individual process: with a failure handlerspawn worker(data) catch |e| { ... }
// Group of processes: spawn-blockspawn { worker_a(data_a) catch |e| { ... } worker_b(data_b) catch |e| { ... }}
// Group with a handler for when the whole group is exhaustedspawn { worker_a(data_a) catch |e| { ... } worker_b(data_b) catch |e| { ... }} catch |e| { ... }The data transfer semantics is annotated on the argument (see section 5).
Scheduler
Section titled “Scheduler”Two backends selectable via a compile flag, with the same communication API for both (user code does not change):
--scheduler=deterministic # rigid time-slicing, BEAM style # ideal for real-time systems and high availability--scheduler=throughput # work-stealing, Go/Zig style # default, maximum performance on multi-coreThe deterministic scheduler is the basis of BEAM-style high availability: rigid time-slicing with real preemption (safepoints inserted by the compiler at loop back-edges, OS signals, or reduction counting), so that a CPU-bound process yields the core on its quantum even without touching io.*. Work-stealing (the default) also preempts, so a tight loop does not lock a core indefinitely (8 loops on an octa-core do not deadlock); the difference is the distribution policy, not whether it preempts: the deterministic one slices rigidly for real-time predictability, the work-stealing one balances load across cores for throughput. In both, the cooperative points (io.*/send/recv/<->, section 6) are the minimum guaranteed yielding; preemption is what limits monopolization beyond them.
How a preemption actually happens. Two bounds, independent because they measure different things. Reductions: the compiler places a safepoint at every loop back-edge, each one spends a unit of the running process’s budget, and an exhausted budget yields. This bounds WORK, needs no thread, no signal and no clock, and is therefore the floor that also holds on WebAssembly and bare metal. Time: a monitor watches how long each worker has been on the same process and requests a preemption when it overruns its quantum. This bounds LATENCY, and it catches what reductions cannot – a loop whose every iteration is expensive burns milliseconds while spending almost no budget.
A “safepoint” here is a point the COMPILER placed where suspending is known to be safe, not an arbitrary instruction. So a process that overruns does not stop where it stands: it stops at its next safepoint, at most one loop iteration away in compiled Makoto code. Code containing no safepoint at all – C-FFI (section 17) and hand-written @asm – is therefore not preemptable this way; blocking FFI is handled by absorption instead, as described below.
Async-forced preemption: stopping at an arbitrary instruction (opt-in). The two bounds above are cooperative: they only take effect at a safepoint the compiler placed, so a stretch of straight-line code with no back-edge – a long unrolled computation, a hot inner sequence – can still overrun without ever reaching one. The third mechanism removes that gap. Inside an async region (opt-in, off by default) the scheduler can force a preemption at a genuinely arbitrary instruction: it delivers an async signal (SIGURG) to the worker, a trampoline saves the full register set and suspends the fiber mid-expression, and the process resumes bit-identically later. Stopping mid-expression is exactly what needs per-instruction metadata recording which registers hold pointers, so a collection can still read the frame – and that is what precise GC provides (the stackmaps of section 5), so async-forced preemption rides on the same machinery and is available wherever precise stackmaps are. It stays opt-in because the cooperative floor (reductions) is enough for the vast majority of code and costs nothing when no collection or overrun happens; the async region is the escape-hatch for the rare safepoint-free hot path that must still be bounded. The observability layer (section 19) counts the two apart – how many preemptions were cooperative (a safepoint) versus async-forced (a signal) – so you can see whether a workload is actually leaning on the forced path.
Blocking FFI and the scheduler. A C call that blocks (section 17) does not go through the runtime’s non-blocking IO: it is a raw call that pins the OS thread. Under work-stealing, this is absorbed: the blocked thread stops, the others continue and steal its work (like BEAM’s dirty schedulers and Zig’s threaded backend). Under deterministic (real-time, without that slack), a blocking call would break the guarantee, so there you isolate it in a dedicated process (your responsibility, consistent with “C-FFI is unsafe by default”, section 17).
Process reference and registration
Section titled “Process reference and registration”By default, spawn is fire-and-forget: spawn worker(x) loose, without ceremony, and nothing has to be captured (no _ := spawn ... on every fire-and-forget). When you do want the process later – to wait for it, stop it, or inspect it (observability, section 19) – bind it: p := spawn worker(x) gives a Process handle to the process just started. Binding is optional; the loose statement stays the common case. (A spawn { ... } block is a group, not one process, so it cannot be bound; its members are addressed through registration below.)
A Process handle names one incarnation of a process, exactly like its id (section 19): if a supervisor restarts the process, the restart is a new incarnation and the old handle keeps naming the one that died. The reference that survives restarts is the registered name, below.
Waiting and stopping. Two methods act on a handle, and two shortcuts act on all of the caller’s children:
p := spawn worker(x)e := p.wait() catch |err| { ... } // blocks until p dies; e is HOW it died: error.Normal, Crashed(msg), error.Killed, ...p.kill() // asks p to stop: it runs its defers and dies with error.Killedruntime.wait_children() // wait until every child of this process has diedruntime.kill_children() // ask every child of this process to stopp.wait() delivers the death as its value – a death is an error value (section 7/8), and here it is data to inspect, not a failure of the wait. Crashed(msg) carries its text in the value, so the death can be matched, printed (error.Crashed(boom)) or kept after the wait returns. The wait itself fails like a receive does: with error.ProcessDown when the process is already gone and how it died is no longer known, and with error.Timeout when a timeout(d) trailer runs out first (p.wait() timeout(5s) catch |err| { ... }; timeout(0) looks once, timeout(_) waits with no deadline). The death of a child spawned with a bound handle is kept until it is waited for or until its parent dies (as an exit status waits to be reaped), so waiting for your own children never misses; once it has been waited for, a second wait answers error.ProcessDown. Any process can wait on a handle it holds – a handle is a value, it can travel over a channel – and a process that is still alive is waited for until it dies. A wait is a cooperative point: if the waiter supervises the process it waits for, the handler for that death runs before the wait returns, and runtime.wait_children() likewise returns only after the handlers of its children’s deaths have run (looking again if one of them restarted a child). A child stopped by its supervisor’s one_for_all/rest_for_one sweep reads as error.Killed. p.kill() only asks: it returns at once, and the process stops at its next safepoint or cooperative point, running its death sequence (section 8); follow it with p.wait() to know when it is gone. Killing a process that already died does nothing; a process may kill itself, but it cannot wait for itself – that wait could never end, so it is a crash (Crashed("a process cannot wait for itself")).
Registration remains the way to reference a process by name, opt-in, via a decorator.
@register("name") gives the process a stable name (string). The name is unique (registering two under the same name is an error) and, most importantly, survives restart: a supervised process that crashes and restarts is a new process with a new internal identity, but the supervisor re-registers the new one under the same name. That is why the reference is a name, not a raw id: an id is a point-in-time and ages on the first restart; the name crosses the deaths and rebirths.
@register("db_writer")spawn db_writer(conn) // registered under "db_writer"; stable across restart@register("name", group) adds the process to a group: a collection under a name, addressable by index. It is the answer to the high-volume case: you do not invent a name for 10,000 identical workers; you register all of them in a group and iterate/monitor by index. The collision semantics is opposite to the unique registration’s (there, colliding is an error; here, “colliding” is the expected append), and it is the second argument that inverts it: group marks the append. The common case (one process, one name) is @register("name") (implicit default; @register("name", single) is the explicit form, if you want to nail it down).
loop req in requests { @register("handlers", group) // each one joins the "handlers" group spawn handle(req) // no individual name; addressable by index}
spawn log_metric(x) // fire-and-forget: no decorator, no reference, no costThe registration decorators are of the “reference” category and compose with @supervisor (“supervision” category) on the same stack:
@supervisor(max_restarts: 3, window: 10s)@register("db_writer")spawn db_writer(conn) // supervised AND registeredFrom inside any process, runtime.self() returns its own reference, useful to identify itself, or to hand the reference to another process (over a channel) as “inspect me here”. It is orthogonal to registration: named or not, a process can always identify itself from the inside.
There is no raw pid type that you manipulate. Communication between processes is by channel (section 4), not by addressing a pid; supervision is structural (the tree is the code, section 8). A reference serves to wait for, stop or inspect a process, never to message it: you get one by binding a spawn, by name (runtime.process("name") a Process handle), by group (runtime.group("name")), or from the tree (runtime.self().children()). Whoever does not need a reference does not bind or register, and pays nothing.