DEV Community

Dylan Dumont
Dylan Dumont

Posted on

Actor Model in Rust: Building Concurrent Systems With tokio and Channels

Message passing isolates state, preventing race conditions by design in concurrent architectures.

What We're Building

We are constructing a concurrent event processing pipeline where distinct units of logic, known as actors, communicate strictly via asynchronous channels. Instead of sharing mutable state protected by mutexes, actors maintain isolated memory spaces and exchange data through typed message queues. This pattern is essential for building robust, scalable backend services, distributed data pipelines, and high-throughput worker pools where isolation prevents data corruption and deadlocks. We will implement a simplified log aggregator that routes incoming events to specialized processors, demonstrating how the Actor Model scales in Rust.

Step 1 — Define Explicit Message Types

Messages act as the contract between actors, replacing direct memory access with typed envelopes that enforce data safety.

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LogEvent {
    Warning { message: String },
    Error { code: u32, message: String },
}

pub struct ProcessAck {
    pub id: u64,
    pub status: String,
}
Enter fullscreen mode Exit fullscreen mode

Rust's strong typing ensures that a sender knows exactly what the receiver expects, preventing runtime errors caused by mismatched payloads.

Step 2 — Construct Isolated Actor State

An actor encapsulates its own mutable state within a struct, ensuring that concurrent writes never interfere with each other across actor boundaries.

struct LogActor {
    buffer: Vec<String>,
    id: u64,
}

impl LogActor {
    async fn handle(&mut self, event: LogEvent) -> Result<ProcessAck, ()> {
        match event {
            LogEvent::Warning { message } => {
                println!("Warning {}: {}", self.id, message);
                Ok(ProcessAck { id: self.id, status: "processed".to_string() })
            }
            _ => Ok(ProcessAck { id: self.id, status: "ignored".to_string() }),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This isolation guarantees that a failure in processing one log entry never corrupts the internal state of the actor or blocks other workers from reading data.

Step 3 — Establish Channel Communication

Communication happens through tokio::sync::mpsc channels, where senders transmit messages and receivers process them at their own pace.

let (sender, mut receiver) = mpsc::channel(256);
let actor = LogActor { id: 1, buffer: Vec::new() };
let worker_handle = tokio::spawn(async move {
    while let Some(msg) = receiver.recv().await {
        let _ = actor.handle(msg).await;
    }
});
Enter fullscreen mode Exit fullscreen mode

Using a bounded channel here provides backpressure, preventing memory exhaustion if the sender pushes data faster than the actor can process it.

Step 4 — Implement the Event Loop

The actor's life cycle is defined by a while let loop that blocks until a new message arrives, at which point it executes a handler.

loop {
    match receiver.recv().await {
        Some(msg) => {
            // Perform action
        }
        None => break, // Channel closed
    }
}
Enter fullscreen mode Exit fullscreen mode

The await keyword ensures non-blocking I/O, allowing the runtime to yield control when the channel is empty without halting the entire system.

Step 5 — Graceful Error Handling

Errors inside the loop must be caught explicitly, ensuring the actor can log failures and decide whether to recover or terminate.

match actor.handle(msg).await {
    Ok(ack) => {
        // Handle success
    }
    Err(e) => {
        eprintln!("Error processing event {}: {}", ack.id, e);
        // Decide to panic or continue based on severity
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern ensures that a single bad message does not cause the entire pipeline to crash, maintaining stability under load.

Key Takeaways

The Actor Model in Rust relies on isolation over synchronization. By defining strict message types, encapsulating state within actor structs, and utilizing tokio channels for communication, we build systems that scale without race conditions. Bounded channels act as natural backpressure mechanisms, preventing memory issues. This pattern is particularly effective for microservices and event-driven architectures where latency and stability are paramount.

What's Next?

In the next article, we will explore how to implement distributed actors using gRPC and how to manage backpressure at the network layer. We will also cover testing actor-based systems to ensure robustness in production environments.

Further Reading

Part of the Architecture Patterns series.

Top comments (0)