What you'll learn
Quick Answer
The actor model is a concurrency design where each unit of state (an 'actor') is private and can only be changed by sending it messages through a mailbox, which it processes one at a time. Because no two threads ever touch the same memory directly, actors avoid race conditions entirely, without needing locks — a claim this post backs up with a real race condition reproduced in code, and the same logic rebuilt as an actor with zero lost updates.
The Problem: Shared Mutable State and Race Conditions
Multithreaded programming has one recurring, nasty failure mode: two threads read a piece of shared state, both compute an update based on what they read, and both write their result back — and if the timing lines up badly, one thread's write silently overwrites the other's, and an update is lost with no error, no exception, no log line. This is a classic read-modify-write race condition, and it's one of the main reasons concurrent code is harder to get right than sequential code.
The usual fix is locking: wrap the read-modify-write sequence in a mutex so only one thread can be in it at a time. Locks work, but they introduce their own problems at scale — deadlocks when two locks are acquired in different orders by different threads, contention that serializes work you wanted to run in parallel, and a mental burden where every piece of shared state needs someone to remember 'don't touch this without taking the lock first,' which inevitably gets forgotten somewhere in a large codebase.
The actor model sidesteps the whole category of problem with a different rule: no state is ever shared in the first place. Every actor owns its own state exclusively, and the only way anything outside the actor can affect that state is by sending it a message, which the actor processes on its own schedule, one message at a time. There is nothing to lock because there is nothing to contend over.
It's worth being precise about why this isn't just 'locking, but renamed.' A lock protects a critical section of code that any thread might enter; the discipline only holds if every single piece of code that touches the shared data remembers to acquire the lock first, and a single missed lock acquisition anywhere in a large codebase reintroduces the race condition silently. An actor's state, by contrast, isn't reachable by direct reference at all from outside the actor — there is no variable another thread could even accidentally read or write without going through the message-passing interface, because the state lives inside the actor's own object and nothing external holds a pointer to it. The safety comes from the data simply not being reachable the wrong way, not from everyone remembering to follow a convention correctly every time.
What an Actor Actually Is
An actor is a small, independent unit that bundles three things together: some private state that nobody outside the actor can directly read or write, a mailbox (a queue) that buffers incoming messages, and a processing loop that pulls one message at a time from the mailbox and handles it, typically with pattern matching on the message's shape or type.
The defining guarantees, confirmed against how the model is actually described: actors communicate by sending immutable messages, and since there is no shared state, actors interact by transmitting messages asynchronously. Messages queue up in the mailbox until the actor is ready, and critically, no actor ever handles more than one message at a time — if two messages arrive simultaneously, they simply queue in the mailbox and get processed one after another. That single guarantee — one message at a time, always — is what eliminates the race condition category entirely: there is never a moment where two pieces of code are mutating the same actor's state concurrently, because the actor's own processing loop is single-threaded with respect to its own state, even though the overall system has many actors running concurrently.
This is a genuinely different mental model from 'shared memory plus locks.' Instead of asking 'who needs to hold a lock before touching this data,' you ask 'which actor owns this data, and what message do I send it to make a change.' The actor itself is the only code path that ever mutates its own state, so correctness doesn't depend on every caller remembering a locking discipline.
There's a useful real-world analogy here: think of an actor like a single bank teller window with a queue in front of it, rather than a shared vault that multiple people reach into at once. Customers (other actors, or external code) don't walk behind the counter and grab cash themselves — they join the queue, state their request when it's their turn, and the teller (the actor's own processing loop) handles exactly one request at a time, start to finish, before moving to the next. Multiple teller windows (multiple actors) can all be busy simultaneously, serving different customers in parallel, but within any single window, there is never more than one transaction happening at once. That's the whole model, scaled up to however many actors a system needs.
Reproducing a Real Race Condition
Rather than just describing a race condition, here is one, built and actually run: 8 threads, each incrementing a shared counter 5,000 times (40,000 increments total, expected), with a deliberate read/modify/write split and a yield point in between to make the race window real rather than theoretical:
class SharedCounter:
def __init__(self):
self.value = 0
def increment(self):
current = self.value # read
current = current + 1 # modify
time.sleep(0) # yield point -- makes the race window real
self.value = current # write (classic read-modify-write race)
def worker_naive(counter, n):
for _ in range(n):
counter.increment()And the actor-based rewrite of the exact same counter — same 8 threads, same 5,000 increments each, but now every increment is sent as a message to a dedicated actor thread instead of mutating shared memory directly:
class CounterActor:
"""Owns its state privately. The ONLY way to change it is to send a
message into the mailbox. One dedicated thread drains the mailbox and
processes messages one at a time -- never two at once."""
def __init__(self):
self.value = 0
self.inbox = queue.Queue()
self._thread = threading.Thread(target=self._run)
self._thread.start()
def _run(self):
while True:
msg = self.inbox.get()
if msg == "increment":
current = self.value
current = current + 1
time.sleep(0)
self.value = current
def send(self, msg):
self.inbox.put(msg) # any thread can send; only the actor's own
# thread ever touches self.valueThe structural difference between the two versions is the entire point, and it's worth spelling out precisely. In the naive version, all 8 worker threads directly execute counter.increment(), meaning all 8 threads can be in the middle of reading, modifying, or writing self.value at literally the same instant — there is no code anywhere preventing that overlap. In the actor version, the 8 worker threads never call anything that touches self.value directly; they only call .send(), which does nothing more than push an item onto a queue.Queue (which is itself internally thread-safe, built for exactly this producer side of the job). The actual increment logic lives inside _run(), which executes on exactly one thread — the actor's own — created once and never shared. Both versions do the identical amount of 'real work' (40,000 increments); the only difference is which thread is allowed to execute the mutating code.
The Actual Numbers
Running both versions back to back, with the identical workload (8 threads, 5,000 increments each, 40,000 expected total):
=== Shared mutable state, 8 threads, NO synchronization ===
Expected final count: 40000
Actual final count: 5107
Lost updates: 34893 -- classic race condition, writes collide
=== Same counter, modeled as an actor (private state + mailbox), same 8 threads ===
Expected final count: 40000
Actual final count: 40000
Lost updates: 0 -- every mutation serialized through one mailbox-draining threadThe shared-memory version lost 34,893 out of 40,000 increments — a loss rate of over 87%, from 8 threads racing on a single unsynchronized counter. This isn't a rare edge case; it's what happens by default any time multiple threads do unsynchronized read-modify-write on the same variable, and the exact number lost will vary run to run depending on OS scheduling, but it will essentially never be zero.
The actor version lost exactly zero updates, with the identical 8 worker threads doing the identical amount of work. The difference is entirely structural: in the actor version, those 8 worker threads never touch self.value at all — they only ever call .send("increment"), which just pushes a message onto a thread-safe queue. The actual mutation of self.value happens exclusively inside the actor's own single dedicated thread, pulling one message off the queue at a time. There is structurally no way for two threads to race on the mutation, because only one thread ever performs it.
Worth being precise about what this demo does and doesn't prove: it doesn't mean actors are 'faster' — funneling every mutation through one thread is itself a serialization point, and a system with enough actors all hammering one specific actor can become bottlenecked on that single actor's mailbox. What it proves is correctness without locks, for this exact category of problem, which is the actual claim the actor model makes.
Erlang and Akka in Practice
The actor model isn't just an academic pattern — it's the foundation of production systems built for high concurrency and fault tolerance. Erlang, the language, builds the actor model in at the language level rather than as a library: Erlang processes are actors by default, extremely lightweight (a single machine can run millions of them), and communicate purely through message passing, which is part of why Erlang-based systems (notably telecom switching systems, which is what Erlang was originally built for) have a long track record of extremely high uptime.
Akka is a toolkit for building highly scalable, concurrent applications, written for the JVM (used from Scala or Java), and explicitly based on the ideas from Erlang's actor model, bringing the same mailbox-and-message-passing discipline to JVM-based systems without needing a dedicated language runtime built around it.
Both environments pair the actor model with a related idea worth knowing about even though it's a separate concept: supervision. Since actors are isolated from each other's state by design, a single actor crashing doesn't corrupt anyone else's data — it can simply be restarted by a supervising actor, often with a clean initial state, an approach commonly summarized as 'let it crash' rather than trying to defensively handle every possible error inline. This fits naturally with the actor model's isolation guarantees, though it's a design philosophy layered on top of actors, not a property actors give you automatically.
The combination of lightweight processes, message passing, and supervision is specifically why Erlang and Akka get reached for in systems that need to stay up continuously while handling large numbers of simultaneous, mostly-independent units of work — a telecom switch handling millions of concurrent calls, or a game server handling many concurrent player sessions, both look like 'lots of mostly-independent state machines that occasionally send each other a message,' which is exactly the shape the actor model is built for. It's a much worse fit for problems that are fundamentally about one large, tightly-coupled shared data structure that many operations need to read and write together — that kind of problem usually wants a database transaction, not a swarm of actors.
The Trade-offs Nobody Should Skip
The actor model isn't a universal upgrade over shared-memory concurrency. Message passing has real overhead compared to directly reading a shared variable — every interaction between actors means constructing a message object and pushing it through a queue, instead of a direct memory read. For extremely hot, simple shared counters, this overhead can matter.
It also changes how you model data, not just how you synchronize access to it. If multiple actors each need a view of the same information, you either duplicate that information across actors (and now need a strategy for keeping copies reasonably in sync) or route every access through a single owning actor (which turns that actor into a serialization bottleneck for that specific piece of data, exactly as this post's demo would if every actor in a big system needed to touch the same counter actor).
And debugging a system of many actors sending each other messages asynchronously has its own learning curve — a bug caused by message ordering or an actor's mailbox backing up under load doesn't look like a traditional stack-trace-pointing-at-one-line bug, it looks like 'this actor is slow and I need to trace which other actors are sending it too many messages.' The actor model trades one category of hard bug (data races) for a different category of hard-to-trace behavior (message flow and backpressure), which is a genuine trade, not a free lunch.
There's also a sizing decision that doesn't exist in shared-memory code: how many actors to create, and what each one should own. Too few, coarse-grained actors and you've recreated a bottleneck — one actor's mailbox becomes the new serialization point for everything that actor is responsible for, which is exactly what the counter-actor demo in this post would become if it were the ONLY actor in a much bigger real system. Too many, fine-grained actors and the message-passing overhead this section opened with starts to dominate, since every small interaction now costs a queue push and a context switch instead of a direct function call. Getting actor granularity right is its own design skill, separate from understanding the model's correctness guarantees.
Frequently Asked Questions
Is the actor model the same as using a queue?
A mailbox is a queue, but the actor model is more than just 'use a queue.' The defining property is that an actor's state is private and can ONLY be changed by messages it processes one at a time from its own mailbox -- no other code path is allowed to mutate that state directly, which is what actually prevents races.
Do actors eliminate the need for any synchronization at all?
Within a single actor's own state, yes -- there is nothing to synchronize because only one thread (the actor's own processing loop) ever touches it. Across actors, correctness instead depends on message ordering and each actor's own logic, which is a different kind of care, not zero care.
Why did the demo lose exactly 34,893 updates and not some other number?
The exact lost-update count depends on OS thread scheduling, which varies between runs -- it will not be the same number every time you run it, but it will reliably be a large nonzero number any time 8 threads race unsynchronized on one counter with a yield point between read and write, which is the point being demonstrated.
Is Erlang the only language with actors built in?
No. Erlang builds actors into the language itself as lightweight processes, but the pattern is also available as libraries or toolkits in other languages -- Akka brings it to the JVM (Scala and Java), and similar actor libraries exist for other ecosystems too.
What is 'let it crash' and is it part of the actor model itself?
'Let it crash' is a supervision philosophy common in Erlang and Akka systems: instead of defensively handling every possible error inline, let an actor crash on an unexpected error and have a supervising actor restart it cleanly. It pairs naturally with actor isolation (a crash doesn't corrupt other actors' state) but is a separate design choice layered on top of the actor model, not a guarantee the model provides automatically.
