feat: docs and README #13
5 changed files with 696 additions and 44 deletions
286
README.md
286
README.md
|
|
@ -1,67 +1,265 @@
|
|||
# swactor
|
||||
(S)mall (W)ASM-compatible (actor) library
|
||||
|
||||
## Useful
|
||||
Small, WASM-compatible actor runtime for Rust, with Python and WebAssembly
|
||||
bindings.
|
||||
|
||||
View code dependency DAG
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
|
||||
```
|
||||
|
||||
## Quick example
|
||||
## Quick Start (Rust)
|
||||
|
||||
```rust
|
||||
use swactor::{
|
||||
Ctx,
|
||||
actor::{ActorAddress, ActorInterface},
|
||||
runtime::{Runtime, RuntimeConfig},
|
||||
};
|
||||
use swactor::actor::{ActorAddress, ActorInterface};
|
||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Greeter { num_greeted: usize }
|
||||
#[derive(Clone)]
|
||||
struct Greet { name: String, reply_to: ActorAddress }
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct GreetMessage { who: String, return_addr: ActorAddress }
|
||||
#[derive(Clone)]
|
||||
struct Greeting(String);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct GreetResponse(String);
|
||||
struct Greeter;
|
||||
|
||||
impl ActorInterface for Greeter {
|
||||
type Incoming = GreetMessage;
|
||||
type Response = GreetResponse;
|
||||
type Incoming = Greet;
|
||||
type Response = Greeting;
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: GreetMessage) {
|
||||
let res = GreetResponse(format!("Hello, {}!", msg.who));
|
||||
self.num_greeted += 1;
|
||||
if let Err(_) = ctx.send(msg.return_addr, res) {
|
||||
self.num_greeted -= 1;
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Greet) {
|
||||
let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let rt = Runtime::new(RuntimeConfig::default());
|
||||
let addr = rt.spawn(Greeter::default()).expect("failed to spawn");
|
||||
let addr = rt.spawn(Greeter).unwrap();
|
||||
let inbox = rt.new_inbox::<Greeting>().unwrap();
|
||||
|
||||
let inbox = rt.new_inbox::<GreetResponse>().unwrap();
|
||||
rt.send_to(addr, GreetMessage {
|
||||
who: "world".into(),
|
||||
return_addr: *inbox.addr(),
|
||||
}).unwrap();
|
||||
rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
let resp = inbox.try_recv().expect("should have response");
|
||||
println!("{}", resp.0); // "Hello, world!"
|
||||
println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
|
||||
}
|
||||
```
|
||||
|
||||
## Build & test
|
||||
## Quick Start (Python)
|
||||
|
||||
```sh
|
||||
cargo build
|
||||
cargo test
|
||||
cargo test --features stress # stress tests
|
||||
cargo run --bin bench --release # benchmarks
|
||||
cargo run --example hello
|
||||
```bash
|
||||
uv pip install . # builds the Rust extension automatically
|
||||
```
|
||||
|
||||
Single-threaded — caller drives each tick:
|
||||
|
||||
```python
|
||||
from swactor import Runtime
|
||||
|
||||
def echo(ctx, msg):
|
||||
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
|
||||
|
||||
rt = Runtime()
|
||||
addr = rt.spawn(echo)
|
||||
inbox = rt.inbox()
|
||||
rt.send(addr, {"name": "world", "reply_to": inbox.addr})
|
||||
rt.tick()
|
||||
print(inbox.try_recv()) # "hello, world!"
|
||||
```
|
||||
|
||||
Multi-threaded — workers run on background threads:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from swactor import Runtime, RuntimeConfig
|
||||
|
||||
async def main():
|
||||
rt = Runtime(RuntimeConfig(num_threads=2))
|
||||
|
||||
def echo(ctx, msg):
|
||||
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
|
||||
|
||||
addr = rt.spawn(echo)
|
||||
inbox = rt.inbox()
|
||||
handle = rt.run() # spawns worker threads, consumes rt
|
||||
|
||||
for name in ["alice", "bob", "charlie"]:
|
||||
handle.send(addr, {"name": name, "reply_to": inbox.addr})
|
||||
while (reply := inbox.try_recv()) is None:
|
||||
await asyncio.sleep(0.01)
|
||||
print(reply)
|
||||
|
||||
handle.shutdown()
|
||||
handle.join()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Quick Start (WASM)
|
||||
|
||||
The `wasm/` crate wraps swactor for use from JavaScript via `wasm-bindgen`.
|
||||
It runs single-threaded with the caller driving `tick()` — a natural fit
|
||||
for game loops, simulations, or any frame-based update cycle.
|
||||
|
||||
```bash
|
||||
cd wasm && wasm-pack build --target nodejs # or --target web
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { SwactorRuntime } from "./wasm/pkg/swactor_wasm.js";
|
||||
|
||||
const rt = new SwactorRuntime();
|
||||
|
||||
// spawn a counter actor — accumulates values sent to it
|
||||
const counter = rt.spawn_counter();
|
||||
|
||||
// spawn a relay that forwards messages to the counter
|
||||
const relay = rt.spawn_relay(counter);
|
||||
|
||||
// send through the relay
|
||||
rt.send(relay, 5);
|
||||
rt.send(relay, 7);
|
||||
|
||||
rt.tick(); // relay receives and forwards
|
||||
rt.tick(); // counter receives forwarded messages
|
||||
|
||||
// drain results from the inbox
|
||||
let v;
|
||||
while ((v = rt.try_recv()) !== undefined) {
|
||||
console.log(v); // 5, then 12
|
||||
}
|
||||
|
||||
rt.free();
|
||||
```
|
||||
|
||||
The WASM crate uses the `no_random` feature (deterministic address
|
||||
generation) so there's no dependency on system RNG.
|
||||
|
||||
## Running the Examples
|
||||
|
||||
```bash
|
||||
cargo run --example hello # single actor, request/response
|
||||
cargo run --example ring # 500 actors in a ring topology
|
||||
```
|
||||
|
||||
## Multi-threaded Mode
|
||||
|
||||
Pass `num_threads` in the config. The runtime spawns OS threads and runs
|
||||
workers autonomously — no `tick()` calls needed.
|
||||
|
||||
```rust
|
||||
let mut config = RuntimeConfig::default();
|
||||
config.num_threads = 4;
|
||||
let rt = Runtime::new(config);
|
||||
|
||||
let addr = rt.spawn(MyActor::default()).unwrap();
|
||||
let handle = rt.run().unwrap(); // consumes rt, spawns 4 threads
|
||||
|
||||
// use handle.runtime to spawn/send while workers run
|
||||
handle.runtime.send_to(addr, MyMsg).unwrap();
|
||||
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The runtime is layered: **Runtime** → **Workers** → **ActorPool** → **Actors**.
|
||||
|
||||
```
|
||||
┌─ Runtime (Arc, shared) ──────────────────────────────────────┐
|
||||
│ │
|
||||
│ AddressMap Placement InboxRegistry is_running │
|
||||
│ (addr→worker) (round-robin) (external inboxes) (AtomicBool) │
|
||||
│ │
|
||||
│ transfer_txs[] spawn_txs[] │
|
||||
│ (one Sender per worker) (one Sender per worker) │
|
||||
│ │
|
||||
└───────┬───────────────┬───────────────┬───────────────────────┘
|
||||
│ │ │
|
||||
v v v
|
||||
┌─ Worker 0 ──┐ ┌─ Worker 1 ──┐ ┌─ Worker 2 ──┐
|
||||
│ ActorPool │ │ ActorPool │ │ ActorPool │
|
||||
│ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │
|
||||
│ │mailbox │ │ │ │mailbox │ │ │ │mailbox │ │
|
||||
│ │ actor │ │ │ │ actor │ │ │ │ actor │ │
|
||||
│ └────────┘ │ │ └────────┘ │ │ └────────┘ │
|
||||
│ ┌────────┐ │ │ ┌────────┐ │ │ │
|
||||
│ │mailbox │ │ │ │mailbox │ │ └──────────────┘
|
||||
│ │ actor │ │ │ │ actor │ │
|
||||
│ └────────┘ │ │ └────────┘ │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
Each worker runs a **four-phase tick loop**:
|
||||
|
||||
1. **Drain spawn queue** — add newly spawned actors to the pool
|
||||
2. **Drain transfer queue** — deliver cross-worker messages to mailboxes
|
||||
3. **Tick all actors** — pop messages, call handlers, buffer outgoing sends
|
||||
4. **Drain pending local** — deliver same-worker messages for the next tick
|
||||
|
||||
Messages are type-erased (`Box<dyn Any + Send>`) in transit and downcast
|
||||
back to the concrete type at delivery. Mismatched types are silently dropped.
|
||||
|
||||
Detailed architecture docs live in `docs/`:
|
||||
|
||||
| Document | Covers |
|
||||
|----------|--------|
|
||||
| [Worker Thread](docs/worker-thread.md) | Tick phases, backoff, message routing, full system topology |
|
||||
| [Runtime](docs/runtime.md) | Runtime, Ctx, Inbox, RuntimeHandle, stats |
|
||||
| [Actor Model](docs/actor-model.md) | Traits, type erasure, addresses |
|
||||
| [Channels & Shared State](docs/channels.md) | HybridChannel, AddressMap, Placement |
|
||||
|
||||
## Source Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib.rs module root, feature gates, get_random()
|
||||
├── actor.rs Message, ActorInterface, ActorAddress, type erasure
|
||||
├── runtime.rs Runtime, Ctx, Inbox, RuntimeHandle, InboxRegistry
|
||||
├── worker/
|
||||
│ ├── mod.rs Worker, WorkerContext, ActorPool, tick loop
|
||||
│ └── tests.rs worker unit tests with step-based DSL
|
||||
├── channel.rs HybridChannel (ArrayQueue + SegQueue), Sender/Receiver
|
||||
├── config.rs RuntimeConfig, BackoffPolicy
|
||||
├── address_map.rs AddressMap (RwLock<HashMap>), Placement (round-robin)
|
||||
├── error.rs Error type
|
||||
└── python.rs PyO3 bindings (feature = "python")
|
||||
|
||||
wasm/
|
||||
├── Cargo.toml separate crate, depends on swactor with no_random
|
||||
├── src/lib.rs wasm-bindgen wrapper (SwactorRuntime)
|
||||
└── test.mjs Node.js test suite
|
||||
|
||||
examples/
|
||||
├── hello.rs echo actor
|
||||
├── ring.rs ring topology
|
||||
└── python/
|
||||
├── hello_single_thread.py minimal Python example
|
||||
├── hello_async.py multi-threaded + asyncio
|
||||
└── getting_started.ipynb Jupyter notebook
|
||||
|
||||
tests/
|
||||
├── runtime_api.rs single + multi-thread integration tests
|
||||
├── stats_demo.rs stats snapshot tests
|
||||
└── test_python.py Python binding tests
|
||||
```
|
||||
|
||||
## Building & Testing
|
||||
|
||||
```bash
|
||||
# Rust
|
||||
cargo test # run all tests
|
||||
cargo run --example hello # run an example
|
||||
cargo bench # benchmarks (criterion)
|
||||
|
||||
# Python bindings (requires Rust toolchain on PATH)
|
||||
uv pip install . # build + install
|
||||
uv run python3 tests/test_python.py # run Python tests
|
||||
|
||||
# WASM bindings
|
||||
cd wasm && wasm-pack build --target nodejs
|
||||
node test.mjs # run WASM tests
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Flag | Default | What it does |
|
||||
|------|---------|--------------|
|
||||
| `getrandom` | yes | System RNG for actor addresses |
|
||||
| `no_random` | no | Deterministic counter (for WASM / reproducible tests) |
|
||||
| `python` | no | PyO3 bindings, builds cdylib wheel |
|
||||
|
|
|
|||
138
docs/actor-model.md
Normal file
138
docs/actor-model.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Actor Model
|
||||
|
||||
Swactor's actor model is intentionally minimal. An actor is a struct that
|
||||
implements one trait, receives one message type, and communicates only
|
||||
through `Ctx`.
|
||||
|
||||
## Defining an Actor
|
||||
|
||||
```rust
|
||||
use swactor::actor::ActorInterface;
|
||||
use swactor::runtime::Ctx;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct Ping { return_addr: ActorAddress }
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct Pong;
|
||||
|
||||
struct MyActor {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl ActorInterface for MyActor {
|
||||
type Incoming = Ping;
|
||||
type Response = Pong; // not enforced at runtime — a documentation hint
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
|
||||
self.count += 1;
|
||||
let _ = ctx.send(msg.return_addr, Pong);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it. No lifecycle hooks, no supervision trees, no async. Just a
|
||||
`handle` method.
|
||||
|
||||
## The Traits
|
||||
|
||||
```
|
||||
┌─ Message ─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ trait Message: 'static + Sized + Clone + Send + Sync {} │
|
||||
│ │
|
||||
│ Blanket-implemented for any type that meets the bounds. │
|
||||
│ You never implement this manually. │
|
||||
│ │
|
||||
│ Why Clone + Send + Sync? │
|
||||
│ Clone — messages may be duplicated (Python bindings, stats, etc.) │
|
||||
│ Send — messages cross thread boundaries │
|
||||
│ Sync — required by the type-erased Any + Send path │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ ActorInterface ──────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ trait ActorInterface: 'static + Send { │
|
||||
│ type Incoming: Message; │
|
||||
│ type Response: Message; │
|
||||
│ fn handle(&mut self, ctx: &Ctx, msg: Self::Incoming); │
|
||||
│ } │
|
||||
│ │
|
||||
│ This is what you implement. The actor owns mutable state (&mut self) │
|
||||
│ and receives typed messages. │
|
||||
│ │
|
||||
│ Actors are Send but NOT Sync — only one worker thread ever touches │
|
||||
│ a given actor. │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Type Erasure
|
||||
|
||||
Actors in the runtime are stored as `Box<dyn AnyActor>`, which erases the
|
||||
concrete type. Messages are stored as `Box<dyn Any + Send>`. Type checking
|
||||
happens at delivery time via `downcast`:
|
||||
|
||||
```
|
||||
compile time runtime
|
||||
─────────── ───────
|
||||
ctx.send(addr, msg)
|
||||
│
|
||||
v
|
||||
Box::new(msg) as Box<dyn Any + Send> -- type erased here
|
||||
│
|
||||
v
|
||||
enqueued in mailbox (VecDeque<Box<dyn Any + Send>>)
|
||||
│
|
||||
v
|
||||
actor.handle_any(ctx, msg)
|
||||
│
|
||||
v
|
||||
msg.downcast::<A::Incoming>() -- type recovered here
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
ok err
|
||||
│ │
|
||||
v v
|
||||
A.handle silently dropped
|
||||
(ctx,msg)
|
||||
```
|
||||
|
||||
Why silent drop? In a dynamic system (especially with Python bindings),
|
||||
type mismatches aren't crashes — they're routing errors. The actor simply
|
||||
ignores messages it doesn't understand.
|
||||
|
||||
## ActorAddress
|
||||
|
||||
```
|
||||
┌─ ActorAddress ────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ pub struct ActorAddress(pub [u8; 32]); │
|
||||
│ │
|
||||
│ 32 random bytes — globally unique, no coordination needed. │
|
||||
│ Generated via get_random() (system RNG or deterministic counter │
|
||||
│ for WASM builds). │
|
||||
│ │
|
||||
│ Derives: Debug, Default, Clone, Copy, PartialEq, Eq, Hash │
|
||||
│ │
|
||||
│ Used as keys in: │
|
||||
│ AddressMap (actor → worker lookup) │
|
||||
│ ActorPool (actor → mailbox + state) │
|
||||
│ InboxRegistry (external inbox lookup) │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Where Things Live in the Code
|
||||
|
||||
| Concept | File | Key lines |
|
||||
|---------|------|-----------|
|
||||
| `Message` trait | `src/actor.rs` | blanket impl |
|
||||
| `ActorInterface` trait | `src/actor.rs` | user-facing trait |
|
||||
| `ActorAddress` | `src/actor.rs` | 32-byte random ID |
|
||||
| `Actor<A>` wrapper | `src/actor.rs` | wraps user state |
|
||||
| `AnyActor` trait | `src/actor.rs` | type-erased handler |
|
||||
| `ActorPool` | `src/worker/mod.rs` | per-worker storage |
|
||||
| `ActorSlot` | `src/worker/mod.rs` | mailbox + actor pair |
|
||||
124
docs/channels.md
Normal file
124
docs/channels.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Channels & Shared State
|
||||
|
||||
All communication between workers (and between the `Runtime` and workers)
|
||||
goes through lock-free channels. There are no mutexes in the hot path.
|
||||
|
||||
## HybridChannel
|
||||
|
||||
The core primitive. A lock-free MPSC queue with bounded fast path and
|
||||
unbounded overflow.
|
||||
|
||||
```
|
||||
┌─ HybridChannel<T> ───────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─ ring: ArrayQueue<T> (crossbeam) ─────────────────────────────────┐ │
|
||||
│ │ Pre-allocated, fixed capacity, lock-free CAS │ │
|
||||
│ │ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │
|
||||
│ │ └───┴───┴───┴───┴───┴───┴───┴───┘ │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ overflow: SegQueue<T> (crossbeam) ───────────────────────────────┐ │
|
||||
│ │ Unbounded linked-list queue, lock-free │ │
|
||||
│ │ Only used when ring is full │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ push(v): │
|
||||
│ ring.push(v) → Ok: done │
|
||||
│ ring.push(v) → Err: overflow.push(v) │
|
||||
│ │
|
||||
│ pop(): │
|
||||
│ ring.pop() → Some: return it │
|
||||
│ overflow.pop() → Some: return it │
|
||||
│ otherwise → None │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The fast path (ring) avoids allocation. The overflow (SegQueue) acts as a
|
||||
safety net — the system never drops messages due to capacity, but
|
||||
performance degrades under sustained overflow.
|
||||
|
||||
## Sender and Receiver
|
||||
|
||||
```
|
||||
┌─ Sender<T> ──────────┐ ┌─ Receiver<T> ────────────┐
|
||||
│ │ │ │
|
||||
│ queue: Arc<Hybrid> │──same──▶│ queue: Arc<Hybrid> │
|
||||
│ │ Arc │ │
|
||||
│ try_send(v) → push │ │ try_recv() → pop │
|
||||
│ │ │ │
|
||||
│ Clone: new_sender() │ │ Single consumer │
|
||||
│ (clones the Arc) │ │ (not Clone) │
|
||||
│ │ │ │
|
||||
└───────────────────────┘ └───────────────────────────┘
|
||||
```
|
||||
|
||||
`Sender` is `Clone` — multiple producers can send into the same channel.
|
||||
`Receiver` is not `Clone` — exactly one consumer drains it.
|
||||
|
||||
## What Channels Exist
|
||||
|
||||
Each worker gets two inbound channels, created at `Runtime::new()` time:
|
||||
|
||||
```
|
||||
Per Worker:
|
||||
|
||||
transfer channel: carries Envelope (messages to actors)
|
||||
Senders: Runtime, other workers (via TickContext)
|
||||
Receiver: this Worker
|
||||
|
||||
spawn channel: carries (ActorAddress, Box<dyn AnyActor>)
|
||||
Senders: Runtime, other workers (via TickContext)
|
||||
Receiver: this Worker
|
||||
```
|
||||
|
||||
For N workers, the runtime holds N transfer senders and N spawn senders.
|
||||
Every worker can reach every other worker's queues through `TickContext`.
|
||||
|
||||
## AddressMap
|
||||
|
||||
Global directory mapping actor addresses to the worker that owns them.
|
||||
|
||||
```
|
||||
┌─ AddressMap ──────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ RwLock< HashMap<ActorAddress, WorkerId> > │
|
||||
│ │
|
||||
│ Read path (very frequent): │
|
||||
│ Every ctx.send() and Runtime.send_to() does a lookup. │
|
||||
│ RwLock allows concurrent readers — no contention. │
|
||||
│ │
|
||||
│ Write path (rare): │
|
||||
│ Only on spawn. Takes exclusive lock briefly. │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Placement
|
||||
|
||||
Decides which worker gets a newly spawned actor.
|
||||
|
||||
```
|
||||
┌─ Placement ───────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ next: AtomicUsize │
|
||||
│ num_workers: usize │
|
||||
│ │
|
||||
│ next_worker() → WorkerId( next.fetch_add(1) % num_workers ) │
|
||||
│ │
|
||||
│ Simple round-robin. No load balancing, no affinity. │
|
||||
│ Actors stay on their assigned worker for life. │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Where Things Live in the Code
|
||||
|
||||
| Concept | File |
|
||||
|---------|------|
|
||||
| `HybridChannel`, `Sender`, `Receiver` | `src/channel.rs` |
|
||||
| `AddressMap`, `WorkerId`, `Placement` | `src/address_map.rs` |
|
||||
| `Envelope` | `src/runtime.rs` |
|
||||
| `InboxRegistry` | `src/runtime.rs` |
|
||||
| `RuntimeConfig`, `BackoffPolicy` | `src/config.rs` |
|
||||
185
docs/runtime.md
Normal file
185
docs/runtime.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# Runtime Architecture
|
||||
|
||||
The `Runtime` is the main entry point. It creates workers, owns the shared
|
||||
infrastructure, and provides the public API for spawning actors and sending
|
||||
messages.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
┌─ Runtime ─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ config: RuntimeConfig -- tunable knobs (see config.rs) │
|
||||
│ is_running: AtomicBool -- shutdown flag, read by all workers │
|
||||
│ │
|
||||
│ ┌─ Shared State (lives on Arc<Runtime>) ──────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
|
||||
│ │ inbox_registry: Arc<InboxRegistry> -- external inbox delivery │ │
|
||||
│ │ placement: Placement -- round-robin worker picker │ │
|
||||
│ │ worker_stats: Vec<Arc<WorkerStats>> -- atomic stat counters │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Channel Endpoints ─────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ transfer_txs: Vec<Sender<Envelope>> -- one per worker (messages) │ │
|
||||
│ │ spawn_txs: Vec<Sender<(Addr,Box)>> -- one per worker (spawns) │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Mode ──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ SINGLE-THREADED: single_worker: Some(RefCell<Worker>) │ │
|
||||
│ │ MULTI-THREADED: pending_workers: Some(Vec<Worker>) │ │
|
||||
│ │ │ │
|
||||
│ │ After run() is called, both are None — workers move to threads. │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Two Modes of Operation
|
||||
|
||||
```
|
||||
SINGLE-THREADED MULTI-THREADED
|
||||
────────────── ──────────────
|
||||
|
||||
let rt = Runtime::new(config); let mut config = RuntimeConfig::default();
|
||||
config.num_threads = 4;
|
||||
let rt = Runtime::new(config);
|
||||
|
||||
rt.spawn(my_actor)?; rt.spawn(my_actor)?;
|
||||
rt.send_to(addr, msg)?; rt.send_to(addr, msg)?;
|
||||
|
||||
loop { rt.tick(); } let handle = rt.run()?;
|
||||
^ ^
|
||||
| |
|
||||
caller drives each tick workers run on their own threads
|
||||
handle.join() blocks until shutdown
|
||||
```
|
||||
|
||||
Single-threaded mode keeps the `Worker` inline and requires the caller to
|
||||
call `rt.tick()` to advance the simulation. This is useful for deterministic
|
||||
testing, WASM, or game loops where you want frame-level control.
|
||||
|
||||
Multi-threaded mode consumes the `Runtime` via `run()`, wraps it in an
|
||||
`Arc`, and spawns one OS thread per worker. Returns a `RuntimeHandle`.
|
||||
|
||||
## Ctx — the Actor Syscall Interface
|
||||
|
||||
When an actor's `handle()` method runs, it receives a `&Ctx`. This is the
|
||||
only way for actors to interact with the outside world.
|
||||
|
||||
```
|
||||
┌─ Ctx<'a> ─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ inner: &dyn ContextInner -- polymorphic dispatch │
|
||||
│ self_addr: ActorAddress -- address of the current actor │
|
||||
│ │
|
||||
│ ┌─ Public API ────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ctx.self_addr() -> ActorAddress │ │
|
||||
│ │ ctx.send(addr, msg) -> Result<(), Error> │ │
|
||||
│ │ ctx.spawn(actor) -> Result<ActorAddress, Error> │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ ContextInner dispatch ─────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ In single-threaded mode: inner = &Runtime │ │
|
||||
│ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │
|
||||
│ │ │ │
|
||||
│ │ In multi-threaded mode: inner = &WorkerContext │ │
|
||||
│ │ send → pending_local (same worker) or transfer_txs (cross) │ │
|
||||
│ │ spawn → spawn_txs[target_wid] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The `ContextInner` trait is the object-safe bridge. It's not public — actors
|
||||
interact only through the typed `Ctx` wrapper.
|
||||
|
||||
## Inbox — Receiving Messages Outside the Runtime
|
||||
|
||||
`Inbox<M>` lets external code (the "main" thread, a game loop, an HTTP
|
||||
handler, etc.) receive typed messages from actors.
|
||||
|
||||
```
|
||||
┌─ Creation ─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ let inbox = rt.new_inbox::<MyResponse>()?; │
|
||||
│ │
|
||||
│ Under the hood: │
|
||||
│ addr = ActorAddress::new_random() │
|
||||
│ receiver = Receiver::<M>::new(capacity) │
|
||||
│ sender = receiver.new_sender() │
|
||||
│ inbox_registry.register(addr, Arc::new(sender)) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Usage ────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ // give inbox.addr() to actors so they know where to reply │
|
||||
│ rt.send_to(greeter, GreetMsg { return_addr: *inbox.addr() })?; │
|
||||
│ │
|
||||
│ // poll for responses │
|
||||
│ if let Some(msg) = inbox.try_recv() { ... } │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Delivery Path ────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ actor calls ctx.send(inbox_addr, response) │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ address_map.lookup(inbox_addr) → None (inboxes aren't actors) │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ inbox_registry.try_deliver(addr, msg) │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ downcast Box<Any> → M, push into Receiver<M> │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## RuntimeHandle
|
||||
|
||||
Returned by `run()`. Holds `Arc<Runtime>` and the thread `JoinHandle`s.
|
||||
|
||||
```
|
||||
┌─ RuntimeHandle ───────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ runtime: Arc<Runtime> -- still usable for spawn/send/stats │
|
||||
│ threads: Vec<JoinHandle<()>> -- one per worker │
|
||||
│ │
|
||||
│ handle.shutdown() → runtime.is_running.store(false) │
|
||||
│ handle.join() → waits for all worker threads to exit │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## RuntimeStats
|
||||
|
||||
`rt.stats()` (or `handle.runtime.stats()`) returns a snapshot:
|
||||
|
||||
```
|
||||
┌─ RuntimeStats ────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ num_workers: usize │
|
||||
│ actors: Vec<(ActorAddress, worker_id)> -- from AddressMap snapshot │
|
||||
│ workers: Vec<WorkerInfo> │
|
||||
│ ├─ id: usize │
|
||||
│ ├─ num_actors: usize -- from atomic counter │
|
||||
│ ├─ mailbox_depth: usize -- total queued messages │
|
||||
│ └─ messages_processed: u64 -- cumulative count │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Stats are published by workers via atomic stores at the end of each tick,
|
||||
so they're always slightly stale but never block.
|
||||
|
|
@ -12,3 +12,10 @@ dev = ["jupyter", "ipykernel"]
|
|||
|
||||
[tool.maturin]
|
||||
features = ["python"]
|
||||
|
||||
[tool.uv]
|
||||
cache-keys = [
|
||||
{ file = "pyproject.toml" },
|
||||
{ file = "Cargo.toml" },
|
||||
{ file = "src/**/*.rs" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue