🌐 English | 日本語
You write plain synchronous Rust. An actor is "one message type + one handler". The guarantees (data-race freedom, no GC, no locks) are added by the type system under the floor — the surface stays shallow. Each section maps to a runnable example in
core/examples/(cargo run --example <name>).
- actor = state (struct fields) + behavior (
handle). State is single-owned, so no lock. - message =
sendmoves its ownership into the runtime (using it after send isE0382, a compile error). - System = a runtime with one thread per core. Actors are pinned to a core and never migrate (thread-per-core).
use aetherflow::{Actor, System};
struct Greeter { count: u32 }
impl Actor for Greeter {
type Message = String; // the (fixed) message type
fn handle(&mut self, name: String) { // &mut self = sole owner, no lock
self.count += 1;
println!("hello, {name}! (#{})", self.count);
}
}
let sys = System::with_cores(1); // 1-core runtime
let greeter = sys.spawn_on(0, Greeter { count: 0 });
greeter.send_blocking("Ada".to_string()).unwrap();
drop(greeter); // drop send handle → drain → stop
sys.shutdown();Lifecycle hooks: on_start (after placement) / on_stop (after the mailbox drains) / on_restart
(§4).
Choose between fire-and-forget (send_blocking / try_send) and awaiting a reply (ask).
enum Cmd { Set(String, i64), Get(String, Responder<Option<i64>>) }
// ...
kv.send_blocking(Cmd::Set("apples".into(), 3)).unwrap(); // fire-and-forget
let v: Option<i64> = kv.ask(|reply| Cmd::Get("apples".into(), reply)).unwrap(); // await replyaskputs the reply slot on the caller's stack → zero heap allocation.- To bound the wait, use
ask_timeout(dur, ..)→Err(AskError::Timeout)if no reply arrives. It holds the cell in anArc(one allocation) so a late reply after the timeout is discarded safely. askblocks the calling thread, so call it from outside the runtime (main / an I/O thread). Calling it from inside a handler on the same core returnsErr(WouldBlockCallingCore)instead of hanging silently.
Put N workers on N cores and route by key hash. The same key always lands on the same shard.
let sys = System::with_cores(4);
let shards: Vec<_> = (0..4).map(|id| sys.spawn_on(id, Shard::new(id))).collect();
let s = shard_of(key, 4);
shards[s].send_blocking(Msg::Bump(key)).unwrap(); // separate core = separate thread, no sharingThere is no work-stealing, so placement is predictable and cache locality is preserved.
With spawn_on_supervised, a panicking actor is dropped and replaced with a fresh one, and the
mailbox keeps going.
let worker = sys.spawn_on_supervised(0, Worker::new); // rebuild with Worker::new on panicBecause the type system guarantees state is single-owned (never shared), a panicked actor can be
restarted safely — no corrupted state lingers (unlike Arc<Mutex>, which poisons on panic).
ActorRef is Clone + Send, so you can pass it inside a message — that's how you subscribe.
A Hub holds the subscriber list as single-owned state and delivers each Publish to everyone with
try_send (non-blocking).
enum HubMsg { Subscribe(ActorRef<Subscriber>), Publish(String) }
// Inside a handler (i.e. sending to other actors) use try_send (send_blocking would deadlock same-core):
for sub in &self.subscribers { let _ = sub.try_send(text.clone()); }Any ActorRef can schedule messages. send_after fires once after a delay; send_every fires on
an interval and returns a TimerHandle you can cancel. Delivery is non-blocking (best-effort), and
a periodic timer stops on its own once the actor is gone (so a dropped handle never leaks).
hb.send_after(Duration::from_millis(50), Msg::WarmupDone); // one-shot, fire-and-forget
let ticker = hb.send_every(Duration::from_millis(30), || Msg::Tick); // periodic
// ...
ticker.cancel(); // stop the periodic timerTimers run on one shared background thread — meant for "tick every N ms" / "fire after a delay", not sub-microsecond scheduling.
Keep your existing async I/O on Tokio and move just the state and compute onto AetherFlow. Two rules:
- async → actor sends are non-blocking (
try_send/send_blocking) — call them from async. - Call
askinsidetokio::task::spawn_blocking(it blocks; don't stall the async runtime).
// From an async handler:
let _ = metrics.try_send(Cmd::Record { bytes });
// Read the aggregate (blocks, so run it on a blocking thread):
let snap = tokio::task::spawn_blocking(move || m.ask(Cmd::Snapshot).unwrap()).await.unwrap();| Call | Blocks? | On full mailbox | Reply? | Use when |
|---|---|---|---|---|
try_send |
no | returns Err(Full(msg)) |
no | async / hot path / handle backpressure yourself |
send_blocking |
only when full | waits until space frees | no | simple fire-and-forget |
ask / ask_timeout |
until reply / timeout | — | yes | request-reply (from outside the runtime) |
On failure the original message is returned (err.into_message()), so you can retry, persist, or
log it.
The net feature lets you write servers as "I/O as messages" (connection = actor, inbound =
messages, outbound = a non-blocking handle, no async). See the echo_server / io_bench examples
and io-surface-design.md.
design.md— the four-pillar technical thesis and "deep theory, shallow surface"concepts-explained.md— a gentle explanation of the conceptscore/examples/— every example from this guide (runnable)