Skip to Content
Course content

140: Practice Exercise: Building a Simple Actor System

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start building actor systems in Rust, the instinct is often to reach for Arc<Mutex<T>> for everything. You want your actors to be accessible, and you want them to be able to change their internal state. But this is exactly where most people trip up. I've seen a lot of developers build a "system" that is actually just a collection of shared-state objects that deadlock the moment you add a second actor.

Take a look at this snippet. This is a classic "first attempt" at a simple counter actor.

use std::sync::{mpsc, Arc, Mutex};
use std::thread;

enum Message {
    Increment,
    GetCount(mpsc::Sender<u32>),
}

struct CounterActor {
    count: u32,
}

fn main() {
    let (tx, rx) = mpsc::channel();
    let actor = Arc::new(Mutex::new(CounterActor { count: 0 }));

    let actor_clone = Arc::clone(&actor);
    thread::spawn(move || {
        while let Ok(msg) = rx.recv() {
            let mut state = actor_clone.lock().unwrap();
            match msg {
                Message::Increment => state.count += 1,
                Message::GetCount(reply_tx) => {
                    reply_tx.send(state.count).unwrap();
                }
            }
        }
    });

    tx.send(Message::Increment).unwrap();
    // Imagine more complex logic here...
}

The Mutex Guard Bottleneck

At first glance, this looks fine. It compiles. But there's a fundamental architectural flaw here: we are wrapping the actor's state in a Mutex and then managing the message loop outside of that state. In a real system, if the GetCount logic became more complex—perhaps needing to query another actor—this code would deadlock instantly. You're holding a lock on the state while waiting for external I/O or other actors to respond.

More importantly, you're fighting the "Actor" philosophy. An actor shouldn't be a piece of shared state that is modified by a loop; the actor is the loop and the state combined. By using Arc<Mutex<T>>, you're treating the actor like a global variable with a fancy wrapper.

Owning State via Message Passing

The fix is to move the state inside the thread's closure. We don't need Arc or Mutex at all for the internal state because the thread that processes the messages is the sole owner of that data. This eliminates locking overhead and removes the possibility of deadlocks within the actor itself.

use std::sync::mpsc;
use std::thread;

enum Message {
    Increment,
    GetCount(mpsc::Sender<u32>),
}

struct CounterActor {
    count: u32,
}

impl CounterActor {
    fn new() -> mpsc::Sender<Message> {
        let (tx, rx) = mpsc::channel();
        
        thread::spawn(move || {
            let mut state = CounterActor { count: 0 };
            while let Ok(msg) = rx.recv() {
                match msg {
                    Message::Increment => state.count += 1,
                    Message::GetCount(reply_tx) => {
                        let _ = reply_tx.send(state.count);
                    }
                }
            }
        });
        
        tx
    }
}

fn main() {
    let counter = CounterActor::new();
    
    counter.send(Message::Increment).unwrap();
    
    let (reply_tx, reply_rx) = mpsc::channel();
    counter.send(Message::GetCount(reply_tx)).unwrap();
    
    println!("Count: {}", reply_rx.recv().unwrap());
}

Notice how much cleaner this is. The CounterActor doesn't exist as a struct in the main thread; instead, we hold a Sender. This is the core of the actor model: you don't call methods on an object; you send a message to an address. The state is private, encapsulated, and modified sequentially by the message loop. No locks, no contention, just a clean stream of events.

Handling Actor Shutdown

One thing I always tell my juniors: don't forget about the "poison pill." In the example above, the actor thread runs forever until the Sender is dropped. In a production system, you want a way to tell an actor to shut down gracefully so it can save its state or close database connections.

The simplest way to do this is to add a Shutdown variant to your message enum. When the actor receives Shutdown, it simply breaks the while let loop. Because the state is owned by the closure, it will be dropped naturally as the thread exits. This is far more predictable than just killing the thread or relying on the channel to close.




📋 Practical Task

Exercise: Building a Concurrent Log-Aggregator Actor System

Your task is to build a system where multiple "Worker" actors send log messages to a single "Aggregator" actor. The Aggregator should maintain a count of how many logs of each level (Info, Warning, Error) it has received.

  • The Message Types: Create an enum LogMessage that can be a Log(LogLevel, String) or a GetReport(mpsc::Sender<HashMap<LogLevel, u32>>).
  • The Aggregator: Implement an Aggregator actor that owns a HashMap<LogLevel, u32>. It should process logs by incrementing the count for that level and respond to GetReport requests with the current map.
  • The Workers: Spawn three separate threads. Each thread should act as a worker, sending at least 5 random log messages (varying levels) to the Aggregator.
  • The Shutdown: Implement a Shutdown message. Once the workers are finished, send the shutdown signal to the Aggregator and ensure the program exits cleanly.

Constraint: Do not use Arc<Mutex<T>> to share the log counts. All state updates must happen via message passing within the Aggregator's own thread loop.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.