user@techdebt:~/blog$_
$ cd ..

Actors and Message Passing

Actor A {count: 0} idle X mailbox Actor B {sum: 0} idle X mailbox Actor C {log: []} idle X mailbox Supervisor Thread 1 idle -- Thread 2 idle -- counter 0 Lock: free no lock!
mode:
$ actor inspector
click Step or Play to inspect actor state
$ simulation.log

the idea

Every concurrency bug we’ve looked at so far has the same root cause: shared mutable state. Two threads touch the same memory, and things go wrong. Locks fix it, but locks bring deadlocks, priority inversion, and code that’s hard to reason about.

The actor model takes a different approach. Instead of sharing memory and coordinating access, actors are independent units that communicate by sending messages. Each actor has four things:

  • An address, so others can send to it.
  • A mailbox, a queue of incoming messages.
  • Private state, that no one else can touch.
  • Behavior, how it processes each message.

When an actor processes a message, it can do three things: send messages to other actors, create new actors, or change its own state for the next message. That’s it. No reaching into another actor’s state. No shared variables. No locks.

a python actor

Python doesn’t have actors built in, but you can build a minimal actor system with threads and queues:

import threading
from queue import Queue

class Actor:
    def __init__(self):
        self.mailbox = Queue()
        self.state = {}
        self._running = True
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()

    def _loop(self):
        while self._running:
            message = self.mailbox.get()
            if message is None:
                break
            self.handle(message)

    def handle(self, message):
        raise NotImplementedError

    def send(self, message):
        self.mailbox.put(message)

    def stop(self):
        self._running = False
        self.mailbox.put(None)

Each actor gets its own thread and its own mailbox. The _loop method pulls one message at a time and calls handle. Subclasses define what handle does. send drops a message into the mailbox. stop sends a poison pill (None) to shut the loop down.

Here’s a counter built on top of it:

class CounterActor(Actor):
    def __init__(self):
        super().__init__()
        self.state = {"count": 0}

    def handle(self, message):
        if message["type"] == "add":
            self.state["count"] += message["value"]
        elif message["type"] == "get":
            message["reply_to"].send({
                "type": "count",
                "value": self.state["count"]
            })

No locks anywhere. The actor’s _loop processes one message at a time on a single thread. State is private. Only the actor’s own thread ever reads or writes self.state. This is thread-safe by design, not by discipline.

Send a thousand add messages from ten different threads. Every single one is processed sequentially through the mailbox. No lost updates, no race conditions.

counter = CounterActor()

def spam_adds():
    for _ in range(1000):
        counter.send({"type": "add", "value": 1})

threads = [threading.Thread(target=spam_adds) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

import time
time.sleep(1)  # let the actor drain its mailbox

class Printer(Actor):
    def handle(self, message):
        print(f"count: {message['value']}")  # always 10000

printer = Printer()
counter.send({"type": "get", "reply_to": printer})

when actors don’t fit

Actors aren’t free. Message passing is slower than a direct function call. Every message goes through a queue, gets dispatched, and the reply comes back through another queue. For tight computational loops where you need nanosecond-level performance, this overhead matters.

Debugging is harder too. Instead of a stack trace, you’re tracing messages across actors. When something goes wrong, you need to reconstruct the sequence of messages that led to the bad state. Logging and tracing become essential.

Sometimes a simple lock is the right answer. If two threads need to bump a counter a few times during startup, pulling in an actor framework is overkill. Actors shine when you have many independent entities with their own state that need to coordinate over time.