Processes
Plan 11 step 43 (2026-10-08): spawned processes on a cooperative executor; step 44: exit reasons and error reports; step 45: sending messages; step 46: selective receive; step 47: receive timeouts; step 48: links and exit signals; step 49: monitors; step 50: registered names; step 53: ports (none); step 56: scheduler workers; step 57: cross-worker wakeups and shutdown.
Executor
One runtime runs its processes on its scheduler workers (workers, execution model). Startup makes the entry function's call the first process, the main process, and runs the executor until it ends:
- Runnable processes wait in one first-in, first-out queue. A worker runs
the first one for a time slice of 4,000 reductions (OTP's
CONTEXT_REDS), then puts it back at the end of the queue, unless it ended. - Every function entry spends one reduction: calls, tail calls, fun and dynamic calls, and builtins. With none left, the entry does not happen: the process records the function it was entering, keeps its arguments in its registers (they stay collection roots) and returns to the executor, which later resumes it by repeating the entry. A loop that makes no call (a comprehension over a list without calls in its body) runs to its end before the process can yield. Builtins whose work grows with a list or binary argument run in portions and yield between them (portions).
- Each process owns its heap and stack. Arguments of a new process are copied into its heap (copying between heaps).
- A process ends when its first call returns or raises. An ended process's
context, heap and stack are released at once; its pid stays a valid term
and
is_process_alive/1returns false for it. - A process waiting in a
receiveis not in the queue; a send to it puts it back at the end (receive). - When the main process ends, the program ends with its outcome
(executables): processes still queued or waiting are
released without running further, as an OTP escript halts when
main/1returns. - An exit signal that ends the main process ends the program (exit signals).
erlang:halt/0,1in any process ends the program with its status. A runtime failure in any process (memory exhausted, an optional cap exceeded, an internal error) ends the program as a runtime failure (exit 70). An Erlang exception ends only the process that raised it (exits).- Host invocations of exported functions (
CLAUSE_invoke_v1) run their function in the calling context to completion, resuming it after each yield without running other processes; programs started byCLAUSE_main_v1use the executor.
Workers
Plan step 56 (2026-10-08). The executor runs processes on
RuntimeOptions::schedulers worker threads: the thread that started the
program and one more thread per further worker. Programs take the count from
--schedulers N (1 to 1,024, runtime options);
the default is one worker per logical processor, as OTP's +S.
- Every worker takes the first process of the one shared queue (the documented alternative to per-worker queues with work stealing: one queue keeps OTP's first-in, first-out order and needs no stealing). Idle workers sleep until a process is queued or the earliest receive timeout expires.
- One executor mutex guards the queue, the waiting processes, the timers, the registered names, the links and monitors of every process, and every process that is not running. A running process's heap, stack, mailbox and failure channel belong to its worker alone; Erlang code runs without the lock.
- Links, monitors and names change under the lock at once, also for a process
running elsewhere. A send, or an exit signal of
exit/2orexit_signal/2, to a process running on another worker cannot touch its heap: the builtin does nothing and its process ends its slice (builtins::Blocked); it waits until the target's slice ends, then runs first and repeats the builtin with the same arguments. The target is held until then, so the repeated builtin cannot find it running again. A process that ends with links or monitors whose processes run elsewhere is finished likewise once their slices end. - So messages and exit signals still take effect when they are sent: a send
returns after the message is in the receiver's mailbox, and
is_process_alive/1afterexit(Pid, kill)is false, as OTP promises for signals from the caller. - A spawn links or monitors the new process (
spawn_link,spawn_monitor) before any worker can run it. A spawned process may run before its parent goes on, so a program that monitors or links to a short-lived process afterspawn/1can seenoproc, as on a multi-scheduler OTP. - The main process's end, a halt or a runtime failure stops the program once every worker has finished its current slice; then the other processes are released.
- Shared runtime services are synchronized (threads): atoms, the code server, pid numbers and the memory account.
- Output of different processes interleaves in the order their writes happen.
Each
io:formatanderlang:displaycall writes its text at once.
Wakeups and shutdown (plan step 57) need no further mechanism, because every change of a process's scheduling state happens under the executor mutex:
- A message,
'EXIT'or'DOWN'for a waiting process queues it in the same critical section that delivers it, and wakes one idle worker. A process that is still in the slice in which it began to wait cannot get a message then (its senders wait for the slice to end), so the delivery always finds it parked. - A worker that finds no runnable process sleeps until another worker queues one, the earliest receive timeout, or the program's end; parking a process with a timeout wakes every idle worker so they wait for the new deadline. Busy workers check the timers before every slice.
- A message and an expiring timeout of one receive may race: whichever comes first queues the process and cancels the other, and the receive takes a message that arrived before it resumed, as OTP's does.
- An exit signal to a waiting, queued, held or blocked process takes it out of wherever it is before it is finished.
- When the program ends, every worker finishes its current slice and stops;
run()returns after joining them, and every other process the executor started is released, so the runtime shuts down with no context left. - The OTP golden
executables_wakeupsstresses this with 1, 2, 4 and all workers: receivers whose 0–2 ms timeouts race their senders' messages, a chain of 16 linked spinning processes ended by one exit signal with every member monitored, monitored processes ending as their watcher wakes, a program ending while processes spin, wait and flood each other, and a halt in another process.
Exits
A process other than the main one ends with an exit reason, as in OTP
(detail::exit_reason, process/exits). Exit signals carry it to linked
processes (links) and 'DOWN' messages to monitoring ones
(monitors); error reports show it.
| How the process ends | Exit reason | Error report |
|---|---|---|
| Its first call returns | normal | None |
exit(Reason) (also normal, kill) | Reason | None |
An error (error/1,2,3, badarith, {badmatch, V}, undef, ...) | {Reason, Stack} | Yes |
An uncaught throw(Value) | {{nocatch, Value}, Stack} | Yes |
| An exit signal ends it (exit signals) | The signal's reason, killed for exit(Pid, kill) | None |
An error report is written on stderr when the process ends, after flushing standard output, in the format of OTP's default logger handler:
=ERROR REPORT==== 8-Oct-2026::03:42:15.983000 ===
Error in process <0.8.0> with exit value:
{boom,[{crash_reports,'-main/1-fun-6-',0,[]}]}
The header has the local time; the reason is laid out as ~p does. The main
process does not write one: its uncaught exception is the program's
(executables).
Messages
Dest ! Msg and erlang:send(Dest, Msg) (plan step 45) evaluate Dest
first and return Msg.
Dest | Effect |
|---|---|
| A pid of a live process | Msg is copied into the receiver's heap (copying between heaps), keeping its sharing, and appended to its signal inbox |
| A pid of a process that has ended | Nothing; the send succeeds |
| A registered name | As for its pid; badarg when no live process has the name |
{Name, nonode@nohost} of two atoms | As for the pid registered as Name; nothing when there is none |
{Name, Node} for any other node | Nothing (there are no other nodes) |
| Anything else | badarg |
- Messages of one sender arrive in the order it sent them; a self-send is a message like any other.
- Every message is a collection root of its receiver until a receive takes it (runtime heap).
- The receiver's mailbox (
Mailbox) keeps a signal inbox, where sends append, and a message queue that receive scans from a saved position: arrived messages move behind the queue when a receive examines them. - When the receiver's heap refuses the copy (an optional cap such as
--max-heap, or the host out of memory), nothing is delivered and the sending process fails as a runtime failure (exit 70), like any process that exceeds a cap.
Receive
receive (plan steps 46–47) selects among its clauses like case, over the
messages of the mailbox:
- The mailbox scan starts at the oldest message. Each message is matched
against the clauses in order (patterns and guards, which may read bindings
from before the
receive); the first message some clause matches is removed and that clause's body runs. Messages no clause matches stay in the mailbox in their order; the next receive starts at the oldest message again. - When every message has been examined, the process waits
(
CLAUSE_wait_frame_v1, a builtin entered like a call): it leaves the run queue until a send delivers a message to it, then the scan continues with the messages that arrived. Waiting processes keep their frames and messages as collection roots and collect when resumed (waiting processes). - Names bound in every clause, and in the
afterbody when there is one, are exported after thereceive, as forcase; a call in a clause orafterbody's tail position is a tail call, so a server loop runs in constant stack. after T -> Body:Tis evaluated first, before the scan. When the receive would wait,Tmust beinfinityor an integer in 0..4294967295 (milliseconds), elseerror:timeout_value; a message that matches at once never checks it, as in OTP.after 0runsBodyas soon as every message has been examined. A finite timeout starts when the receive first waits and is not restarted by messages that match no clause; when it expires,Bodyruns from the bindings before the receive and the next receive scans from the oldest message. A message that arrives before the timeout expires is taken. A receive with onlyafteris a sleep (timer:sleep/1's idiom).- Generated code: a loop head peeks at the next unexamined message
(
CLAUSE_receive_v1peek, into a root slot), clause selection takes a matched message (take) before its body or skips an unmatched one (skip) and loops; with no message left the loop enters the wait with the timeout, which answerstrue(scan again) orfalse(timed out:restart, then theafterbody). - The executor keeps a timer per waiting process with a finite timeout: an expired one puts the process back in the queue, and when no process can run the executor sleeps until the earliest timer. Timeouts are measured on a monotonic clock in milliseconds and never fire early.
- When every process waits without a timeout for a message nothing can send,
the program waits forever, as OTP's does. A host invocation (
CLAUSE_invoke_v1) that would wait fails withbusyinstead: no other process runs during it.
Links
Plan step 48. A link connects two processes both ways (Signals in each
context: the linked pids in link order and the trap_exit flag).
link(Pid)links the caller to a live process and returnstrue; linking to itself or again does nothing. For a process that has ended it raiseserror:noproc, or, when the caller traps exits, returnstrueand sends the caller{'EXIT', Pid, noproc}, as OTP's locallink/1does.unlink(Pid)removes the link on both sides; the link has no effect after it returns.truealso when there was no link.spawn_link/1,3link the new process to the caller before it runs.- When a process ends, every linked process gets an exit signal with its exit reason (exits) and the link is gone. Linked processes are signalled in the order the links were made.
Monitors
Plan step 49. A monitor is one-way: the monitoring process holds it (by
reference, in Signals) and the monitored process keeps the reference and
the monitoring pid, so it can send the message when it ends.
monitor(process, Pid)returns a new reference. When the process ends, the caller gets{'DOWN', Ref, process, Pid, Reason}with its exit reason (exits); for a process that has already ended it gets the message at once with reasonnoproc. Monitoring itself creates nothing. Every call makes a separate monitor with its own message; the messages of one process go out in the order the monitors were made.demonitor(Ref)stops the monitor: no'DOWN'of it arrives afterwards. It returnstrue, also for a reference that is not an active monitor of the caller.demonitor(Ref, Options):inforeturns whether the monitor was still active;flushremoves the oldest{_, Ref, _, _, _}message when it was not (its'DOWN'is already in the mailbox), as OTP does.spawn_monitor/1,3return{Pid, Ref}, the monitor made before the new process runs.- A process that ends drops the monitors it holds.
monitor(process, Name)andmonitor(process, {Name, nonode@nohost})monitor the process registered asName(noprocat once when there is none); its'DOWN'names{Name, nonode@nohost}instead of the pid.{Name, Node}for another node isbadarg.monitor(port, Port | Name)monitors a port (ports):{'DOWN', Ref, port, Port, Reason}when it closes. A pid forport, or a port forprocess, isbadarg.
Registered names
Plan step 50. The executor keeps one table of names (atoms) to pids, and each
process its own name (Signals::name).
register(Name, Pid)names a live process:badargforundefined, a name in use, a process that already has a name, a process that has ended, or aPidthat is not a pid. A process may register itself.unregister(Name)releases the name (badargwhen no process has it);whereis(Name)is the pid orundefined;registered()lists the names in atom-table order (OTP's order is unspecified too).- A process's name is released as it ends, before its links and monitors are
signalled, so a
'DOWN'or'EXIT'receiver can register the name again, as in OTP.
Exit signals
Every exit signal comes from a running process (exit/2,
exit_signal/2, link/1) or from the end of a process. The executor acts on
it at once when its target does not run on another worker, else once the
target's slice ended (workers, scheduler/signals): an ended
target leaves the run queue or its wait and is finished (its links
signalled, its error report written, its context released) before the
sending builtin returns. A long linked chain ends process by process without
recursion.
| Signal at a process | Not trapping exits | Trapping exits |
|---|---|---|
exit(Pid, kill), exit_signal(Pid, kill) | Ends with reason killed | Ends with reason killed |
Reason normal (from a link, or sent to another process) | Nothing | {'EXIT', From, normal} message |
exit(self(), normal) | Ends with reason normal (OTP's quirk) | {'EXIT', Self, normal} message |
exit_signal(self(), normal) | Nothing | {'EXIT', Self, normal} message |
Any other reason, including kill from a link | Ends with that reason | {'EXIT', From, Reason} message |
process_flag(trap_exit, Bool)sets trapping and returns the previous setting (initiallyfalse).- A process ended by a signal it sent itself (
exit(self(), kill)) unwinds past everycatch,tryhandler andafterbody: the signal is not an exception (CallError::exitedin the failure channel). exit/2andexit_signal/2returntrue; to a process that has ended they do nothing. A reference destination does nothing either (there are no process aliases).- An exit signal that ends the main process ends the program like an
uncaught
exit(exit status): reasonnormalexits 0. - When the receiver's heap refuses an exit reason or
'EXIT'message, the receiver fails as a runtime failure (exit 70).
Ports
Ports (plan steps 57A–57F) are specified in ports: a port is
linked to its opener, takes part in links, monitors, exit signals and
registered names like a process, and talks to its connected process with
messages. Step 57B provides identities, the port table, the port builtins
and output-only {fd, In, Out} ports.
Builtins
| Builtin | Behavior |
|---|---|
spawn(Fun) | badarg unless Fun is a fun; otherwise a new process calls Fun(), raising {badarity, {Fun, []}} in that process for another arity |
spawn(M, F, Args) | badarg unless M and F are atoms and Args a proper list; otherwise a new process calls M:F(Args...), raising undef in that process when no module exports it and no builtin has that name |
spawn_link(Fun), spawn_link(M, F, Args) | As spawn, and the new process is linked to the caller (links) |
is_process_alive(Pid) | badarg unless Pid is a pid; true while its process has not ended |
spawn_monitor(Fun), spawn_monitor(M, F, Args) | As spawn, returning {Pid, Ref} of a new monitor |
link(Pid), unlink(Pid) | badarg unless Pid is a pid (links) |
monitor(process, Item) | badarg for another type or item; a reference (monitors); Item is a pid or a registered name |
register(Name, Pid), unregister(Name), whereis(Name), registered() | See registered names; Name must be an atom |
demonitor(Ref), demonitor(Ref, Options) | badarg unless Ref is a reference and Options a proper list of flush and info |
exit(Dest, Reason), exit_signal(Dest, Reason) | badarg unless Dest is a pid or reference; true after the exit signal |
process_flag(trap_exit, Bool) | The previous setting; badarg for another flag or a non-boolean |
erlang:send(Dest, Msg), Dest ! Msg | Msg, after sending it (messages); send/2 is not auto-imported |
The new process is queued behind every runnable process; spawn returns its
pid at once.
Clause