Compare commits
1 commit
6897d71e4b
...
ab76d180da
| Author | SHA1 | Date | |
|---|---|---|---|
| ab76d180da |
5 changed files with 44 additions and 695 deletions
285
README.md
285
README.md
|
|
@ -1,268 +1,71 @@
|
||||||
# swactor
|
# swactor
|
||||||
|
(S)mall (W)ASM-compatible (actor) library
|
||||||
|
|
||||||
Small, WASM-compatible actor runtime for Rust, with Python and WebAssembly
|
## Useful
|
||||||
bindings.
|
|
||||||
|
|
||||||
## Quick Start (Rust)
|
View code dependency DAG
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick example
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use swactor::actor::{ActorAddress, ActorInterface};
|
use swactor::{
|
||||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
Ctx,
|
||||||
|
actor::{ActorAddress, ActorInterface},
|
||||||
|
runtime::{Runtime, RuntimeConfig},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Default)]
|
||||||
struct Greet { name: String, reply_to: ActorAddress }
|
struct Greeter { num_greeted: usize }
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
struct Greeting(String);
|
struct GreetMessage { who: String, return_addr: ActorAddress }
|
||||||
|
|
||||||
struct Greeter;
|
#[derive(Debug, Default, Clone)]
|
||||||
|
struct GreetResponse(String);
|
||||||
|
|
||||||
impl ActorInterface for Greeter {
|
impl ActorInterface for Greeter {
|
||||||
type Incoming = Greet;
|
type Incoming = GreetMessage;
|
||||||
type Response = Greeting;
|
type Response = GreetResponse;
|
||||||
|
|
||||||
fn handle(&mut self, ctx: &Ctx, msg: Greet) {
|
fn handle(&mut self, ctx: &Ctx, msg: GreetMessage) {
|
||||||
let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
|
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 main() {
|
fn main() {
|
||||||
let rt = Runtime::new(RuntimeConfig::default());
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
let addr = rt.spawn(Greeter).unwrap();
|
let addr = rt.spawn(Greeter::default()).expect("failed to spawn");
|
||||||
let inbox = rt.new_inbox::<Greeting>().unwrap();
|
|
||||||
|
|
||||||
rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
|
let inbox = rt.new_inbox::<GreetResponse>().unwrap();
|
||||||
rt.tick();
|
rt.send_to(addr, GreetMessage {
|
||||||
rt.tick();
|
who: "world".into(),
|
||||||
|
return_addr: *inbox.addr(),
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
|
for _ in 0..3 { rt.tick(); }
|
||||||
|
let resp = inbox.try_recv().expect("should have response");
|
||||||
|
println!("{}", resp.0); // "Hello, world!"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start (Python)
|
## Build & test
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
uv pip install . # builds the Rust extension automatically
|
cargo build
|
||||||
|
cargo test
|
||||||
|
cargo test --features stress # stress tests
|
||||||
|
cargo run --bin bench --release # benchmarks
|
||||||
|
cargo run --example hello
|
||||||
```
|
```
|
||||||
|
|
||||||
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 |
|
|
||||||
## Connectome analysis
|
## Connectome analysis
|
||||||
|
|
||||||
Spectral analysis of the internal dependency graph, producing a Connectome Complexity Index (CCI) and visual dashboards.
|
Spectral analysis of the internal dependency graph, producing a Connectome Complexity Index (CCI) and visual dashboards.
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
# 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
124
docs/channels.md
|
|
@ -1,124 +0,0 @@
|
||||||
# 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
185
docs/runtime.md
|
|
@ -1,185 +0,0 @@
|
||||||
# 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,10 +12,3 @@ dev = ["jupyter", "ipykernel"]
|
||||||
|
|
||||||
[tool.maturin]
|
[tool.maturin]
|
||||||
features = ["python"]
|
features = ["python"]
|
||||||
|
|
||||||
[tool.uv]
|
|
||||||
cache-keys = [
|
|
||||||
{ file = "pyproject.toml" },
|
|
||||||
{ file = "Cargo.toml" },
|
|
||||||
{ file = "src/**/*.rs" },
|
|
||||||
]
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue