Skip to content
Riot Docs

Search is only available in production builds. Try building and previewing the site to test it out locally.

Install Riot GitHub

RFD0004 - Actors Runtime Snapshot

  • Feature Name: actors_runtime_snapshot
  • Start Date: 2026-03-19
  • Status: implemented

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.

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, and Std.Telemetry are standing on top of

This RFD captures the runtime in present tense as a system snapshot.

It focuses on:

  • the public Actors API
  • 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

actors is a minimal single-core actor runtime.

The runtime model is:

  1. a single scheduler owns all processes for a runtime invocation
  2. each process has a PID, mailbox, continuation, and lifecycle state
  3. processes cooperate by yielding, receiving messages, or suspending on I/O
  4. timers and I/O readiness wake suspended processes back into the run queue
  5. 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:

  • run
  • spawn and spawn_link
  • self
  • send
  • receive and receive_any
  • yield
  • syscall
  • Process linking and monitoring
  • Timer.send_after, Timer.send_interval, and Timer.cancel

Higher-level behavior lives above this layer. actors owns runtime mechanics, not application policy.

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 --> H

Each process moves through a small explicit state machine:

  • Uninitialized
  • Runnable
  • Running
  • Waiting_message
  • Waiting_io
  • Exited
  • Finalized

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 --> [*]

Processes do not block the whole runtime when they wait for messages.

receive works by:

  1. looking at the current process mailbox
  2. trying the selector against queued messages
  3. saving unmatched messages into the save queue
  4. suspending the process if no message matches
  5. 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| D

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.

actors depends only on kernel.

That package boundary is deliberate:

  • kernel owns low-level async polling, file descriptors, time, and synchronization primitives
  • actors turns those primitives into a runtime with processes, mailboxes, and scheduling semantics
  • std then turns actors into a more ergonomic application-facing surface

The main public entrypoint is packages/actors/src/actors.mli.

The exported modules and values currently group into these roles:

  • Config: runtime configuration
  • Runtime: reduction counting hooks
  • Pid: process identifiers
  • Message: extensible message root
  • Process: process state, links, monitors, flags
  • Timer and Timer_id: delayed and interval message delivery
  • top-level runtime functions like run, spawn, send, receive, yield, and syscall

The implementation in packages/actors/src/actors.ml is intentionally thin. It mostly forwards to Scheduler, Effects, and Process.

The scheduler state in packages/actors/src/scheduler.ml currently contains:

  • run_queue: runnable processes
  • processes: PID to process map
  • current_process: the process currently being stepped
  • io_poll: kernel async polling handle
  • timer_wheel: hierarchical timer wheel
  • config: 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]

Scheduler.run performs the following high-level sequence:

  1. assert the runtime has not already run in this OS process
  2. create scheduler state
  3. register the scheduler in a process-local cell
  4. spawn the main process
  5. iterate until stop conditions are met

Inside each loop iteration:

  1. consume runnable processes from the run queue
  2. step each process according to its current state
  3. tick timers if any exist
  4. poll I/O if the runtime still has live processes
  5. 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.

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.

actors uses effect-driven cooperative control flow.

The main effect shapes are:

  • Receive
  • Yield
  • Syscall

The scheduler installs a perform handler for the current process. That handler interprets process effects in runtime terms:

  • Receive becomes mailbox scan plus optional timeout logic
  • Yield becomes a scheduler yield point
  • Syscall becomes async-poll registration plus optional timeout logic

The continuation is stepped through Proc_state.run ~reductions:100.

packages/actors/src/runtime.ml exposes:

  • reset_reductions
  • increment_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]

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.

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]

The runtime uses Kernel.Async.Poll as its readiness backend.

When a process performs syscall:

  1. the scheduler builds an async token from the process
  2. the runtime registers interest and source with the poller
  3. the process moves to Waiting_io
  4. the poller returns events
  5. 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.

Exit handling is implemented in Scheduler.handle_exit_proc.

The current behavior is:

  1. send DOWN to monitors
  2. send EXIT to linked processes
  3. if a linked process has trap_exit = true, convert exit into a message
  4. if trap_exit = false and the exit was abnormal, propagate failure by marking the linked process exited
  5. remove and finalize the exiting process

The main process is special. When it exits, the scheduler sets the runtime exit status and begins shutdown.

  • the runtime is intentionally single-core today
  • run is 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

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 kernel async 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.

  • how should runtime tracing and debug instrumentation grow beyond the current stub hooks?
  • should run remain 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?
  • multicore schedulers with explicit cross-scheduler message routing
  • richer tracing and runtime telemetry
  • stronger supervision primitives in std built on the same runtime core
  • better test harness support for repeated runtime invocations
  • pluggable polling backends while preserving the current actor semantics