Zero-ceremony, Tokio-native actors with strong typing and production-ready edge case handling.
Tokio Actors is a lightweight actor framework built for Rust developers who want predictable concurrency without the complexity. Every actor runs as a dedicated tokio::task on your multi-threaded runtime—no custom schedulers, no hidden magic.
Message and response types are enforced at compile time. No runtime type casting, no Any trait abuse.
impl Actor for MyActor {
type Message = MyMessage; // ← Compile-time checked
type Response = MyResponse; // ← No guessing
}Every actor has a bounded mailbox (default: 64). When full, senders wait automatically—no OOM crashes from runaway queues.
Recurring timers have three drift strategies to handle system lag:
- Skip: Jump to next aligned tick
- CatchUp: Send all missed messages immediately
- Delay: Reset timer from now
This is the kind of edge-case thinking production systems need.
Actors naturally fit AI/LLM architectures:
- Multi-Agent Systems: Each LLM agent is an actor with isolated state
- API Orchestration: Coordinate multiple LLM API calls with backpressure
- Conversation State: Bounded mailboxes prevent memory bloat from chat history
- Tool Calling: Actors model tool execution with type-safe request/response
- Async Workflows: Chain LLM calls without callback hell
Query actor status anytime: Initializing → Running → Stopping → Stopped. Perfect for health checks and graceful degradation.
cargo add tokio-actorsuse async_trait::async_trait;
use tokio_actors::{
actor::{Actor, ActorExt, context::ActorContext, handle::ActorHandle},
ActorResult,
};
// Pong actor - simply responds to pings
#[derive(Default)]
struct PongActor {
pings_received: u64,
}
#[derive(Clone)]
enum PongMsg {
Ping, // ← No manual reply_to needed!
}
#[derive(Clone)]
enum PongResp {
Pong, // ← Response goes through send() automatically
}
#[async_trait]
impl Actor for PongActor {
type Message = PongMsg;
type Response = PongResp;
async fn handle(
&mut self,
msg: Self::Message,
_ctx: &mut ActorContext<Self>,
) -> ActorResult<Self::Response> {
let PongMsg::Ping = msg;
self.pings_received += 1;
Ok(PongResp::Pong) // ← Just return the response!
}
}
// Ping actor
struct PingActor {
pong: ActorHandle<PongActor>,
pongs_received: u64,
}
#[derive(Clone)]
enum PingMsg {
SendPing,
}
#[derive(Clone)]
enum PingResp {
Ack,
}
#[async_trait]
impl Actor for PingActor {
type Message = PingMsg;
type Response = PingResp;
async fn handle(
&mut self,
msg: Self::Message,
_ctx: &mut ActorContext<Self>,
) -> ActorResult<Self::Response> {
match msg {
PingMsg::SendPing => {
// Send Ping, get Pong back automatically through send()
let resp = self.pong.send(PongMsg::Ping).await?;
if matches!(resp, PongResp::Pong) {
self.pongs_received += 1;
}
Ok(PingResp::Ack)
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Spawn pong first
let pong = PongActor::default().spawn_actor("pong", ()).await?;
// Spawn ping with reference to pong
let ping = PingActor { pong: pong.clone(), pongs_received: 0 }
.spawn_actor("ping", ())
.await?;
// Send 10 pings - each gets automatic Pong response
for _ in 0..10 {
ping.send(PingMsg::SendPing).await?;
}
Ok(())
}// No config needed - uses defaults
actor.spawn_actor("my-actor", ()).await?;
// Or customize with builder pattern
let config = ActorConfig::default().with_mailbox_capacity(256);
actor.spawn_actor("my-actor", config).await?;
// Reference to config works too
actor.spawn_actor("my-actor", &config).await?;// Fire-and-forget (async until mailbox accepts)
handle.notify(msg).await?;
// Request-response (wait for actor to process)
let response = handle.send(msg).await?;
// Non-blocking attempt (returns immediately)
handle.try_notify(msg)?;Error Handling Nuance:
notifyerrors → actor callshandle_failure()and continues processingsenderrors → actor stops (caller expects a response, failure is critical)
This asymmetry reflects real-world semantics.
use tokio::time::Duration;
use tokio_actors::MissPolicy;
ctx.schedule_after(msg, Duration::from_secs(5))?; // One-shot
ctx.schedule_recurring(
msg,
Duration::from_millis(100),
MissPolicy::CatchUp, // ← Send all missed ticks
)?;Edge Case: Scheduling in the past? The message fires immediately. No panics, no silent failures.
async fn on_started(&mut self, ctx: &mut ActorContext<Self>) -> ActorResult<()> {
// Initialize state, schedule timers
ctx.schedule_recurring(HealthCheck, Duration::from_secs(30), MissPolicy::Skip)?;
Ok(())
}
async fn on_stopped(&mut self, ctx: &mut ActorContext<Self>) -> ActorResult<()> {
// Cleanup resources
self.database.close().await;
Ok(())
}if handle.mailbox_available() < 10 {
warn!("Actor {} is backed up!", handle.id());
}
if !handle.is_alive() {
error!("Actor {} has stopped!", handle.id());
}Recurring timers use closures that are held across .await points in a spawned task:
ctx.schedule_recurring_with(
|| generate_message(), // ← Must be Sync
Duration::from_secs(1),
MissPolicy::Skip,
)?;The closure lives in an Arc that's shared across tasks. Rust's Send future rules require this. For schedule_recurring(msg, ...) where msg: Clone, we require msg: Sync for the same reason—the closure move || msg.clone() captures msg.
Workaround: If your message isn't Sync, use schedule_recurring_with with a factory that doesn't capture state.
Handles implement PartialEq based on ActorId, not channel identity:
let actor1 = MyActor.spawn_actor("foo", ()).await?;
let actor2 = actor1.clone();
assert_eq!(actor1, actor2); // ✅ Same actor ID
let actor3 = MyActor.spawn_actor("bar", ()).await?;
assert_ne!(actor1, actor3); // ✅ Different actor IDThis allows handles to be used in HashSet and HashMap for deduplication and routing.
When the mailbox is full:
notify().awaitblocks until space is availabletry_notify()returnsTrySendError::Fullimmediatelysend().awaitblocks (same as notify, just with response)
During timer catch-up (MissPolicy::CatchUp), we use try_notify to avoid blocking the timer task on a full mailbox. If the mailbox is full, we stop the catch-up—better to skip than deadlock.
| Method | Description |
|---|---|
notify(msg) |
Fire-and-forget (awaits mailbox space) |
try_notify(msg) |
Non-blocking fire-and-forget |
send(msg) |
Request-response (awaits processing) |
stop(reason) |
Request actor to stop |
is_alive() |
Check if actor is still running |
mailbox_len() |
Current queue depth |
mailbox_available() |
Free space in mailbox |
id() |
Get actor ID |
| Method | Description |
|---|---|
schedule_once(msg, when) |
Fire message at specific Instant |
schedule_after(msg, delay) |
Fire message after Duration |
schedule_recurring(msg, interval, policy) |
Recurring timer |
schedule_recurring_with(factory, interval, policy) |
Recurring with message factory |
cancel_timer(id) |
Cancel specific timer |
cancel_all_timers() |
Cancel all active timers |
active_timer_count() |
Number of active timers |
self_handle() |
Get handle to this actor |
status() |
Current lifecycle status |
ActorConfig::default()
.with_mailbox_capacity(512)cargo testTests cover:
- Ping-pong bidirectional messaging
- Timer drift policies
- Mailbox backpressure
- Handle equality and hashing
- Lifecycle hooks
- Error propagation
| Example | Description |
|---|---|
simple_counter |
Basic notify/send usage |
ping_pong |
Bidirectional actor communication |
timers |
Recurring timers with MissPolicy |
cross_comm |
Multiple actors coordinating |
Run with:
cargo run --example ping_pong- Supervision trees: Declarative parent-child relationships
- Actor registry: Named global actor lookup
- Graceful shutdown coordination: Drain mailboxes before stopping
- Telemetry hooks: Metrics and tracing integration
- Remote messaging: Tokio Actors is explicitly local (in-process)
- Distributed systems: Use Akka/Orleans/Proto.Actor for that
- Proc macros: We keep it simple—just traits
Every actor is a dedicated tokio::task. No shared executor, no fancy scheduling—just Tokio doing what it does best.
MIT OR Apache-2.0
Built with ❤️ for Rust developers who value predictability over magic.
For implementation details and edge cases, see examples/ and tests/.
Saddam Uwejan (Sam) - Rust systems engineer specializing in concurrent systems and production infrastructure.
- 🔗 GitHub
- 📧 [email protected]
Building high-performance, production-ready Rust libraries for real-world problems.