Skip to content
rusthacks.dev
Architecture

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.

4 min read
Summarize in AI
Flat illustration for the post “The Actor Model in Rust With Tokio Tasks and Channels”, composed around async.

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

rust
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

rust
#[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.

rust
#[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

  1. Awaiting inside the critical section. An actor handler can .await a database call while it holds the state, and other callers just queue. A std::sync::Mutex guard across an await is a bug, and a Tokio mutex held across slow I/O stalls everyone anyway (mutex guard across await).
  2. A single owner for a resource. A connection, a file or a hardware device with one writer is naturally an actor.
  3. Serialized workflows. Multi-step state transitions are written as straight code without lock ordering.
  4. 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:

rust
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.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.