Python Actor Model — Deep Dive

The actor model in depth

The actor model defines three primitives: create new actors, send messages, and designate the behavior for the next message. Every actor is an isolated unit with its own state, a mailbox (message queue), and a processing loop. There is no shared memory — communication is strictly asynchronous message passing.

Python doesn’t have native actor support, but its multiprocessing and asyncio foundations make it straightforward to build actor systems that are useful in production.

Rolling your own actors with multiprocessing

The simplest actor is a process reading from a queue:

import multiprocessing as mp
from multiprocessing import Process, Queue
from typing import Any

class Actor:
    def __init__(self):
        self.inbox: Queue = Queue()
        self._process = Process(target=self._run, daemon=True)

    def start(self):
        self._process.start()

    def send(self, message: Any):
        self.inbox.put(message)

    def _run(self):
        while True:
            msg = self.inbox.get()
            if msg is None:  # poison pill
                break
            self.handle(msg)

    def handle(self, message: Any):
        raise NotImplementedError


class CounterActor(Actor):
    def __init__(self):
        super().__init__()
        self.count = 0

    def handle(self, message):
        if message == "increment":
            self.count += 1
        elif message == "get":
            print(f"Count: {self.count}")

Each CounterActor has completely private state. No locks, no race conditions. The tradeoff: communicating results back requires another queue or a reply mechanism.

Async actors with asyncio

For I/O-bound workloads, async actors avoid process overhead:

import asyncio
from typing import Any

class AsyncActor:
    def __init__(self):
        self.inbox: asyncio.Queue = asyncio.Queue()

    async def start(self):
        while True:
            msg = await self.inbox.get()
            if msg is None:
                break
            await self.handle(msg)

    async def send(self, message: Any):
        await self.inbox.put(message)

    async def handle(self, message: Any):
        raise NotImplementedError


class OrderProcessor(AsyncActor):
    def __init__(self):
        super().__init__()
        self.processed = 0

    async def handle(self, message):
        order_id = message["order_id"]
        await asyncio.sleep(0.01)  # simulate I/O
        self.processed += 1

You can run hundreds of thousands of async actors in a single event loop. The GIL doesn’t matter here because you’re yielding at I/O boundaries, not fighting over CPU.

Pykka: the Pythonic actor library

Pykka provides a clean actor abstraction with futures for request-reply:

import pykka

class Greeter(pykka.ThreadingActor):
    def __init__(self, greeting="Hello"):
        super().__init__()
        self.greeting = greeting

    def on_receive(self, message):
        name = message.get("name", "stranger")
        return f"{self.greeting}, {name}!"

# Usage
ref = Greeter.start(greeting="Hi")
future = ref.ask({"name": "Alice"})
print(future.get(timeout=5))  # "Hi, Alice!"
ref.stop()

Pykka offers ThreadingActor (one thread per actor) and integrates with gevent for lightweight green threads. It handles actor lifecycle, proxy objects for attribute access, and future-based replies.

Thespian: distributed actors

Thespian goes further with multi-node support and supervision:

from thespian.actors import Actor, ActorSystem

class Calculator(Actor):
    def receiveMessage(self, msg, sender):
        if isinstance(msg, dict) and msg.get("op") == "add":
            result = msg["a"] + msg["b"]
            self.send(sender, {"result": result})

system = ActorSystem()
calc = system.createActor(Calculator)
response = system.ask(calc, {"op": "add", "a": 3, "b": 7}, timeout=5)
print(response)  # {"result": 10}
system.shutdown()

Thespian supports actor creation across multiple machines, message routing over TCP, and supervisor hierarchies where parent actors control child lifecycle.

Supervision trees

In production actor systems, failures are expected. Supervision trees define recovery strategies:

  • One-for-one: restart only the failed child actor
  • One-for-all: restart all children when one fails
  • Escalate: pass the failure up to the parent supervisor
# Thespian supervision example
from thespian.actors import ActorExitRequest

class Supervisor(Actor):
    def receiveMessage(self, msg, sender):
        if isinstance(msg, str) and msg == "start_workers":
            self.workers = [
                self.createActor(Worker) for _ in range(4)
            ]
        elif isinstance(msg, ActorExitRequest):
            # Child died, restart it
            replacement = self.createActor(Worker)
            self.workers = [
                replacement if w == sender else w
                for w in self.workers
            ]

This “let it crash” approach, borrowed from Erlang/OTP, means you design for recovery instead of trying to handle every edge case defensively.

Back-pressure and mailbox overflow

Unbounded mailboxes are the classic actor pitfall. If a producer sends messages faster than a consumer processes them, memory grows without limit. Solutions:

  1. Bounded queuesasyncio.Queue(maxsize=1000) blocks the sender when full
  2. Credit-based flow control — consumers send “ready” tokens to producers
  3. Drop policies — discard oldest or newest messages when the mailbox exceeds a threshold
  4. Batching — accumulate messages and process them in groups
class BackPressureActor(AsyncActor):
    def __init__(self, max_pending=500):
        super().__init__()
        self.inbox = asyncio.Queue(maxsize=max_pending)

    async def send(self, message):
        try:
            self.inbox.put_nowait(message)
        except asyncio.QueueFull:
            # Apply policy: drop, block, or signal upstream
            oldest = self.inbox.get_nowait()
            await self.inbox.put(message)

Performance characteristics

ApproachActors possibleLatency per messageParallelism
multiprocessingHundreds~1ms (IPC overhead)True parallel
asyncioHundreds of thousands~10μsCooperative
Pykka (threading)Hundreds~100μsGIL-limited
Thespian (multi-node)Thousands per node~5ms (network)Distributed

For comparison, Erlang/BEAM can run millions of actors with sub-microsecond messaging. Python’s actor systems are practical for architectures with hundreds to tens of thousands of actors, not millions.

When to use actors vs alternatives

Actors excel when you have many independent entities with their own state: chat sessions, IoT devices, game entities, workflow steps. They’re overkill for simple producer-consumer pipelines (use asyncio.Queue directly) or CPU-bound computation (use multiprocessing.Pool).

The real value is architectural: actors force you to think about boundaries, message contracts, and failure recovery. Even if you never use Pykka or Thespian, designing systems as communicating actors with private state leads to cleaner, more resilient code.

The one thing to remember: actors replace shared mutable state with isolated processes and message queues. In Python, you can build them from raw multiprocessing, asyncio tasks, or dedicated libraries — each with different performance envelopes, but all sharing the same core guarantee of isolation.

pythonadvancedconcurrency

See Also

  • Python Aiocache Caching aiocache remembers expensive answers so your async Python app doesn't waste time asking the same question twice.
  • Python Aiofiles Async Io aiofiles lets your async Python program read and write files without freezing — because normal file operations secretly block everything.
  • Python Aiohttp Understand Aiohttp through an everyday analogy so Python behavior feels intuitive, not random.
  • Python Anyio Portability AnyIO lets your async Python code work with any async library — write once, run on asyncio or Trio without changes.
  • Python Anyio Understand Anyio through an everyday analogy so Python behavior feels intuitive, not random.