How to Use the Actor Model in Rust

The Actor Model in Rust is best implemented using the `tokio` ecosystem, specifically the `tokio::sync::mpsc` channel for message passing and `tokio::spawn` for concurrent actor lifecycles, rather than relying on a single heavy framework.

When shared state becomes a liability

You're building a chat server. One user sends a message, and you need to broadcast it to ten other users. In Python, you might reach for a global dictionary and a lock. In Rust, shared mutable state screams "borrow checker panic." You need a way for independent pieces of code to talk without touching each other's memory. That's where the actor model steps in. Actors are isolated workers. They don't share state. They send messages. If one actor crashes, the rest keep running.

The actor model maps naturally to Rust. Rust's ownership rules forbid shared mutable state by default. Actors take this restriction and turn it into a feature. Each actor owns its data. No one else can touch it. Interaction happens only through messages. The compiler enforces the isolation. You get concurrency without data races, and you get structure without fighting the borrow checker.

The actor is a task with a mailbox

An actor in Rust is an async task that owns a receiver channel. It runs a loop. It waits for messages. It processes them. It updates its private state. It never shares that state. The only way to interact is through the channel.

Think of a restaurant kitchen. The chef is the actor. The orders are messages. Waiters drop orders into a slot. The chef picks them up one by one, cooks, and maybe sends a note back to the waiter. The chef never touches the waiter's tray directly. The waiter never touches the stove. Everything happens through the order slot.

In Rust, the "order slot" is a tokio::sync::mpsc channel. The chef is a task created by tokio::spawn. The messages are enums. The reply notes are temporary channels passed inside messages. This pattern gives you request-response, fire-and-forget, and broadcast semantics using the same building blocks.

Minimal counter actor

Start with a simple counter. The actor holds a value. It accepts increment commands and get requests. The get request uses a reply channel to send the value back to the requester.

use tokio::sync::mpsc;

/// Messages the counter actor understands.
enum CounterMsg {
    /// Increment the internal value.
    Increment,
    /// Request the current value. The sender is used to reply.
    Get(Option<mpsc::Sender<i32>>),
}

/// Spawns a counter actor and returns its sender.
fn spawn_counter() -> mpsc::Sender<CounterMsg> {
    // Capacity 100 allows burst traffic without blocking the sender.
    let (tx, mut rx) = mpsc::channel(100);

    tokio::spawn(async move {
        let mut value = 0;
        // `move` transfers the receiver into the task.
        // The task now owns the mailbox.
        while let Some(msg) = rx.recv().await {
            match msg {
                CounterMsg::Increment => {
                    value += 1;
                }
                CounterMsg::Get(reply) => {
                    if let Some(tx) = reply {
                        // Send the current value back to the requester.
                        // `let _ =` discards the error.
                        // If the receiver dropped, the requester is gone.
                        let _ = tx.send(value).await;
                    }
                }
            }
        }
    });

    tx
}

#[tokio::main]
async fn main() {
    let tx = spawn_counter();

    // Send commands.
    tx.send(CounterMsg::Increment).await.unwrap();
    tx.send(CounterMsg::Increment).await.unwrap();

    // Request the value using a reply channel.
    let (reply_tx, mut reply_rx) = mpsc::channel(1);
    tx.send(CounterMsg::Get(Some(reply_tx))).await.unwrap();

    if let Some(value) = reply_rx.recv().await {
        println!("Current count: {}", value);
    }
}

Spawn the actor and forget the handle. The channel is your only link. If the channel breaks, the actor is gone.

How the loop drives the system

The actor loop is the heart of the pattern. rx.recv().await suspends the task until a message arrives. No CPU cycles are wasted spinning. When a message comes, the task wakes up and processes it. The match arm dispatches to the handler.

The reply channel pattern handles requests. The requester creates a new channel, passes the sender in the message, and waits on the receiver. The actor sends the result back. This keeps the actor's interface simple. The actor doesn't need to know who is asking. It just sends to the provided sender.

The Option<mpsc::Sender> in the Get variant is a convention. It allows the message type to support both commands and queries. Commands don't need a reply, so the option is None. Queries need a reply, so the option is Some. This keeps the enum unified and avoids creating separate types for every message shape.

Backpressure is built into the channel. The channel has a capacity. If the actor is slow and the channel fills up, tx.send().await blocks. The sender waits until the actor processes a message and frees space. This prevents memory exhaustion. The system slows down instead of crashing. Bounded channels are the norm. Unbounded channels can grow until the process runs out of memory.

Realistic pattern: shutdown and error handling

Real actors need to stop. An actor that runs forever leaks resources. You need a shutdown message. When the actor receives shutdown, it cleans up and breaks the loop. The task completes.

use tokio::sync::mpsc;

/// Commands for the logger actor.
enum LoggerCmd {
    /// Log a message.
    Log(String),
    /// Shut down the actor and flush remaining logs.
    Shutdown,
}

/// A logger that buffers messages and flushes periodically.
struct Logger {
    rx: mpsc::Receiver<LoggerCmd>,
}

impl Logger {
    fn new() -> (Self, mpsc::Sender<LoggerCmd>) {
        // Capacity 32 balances memory usage and burst tolerance.
        let (tx, rx) = mpsc::channel(32);
        (Self { rx }, tx)
    }

    /// Runs the actor loop until shutdown.
    async fn run(mut self) {
        let mut buffer = Vec::new();
        while let Some(cmd) = self.rx.recv().await {
            match cmd {
                LoggerCmd::Log(msg) => {
                    buffer.push(msg);
                    // Flush if buffer reaches threshold.
                    if buffer.len() >= 10 {
                        self.flush(&buffer).await;
                    }
                }
                LoggerCmd::Shutdown => {
                    // Flush remaining logs before exiting.
                    self.flush(&buffer).await;
                    break;
                }
            }
        }
    }

    async fn flush(&self, buffer: &[String]) {
        if !buffer.is_empty() {
            println!("Flushing {} logs", buffer.len());
        }
    }
}

#[tokio::main]
async fn main() {
    let (logger, tx) = Logger::new();
    
    // Spawn the actor. Keep the handle if you need to wait for completion.
    let handle = tokio::spawn(logger.run());

    tx.send(LoggerCmd::Log("Hello".into())).await.unwrap();
    tx.send(LoggerCmd::Log("World".into())).await.unwrap();
    
    // Signal shutdown.
    let _ = tx.send(LoggerCmd::Shutdown).await;
    
    // Wait for the actor to finish cleaning up.
    let _ = handle.await;
}

The Shutdown message breaks the loop. The task completes cleanly. The JoinHandle lets you wait for the actor to finish. If you drop the handle, the actor runs in the background. If the channel breaks, the actor dies. This is the standard lifecycle. Send shutdown, wait for the handle, or just drop everything and let the runtime clean up.

Check your message types for Send. The compiler will catch the rest.

Pitfalls and compiler traps

Actors in Rust are safe, but you can still make mistakes. The compiler helps, but you need to know what to look for.

If your message type contains Rc, the compiler rejects the spawn with E0277 (trait bound not satisfied). Rc is not thread-safe. The multi-threaded runtime requires messages to be Send. Use Arc instead. Arc is atomic and safe to share across threads. This is the most common trap for beginners coming from single-threaded async code.

If you try to send the same sender twice without cloning, you get E0382 (use of moved value). The sender moves into the send call. Clone the sender if multiple parts of your code need to send messages. tx.clone() is cheap. It just bumps a reference count.

If you block the actor loop with heavy computation, you block the channel. Other senders wait. The system stalls. Keep the loop tight. Offload heavy work to a separate task or a thread pool. The actor should process messages quickly and move on.

If you create circular dependencies, you get deadlocks. Actor A waits for Actor B, and Actor B waits for Actor A. The system freezes. Design your actor graph to be acyclic. Use timeouts for requests. If an actor doesn't reply in time, assume it's dead and handle the error.

Convention aside: Always derive Debug on your message enums. It saves hours of debugging when you need to log what an actor received. #[derive(Debug)] on the enum is standard practice.

Don't share state. Send messages.

Choosing your actor strategy

Rust offers several ways to build concurrent systems. Pick the tool that matches your complexity.

Use tokio::sync::mpsc channels when you want lightweight actors with zero framework overhead. You get full control over the loop and error handling. The pattern is simple and composable. Most Rust applications need nothing more than channels and tasks.

Use the actix crate when you need a full actor system with supervision trees, named addresses, and built-in lifecycle hooks. It adds abstraction but saves boilerplate for complex systems. actix provides an Address type that carries the actor's identity and can route messages dynamically. This is useful for service discovery and dynamic actor creation.

Use tokio::sync::broadcast when one message needs to reach many actors. mpsc delivers to one receiver. broadcast fans out to all subscribers. Use this for events like configuration changes or global state updates. Subscribers can drop messages if they fall behind, which prevents backpressure from blocking the broadcaster.

Use shared state with Arc<Mutex<T>> when actors need to read the same data frequently and message passing adds too much latency. Measure first. Actors isolate. Shared state couples. Only reach for shared state when profiling proves message passing is the bottleneck. Even then, keep the critical section small.

Start with channels. Add actix only when the boilerplate outweighs the clarity.

Where to go next