The Actor Model in Rust With Tokio Tasks and Channels
An actor is a task that owns its data and processes messages one at a time. Channels replace locks, and a handle type hides the plumbing.

Sharing mutable state between tasks usually means Arc<Mutex<T>>, and that works until a critical section needs an .await, or until the locking discipline spreads across the codebase. The actor model offers another structure: give the state to one task, and let everything else talk to it through messages. Tokio's channels make this natural. Code runs on Rust 1.90, Tokio 1.47.
A bank account actor
use tokio::sync::{mpsc, oneshot};
enum Command {
Deposit { cents: u64 },
Withdraw { cents: u64, reply: oneshot::Sender<Result<u64, String>> },
Balance { reply: oneshot::Sender<u64> },
}
struct AccountActor {
rx: mpsc::Receiver<Command>,
balance: u64,
}
impl AccountActor {
async fn run(mut self) {
while let Some(cmd) = self.rx.recv().await {
match cmd {
Command::Deposit { cents } => self.balance += cents,
Command::Withdraw { cents, reply } => {
let result = if cents <= self.balance {
self.balance -= cents;
Ok(self.balance)
} else {
Err(format!("insufficient funds: have {}, need {}", self.balance, cents))
};
let _ = reply.send(result);
}
Command::Balance { reply } => {
let _ = reply.send(self.balance);
}
}
}
// all handles dropped: the loop ends and the task finishes
}
}The state (balance) is a plain field. Messages are processed one at a time, so check-then-update in Withdraw is atomic with no lock. The reply senders send a result back to the caller. Ignoring a failed reply.send is correct: the caller may have given up.
The handle
#[derive(Clone)]
pub struct AccountHandle {
tx: mpsc::Sender<Command>,
}
#[derive(Debug)]
pub struct ActorGone;
impl AccountHandle {
pub fn spawn(initial: u64) -> (Self, tokio::task::JoinHandle<()>) {
let (tx, rx) = mpsc::channel(64);
let actor = AccountActor { rx, balance: initial };
let join = tokio::spawn(actor.run());
(Self { tx }, join)
}
pub async fn deposit(&self, cents: u64) -> Result<(), ActorGone> {
self.tx.send(Command::Deposit { cents }).await.map_err(|_| ActorGone)
}
pub async fn withdraw(&self, cents: u64) -> Result<Result<u64, String>, ActorGone> {
let (reply, rx) = oneshot::channel();
self.tx.send(Command::Withdraw { cents, reply }).await.map_err(|_| ActorGone)?;
rx.await.map_err(|_| ActorGone)
}
pub async fn balance(&self) -> Result<u64, ActorGone> {
let (reply, rx) = oneshot::channel();
self.tx.send(Command::Balance { reply }).await.map_err(|_| ActorGone)?;
rx.await.map_err(|_| ActorGone)
}
}Callers see ordinary async methods and a cloneable handle they can store in app state and pass to many tasks. The channel is bounded at 64, so if the actor is busy, send().await waits, applying backpressure to callers instead of growing memory (backpressure design, bounded channels). ActorGone distinguishes a dead actor from a business error, which is why withdraw returns a nested Result.
#[tokio::main]
async fn main() {
let (account, join) = AccountHandle::spawn(1_000);
let a = account.clone();
let t1 = tokio::spawn(async move { a.withdraw(700).await });
let b = account.clone();
let t2 = tokio::spawn(async move { b.withdraw(700).await });
let (r1, r2) = (t1.await.unwrap().unwrap(), t2.await.unwrap().unwrap());
println!("{r1:?} {r2:?}"); // exactly one succeeds
println!("balance {}", account.balance().await.unwrap());
drop(account); // last handle gone, actor loop ends
join.await.unwrap();
}Two concurrent withdrawals of 700 from 1,000 cannot both succeed, since the actor serializes them. The shutdown line shows the lifecycle: when every AccountHandle is dropped, recv() returns None and the task completes.
Why this beats a lock here
- Awaiting inside the critical section. An actor handler can
.awaita database call while it holds the state, and other callers just queue. Astd::sync::Mutexguard across an await is a bug, and a Tokio mutex held across slow I/O stalls everyone anyway (mutex guard across await). - A single owner for a resource. A connection, a file or a hardware device with one writer is naturally an actor.
- Serialized workflows. Multi-step state transitions are written as straight code without lock ordering.
- Observability. One queue to measure: its length is the load.
Costs and hazards
- Throughput is one task. If the work is CPU bound and per-key, shard: run one actor per key (a map from key to handle), or per hash bucket.
- Latency. Every call is a message round trip plus a task wake-up, microseconds rather than nanoseconds. Not for hot inner loops.
- Deadlock by self-call. If a handler awaits a call to its own handle, it waits for itself. Pass the data in the message instead, or use a separate task for the call.
- Cyclic waits between two actors that await each other's replies deadlock the same way. Prefer one-way messages and events (event bus).
- Unbounded queues hide overload; use bounded channels and decide what a full queue means (wait, drop, error).
- Panics. A panic in a handler kills the actor, and every handle then returns
ActorGone.
Supervision
Watch the JoinHandle and restart or fail loudly:
async fn supervise(initial: u64) {
loop {
let (handle, join) = AccountHandle::spawn(initial);
publish_handle(handle); // e.g. replace the one in shared state
match join.await {
Ok(()) => break, // clean shutdown
Err(e) if e.is_panic() => tracing::error!("account actor panicked, restarting"),
Err(_) => break, // cancelled
}
}
}Restarting loses in-memory state, so an actor that matters should persist state or rebuild it from storage on start. Frameworks such as ractor and kameo provide supervision strategies if you need them at volume.
When to use which tool
| Need | Tool | |---|---| | Simple shared counter or cache | Arc<Mutex<T>> or atomics | | Read-mostly configuration | ArcSwap or watch channel | | Single owner with async work, serialized commands | actor | | Broadcast notifications | event bus | | Distributed, durable | a message broker |
Related: sharing state in Tokio, channels compared and writing a chat server with broadcast.


