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:
- Bounded queues —
asyncio.Queue(maxsize=1000)blocks the sender when full - Credit-based flow control — consumers send “ready” tokens to producers
- Drop policies — discard oldest or newest messages when the mailbox exceeds a threshold
- 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
| Approach | Actors possible | Latency per message | Parallelism |
|---|---|---|---|
multiprocessing | Hundreds | ~1ms (IPC overhead) | True parallel |
asyncio | Hundreds of thousands | ~10μs | Cooperative |
| Pykka (threading) | Hundreds | ~100μs | GIL-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.
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.