RFD0004 - Actors Runtime Snapshot
- Feature Name:
actors_runtime_snapshot - Start Date:
2026-03-19 - Status:
implemented
Summary
Section titled “Summary”This RFD documents the current architecture of actors. It captures the single-core actor runtime as it exists today: process lifecycle, scheduler behavior, mailbox delivery, timers, cooperative effect handling, and the public runtime surface that std builds on.
Motivation
Section titled “Motivation”actors is one of the pillars of the Riot stack, but its current design lives mostly in code.
That makes a few important things harder than they should be:
- understanding where the public runtime ends and the scheduler internals begin
- understanding how
receive,yield, timers, and I/O polling interact - understanding what “single-core actor runtime” means concretely in this repository
- understanding what higher layers like
Std.Agent,Std.Supervisor, andStd.Telemetryare standing on top of
This RFD captures the runtime in present tense as a system snapshot.
It focuses on:
- the public
ActorsAPI - the scheduler loop
- process state transitions
- mailbox and selector-based message reception
- timer wheel behavior
- cooperative I/O suspension and wakeup
- reduction counting through compiler instrumentation hooks
Guide-level explanation
Section titled “Guide-level explanation”actors is a minimal single-core actor runtime.
The runtime model is:
- a single scheduler owns all processes for a runtime invocation
- each process has a PID, mailbox, continuation, and lifecycle state
- processes cooperate by yielding, receiving messages, or suspending on I/O
- timers and I/O readiness wake suspended processes back into the run queue
- the runtime exits when the main process exits or when no runnable, waiting-I/O, or timed work remains
The public surface exposed by Actors is intentionally compact:
runspawnandspawn_linkselfsendreceiveandreceive_anyyieldsyscallProcesslinking and monitoringTimer.send_after,Timer.send_interval, andTimer.cancel
Higher-level behavior lives above this layer. actors owns runtime mechanics, not application policy.
Runtime structure
Section titled “Runtime structure”flowchart TD A[Actors.run] --> B[Scheduler.create] B --> C[spawn main process] C --> D[run loop] D --> E[step runnable process] D --> F[process expired timers] D --> G[poll I/O] E --> H[update process state] F --> H G --> HProcess lifecycle
Section titled “Process lifecycle”Each process moves through a small explicit state machine:
UninitializedRunnableRunningWaiting_messageWaiting_ioExitedFinalized
The scheduler is the only component that steps processes and changes those states as part of execution.
stateDiagram-v2 [*] --> Uninitialized Uninitialized --> Runnable Runnable --> Running Running --> Runnable Running --> Waiting_message Running --> Waiting_io Running --> Exited Waiting_message --> Runnable Waiting_io --> Runnable Exited --> Finalized Finalized --> [*]Message receive model
Section titled “Message receive model”Processes do not block the whole runtime when they wait for messages.
receive works by:
- looking at the current process mailbox
- trying the selector against queued messages
- saving unmatched messages into the save queue
- suspending the process if no message matches
- optionally installing a timeout timer before suspension
flowchart TD A[receive selector] --> B{mailbox empty?} B -->|yes| C[install timeout if requested] C --> D[mark Waiting_message] D --> E[suspend process] B -->|no| F[read next envelope] F --> G{selector matches?} G -->|yes| H[continue with selected value] G -->|no| I[move envelope to save queue] I --> J{more mailbox fuel?} J -->|yes| F J -->|no| DI/O and timers
Section titled “I/O and timers”syscall is the bridge between actor scheduling and low-level readiness polling.
The current process:
- requests an interest and source
- gets registered with
Kernel.Async.Poll - moves to
Waiting_io - resumes when readiness is observed
Timers are managed through a hierarchical timing wheel and support both one-shot and interval behavior.
Reference-level explanation
Section titled “Reference-level explanation”1. Package boundary
Section titled “1. Package boundary”actors depends only on kernel.
That package boundary is deliberate:
kernelowns low-level async polling, file descriptors, time, and synchronization primitivesactorsturns those primitives into a runtime with processes, mailboxes, and scheduling semanticsstdthen turnsactorsinto a more ergonomic application-facing surface
2. Public module structure
Section titled “2. Public module structure”The main public entrypoint is packages/actors/src/actors.mli.
The exported modules and values currently group into these roles:
Config: runtime configurationRuntime: reduction counting hooksPid: process identifiersMessage: extensible message rootProcess: process state, links, monitors, flagsTimerandTimer_id: delayed and interval message delivery- top-level runtime functions like
run,spawn,send,receive,yield, andsyscall
The implementation in packages/actors/src/actors.ml is intentionally thin. It mostly forwards to Scheduler, Effects, and Process.
3. Scheduler state
Section titled “3. Scheduler state”The scheduler state in packages/actors/src/scheduler.ml currently contains:
run_queue: runnable processesprocesses: PID to process mapcurrent_process: the process currently being steppedio_poll: kernel async polling handletimer_wheel: hierarchical timer wheelconfig: runtime configuration- stop/status fields
This makes the scheduler the single owner of runtime-wide mutable state.
flowchart LR A[Scheduler.t] A --> B[run_queue] A --> C[process map] A --> D[current_process] A --> E[io_poll] A --> F[timer_wheel] A --> G[config] A --> H[stop/status]4. Run loop
Section titled “4. Run loop”Scheduler.run performs the following high-level sequence:
- assert the runtime has not already run in this OS process
- create scheduler state
- register the scheduler in a process-local cell
- spawn the main process
- iterate until stop conditions are met
Inside each loop iteration:
- consume runnable processes from the run queue
- step each process according to its current state
- tick timers if any exist
- poll I/O if the runtime still has live processes
- stop if nothing runnable, waiting on I/O, or timed remains
The current runtime is explicitly one-shot per host process. Scheduler.run rejects multiple invocations in the same executable.
5. Process representation
Section titled “5. Process representation”packages/actors/src/process.mli exposes the process lifecycle model. A process currently owns:
- a PID
- a continuation stored as
Proc_state.t - a mailbox
- a save queue for unmatched receive messages
- flags such as
TrapExit - link and monitor relationships
- receive timeout and syscall timeout timer IDs
- ready I/O tokens
Processes are stepped by the scheduler through step_process, not by direct user control.
6. Effects and continuation stepping
Section titled “6. Effects and continuation stepping”actors uses effect-driven cooperative control flow.
The main effect shapes are:
ReceiveYieldSyscall
The scheduler installs a perform handler for the current process. That handler interprets process effects in runtime terms:
Receivebecomes mailbox scan plus optional timeout logicYieldbecomes a scheduler yield pointSyscallbecomes async-poll registration plus optional timeout logic
The continuation is stepped through Proc_state.run ~reductions:100.
7. Reduction counting
Section titled “7. Reduction counting”packages/actors/src/runtime.ml exposes:
reset_reductionsincrement_reduction_count
The current model is simple:
- each process slice starts with a reduction budget
- compiler-instrumented code decrements that budget
- when the budget reaches zero,
Effects.yield ()is performed
This gives Riot a lightweight preemption boundary without a preemptive multicore scheduler.
flowchart TD A[process slice starts] --> B[reset reductions] B --> C[compiler-instrumented code runs] C --> D[increment_reduction_count] D --> E{budget exhausted?} E -->|no| C E -->|yes| F[perform yield] F --> G[scheduler requeues process]8. Mailboxes and selective receive
Section titled “8. Mailboxes and selective receive”Mailbox behavior is defined by Mailbox plus process-level save queue handling.
The mailbox itself is intentionally simple:
- queue envelope
- return next envelope
- report size/emptiness
Selective receive is layered above the raw mailbox in scheduler logic. Unmatched messages are temporarily moved aside, then restored through Process.read_save_queue.
This keeps mailbox storage simple while still supporting Erlang-style selector-based receive behavior.
9. Timers
Section titled “9. Timers”Timers are represented by Timer.send_after, Timer.send_interval, and Timer.cancel, backed by Timer_wheel.
Each timer has:
- an ID
- a mode: one-shot or interval
- an action: wake a process or send a message
When Scheduler.process_timers sees expired timers:
- wake-process timers mark the process runnable
- send-message timers route a message to the target PID
- interval timers are reinserted
flowchart TD A[timer added] --> B[timer wheel] B --> C[tick] C --> D{expired?} D -->|wake process| E[mark runnable] D -->|send message| F[send target message] E --> G{interval?} F --> G G -->|yes| H[reinsert timer] G -->|no| I[done]10. I/O polling
Section titled “10. I/O polling”The runtime uses Kernel.Async.Poll as its readiness backend.
When a process performs syscall:
- the scheduler builds an async token from the process
- the runtime registers interest and source with the poller
- the process moves to
Waiting_io - the poller returns events
- the process stores ready tokens and becomes runnable again
This allows I/O wait to suspend only the current actor instead of blocking the runtime.
11. Links, monitors, and exit handling
Section titled “11. Links, monitors, and exit handling”Exit handling is implemented in Scheduler.handle_exit_proc.
The current behavior is:
- send
DOWNto monitors - send
EXITto linked processes - if a linked process has
trap_exit = true, convert exit into a message - if
trap_exit = falseand the exit was abnormal, propagate failure by marking the linked process exited - remove and finalize the exiting process
The main process is special. When it exits, the scheduler sets the runtime exit status and begins shutdown.
Drawbacks
Section titled “Drawbacks”- the runtime is intentionally single-core today
runis one-shot per host process, which complicates some test and embedding patterns- tracing hooks exist as stubs rather than as a complete runtime observability story
- the reduction-count model depends on compiler instrumentation rather than on a standalone runtime mechanism
Prior art
Section titled “Prior art”The most obvious prior art is the Erlang/BEAM process model:
- per-process mailboxes
- links and monitors
- selective receive
- actor-oriented timers
actors differs in a few important ways:
- it is currently single-core
- it is built around OCaml effects and continuations
- it uses
kernelasync polling primitives directly - it uses compiler-inserted reduction counting hooks
There is also clear influence from event-loop runtimes that combine:
- a run queue
- a timer facility
- a readiness poller
The Riot combination is actor-oriented rather than callback-oriented.
Unresolved questions
Section titled “Unresolved questions”- how should runtime tracing and debug instrumentation grow beyond the current stub hooks?
- should
runremain single-use per OS process indefinitely? - what exact multicore shape should replace or extend the current single-scheduler design?
- how much of the reduction-budget mechanism should remain compiler-driven?
Future possibilities
Section titled “Future possibilities”- multicore schedulers with explicit cross-scheduler message routing
- richer tracing and runtime telemetry
- stronger supervision primitives in
stdbuilt on the same runtime core - better test harness support for repeated runtime invocations
- pluggable polling backends while preserving the current actor semantics