⏱️ Lectura: 12 min
An actor never shares memory with anyone: it receives a message, updates its own state, and responds, all without touching anyone else’s variable. That restriction, which sounds limiting, is the reason Erlang has kept phone switches and messaging systems running without crashing for more than three decades.
📑 En este artículo
- TL;DR
- What the Actor Model Is and Why It Matters
- How It Works Internally
- Actors in Code: From Hello World to a Supervised Actor
- Getting Started
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison With Alternatives
- Going Deeper
- Frequently Asked Questions
- Is the Actor Model the Same as Operating System Threads?
- What’s the Difference Between GenServer.cast and GenServer.call?
- Can the Actor Model Be Used Outside Erlang and Elixir?
- What Happens if an Actor Receives Messages Faster Than It Can Process Them?
- Does the Actor Model Eliminate All Concurrency Bugs?
- How Do You Debug an Actor System in Production?
- References
The actor model is the concurrency paradigm behind Elixir/OTP, Akka, and Microsoft Orleans. In this guide you’ll understand how it works internally, write your first actors in Elixir, and learn when it makes sense to choose it over threads with locks or over goroutines.
TL;DR
- You’ll understand why an actor never shares memory and why that eliminates race conditions at the root.
- You’ll be able to write a GenServer in Elixir that receives messages and keeps its own state.
- You’ll know how to design a supervision tree that automatically restarts crashed processes (“let it crash”).
- You’ll be able to compare the actor model against threads with locks and against goroutines/CSP to pick the right one.
- You’ll identify common mistakes: blocking calls, overflowing mailboxes, and actors without a supervisor.
- You’ll know how to check live how many actors are running and whether a supervisor restarted any of them.
- You’ll learn about real cases where the actor model powers large-scale messaging systems.
What the Actor Model Is and Why It Matters
Carl Hewitt formalized the concept in 1973 as a mathematical model of concurrent computation based on message passing, long before today’s multicore processors existed. The implementation that made it famous arrived more than a decade later: Erlang, created in 1986 at Ericsson’s labs by Joe Armstrong, Robert Virding, and Mike Williams to power phone switches that couldn’t go down.
The problem the actor model solves is an old one: when two threads share the same data structure in memory, any uncoordinated simultaneous access produces a race condition. The classic solution is locks (mutexes, semaphores), but locks bring their own cost: if two threads acquire two locks in a different order, the program freezes in a deadlock. The actor model attacks the problem from another angle: if no one shares memory, no lock is needed at all.
An actor is the model’s smallest unit of computation: it has its own identity, a private state that no one else can read or write directly, and a message queue called a mailbox. The only way to interact with an actor is to send it a message; the only way an actor responds is by sending another message. There are no shared pointers, no global variables, no memory that two actors can step on at the same time.
How It Works Internally
Each actor processes its mailbox one message at a time, in arrival order. That means there’s never real concurrency within a single actor: an actor’s code always executes sequentially. Concurrency appears between different actors, which do run in parallel with each other, each isolated in its own bubble of state.
When an actor receives a message it can do three things, and only three: send messages to other actors (including itself), create new actors, or decide what behavior it will use to process the next message it receives. That simplicity is deliberate: by reducing the possible operations, the model eliminates entire categories of concurrency bugs before they can exist.
Another key piece is location transparency: sending a message to an actor running on the same node or to one running on another machine in a cluster is, in the code, exactly the same operation. This is what lets Akka and Erlang scale from a single process to a distributed cluster without rewriting business logic.
flowchart LR
A["Actor A"] -->|"sends message"| B["Actor B's mailbox"]
B --> C["Actor B processes one at a time"]
C --> D["Actor B updates its private state"]
D -->|"responds"| A
Actors in Code: From Hello World to a Supervised Actor
The simplest possible example is a counter. In Elixir, each actor is implemented with the GenServer behavior, which defines in a standard way how a process starts, receives messages, and stores state:
defmodule Contador do
use GenServer
# Public API
def start_link(valor_inicial \\ 0) do
GenServer.start_link(__MODULE__, valor_inicial, name: __MODULE__)
end
def incrementar do
GenServer.cast(__MODULE__, :incrementar)
end
def valor_actual do
GenServer.call(__MODULE__, :valor_actual)
end
# Actor callbacks
def init(valor_inicial) do
{:ok, valor_inicial}
end
def handle_cast(:incrementar, estado) do
{:noreply, estado + 1}
end
def handle_call(:valor_actual, _from, estado) do
{:reply, estado, estado}
end
end
GenServer.cast is asynchronous: it sends the message to the mailbox and continues without waiting for a response. GenServer.call is synchronous: it blocks the caller until the actor responds or the timeout expires. The state (estado) lives only inside this process; no other actor can read or modify it directly.
A more realistic case adds supervision: a pool of workers that process tasks and, if one fails, restarts on its own without taking down the others.
defmodule PoolWorkers.Application do
use Application
def start(_type, _args) do
children = [
{DynamicSupervisor, name: PoolWorkers.Supervisor, strategy: :one_for_one}
]
Supervisor.start_link(children, strategy: :one_for_one, name: PoolWorkers.Root)
end
end
defmodule PoolWorkers.Procesador do
use GenServer, restart: :transient
def start_link(tarea) do
GenServer.start_link(__MODULE__, tarea)
end
def init(tarea) do
send(self(), :procesar)
{:ok, tarea}
end
def handle_info(:procesar, tarea) do
resultado = Jason.decode!(tarea.payload)
{:noreply, %{tarea | resultado: resultado}}
end
end
# Start a worker under the dynamic supervisor
DynamicSupervisor.start_child(
PoolWorkers.Supervisor,
{PoolWorkers.Procesador, %{payload: "{\"id\": 42}", resultado: nil}}
)
If the payload arrives malformed, Jason.decode! raises an exception and that worker dies. With restart: :transient, the DynamicSupervisor only restarts it if the exit was abnormal, not if it terminated on purpose, and no other worker in the pool finds out about the failure.
sequenceDiagram
participant S as Supervisor
participant W as Worker
S->>W: starts linked process
W-->>S: confirms start
Note over W: receives an invalid payload and fails
W-->>S: exit signal
S->>W: restarts with clean state
W-->>S: new process active
Getting Started
To try this today you just need Erlang and Elixir installed. On Debian/Ubuntu:
sudo apt update
sudo apt install -y erlang elixir
elixir -v
Next, create a new project, paste the Contador module into lib/demo_actores.ex, and start an interactive console connected to your application:
mix new demo_actores
cd demo_actores
iex -S mix
Inside iex, try the actor’s full lifecycle:
iex> Contador.start_link()
iex> Contador.incrementar()
iex> Contador.valor_actual()
#=> 1
💡 Tip: If you need to decide quickly between paradigms, ask yourself whether your problem is “many independent states that can fail separately” (actor model) or “a data pipeline that flows in one direction” (CSP/channels).
Real-World Use Cases
Akka brings the actor model to the JVM (Scala and Java), and it’s used by financial and trading systems that need fault tolerance without losing performance. Microsoft Orleans implements distributed actors in .NET and is the framework that powers the backend of Xbox Live and Halo, where each player or game session is modeled as an independent actor (a “grain,” in Orleans terminology).
In Elixir, the same model is used in real-time messaging systems: each user connection, each chat room, or each notification channel can live as its own actor, isolated from the rest, which means that an individual connection dropping doesn’t affect the others.
Common Mistakes and Best Practices
The most common mistake is chaining synchronous calls between actors: if actor A makes a GenServer.call to actor B, and B in turn makes a call to A before responding, both end up waiting on each other until the timeout. The practical rule is to avoid having an actor call back synchronously to whoever is calling it.
The second mistake is not measuring the mailbox size. If an actor receives messages faster than it processes them, the queue grows without limit by default and can exhaust the memory of the entire node, not just that actor’s. Distributing the load across several actors (a pool) or applying explicit backpressure solves this.
The third mistake is putting heavy CPU work inside a single critical actor: since each actor processes its mailbox serially, a long computation blocks all the messages waiting behind it in that same queue, even though the rest of the system keeps running in parallel.
⚠️ Watch out:GenServer.call/2is synchronous and blocks the calling actor: if two actors call each other withcall, you can end up in a real deadlock, not just a theoretical one.
Comparison With Alternatives
| Option | When to Use It | Advantage | Limitation |
|---|---|---|---|
| Actor model (Erlang/OTP, Akka, Orleans) | Distributed systems with thousands of independent processes and a need for fault tolerance | Total isolation: one actor can’t corrupt another’s state | Learning curve for the paradigm and for “let it crash” |
| Threads + locks (mutexes, semaphores) | Code that already shares data structures and can’t be redesigned | Fine control over hardware and good performance within a single process | Deadlocks and race conditions if a lock is forgotten or acquired in the wrong order |
| CSP / goroutines and channels (Go) | Data pipelines where flow matters more than the identity of each unit | Lightweight syntax, channels typed in the language itself | Doesn’t come with built-in supervision or automatic restart |
| Async/await (JavaScript, Python asyncio) | Concurrent I/O on a single thread, like web servers | Simple mental model for sequential code with waits | A heavy synchronous block freezes the entire event loop |
Going Deeper
The idea that sets Erlang/OTP apart from the rest isn’t the actor itself, but the supervision tree: every actor is born under a supervisor, and that supervisor decides what to do if the actor fails (restart it alone, restart all its siblings, or propagate the failure upward). This philosophy is known as “let it crash”: instead of defending every line of code with defensive validations, the process is allowed to die cleanly, trusting the supervisor to bring it back with a fresh state.
flowchart TD
A["Root supervisor"] --> B["Connection supervisor"]
A --> C["Worker supervisor"]
B --> D["Actor: connection 1"]
B --> E["Actor: connection 2"]
C --> F["Actor: worker 1"]
C --> G["Actor: worker 2"]
To confirm live that a supervisor actually restarted a process, you can open the observation GUI that ships with the BEAM or query the supervisor’s child count:
iex> :observer.start()
iex> Supervisor.count_children(PoolWorkers.Supervisor)
#=> %{active: 4, specs: 4, supervisors: 0, workers: 4}
Another advanced concept is the location transparency mentioned earlier: in a multi-node cluster, an actor can migrate to another machine or be replicated, and the code that sends it messages doesn’t need to know where it physically lives. This is what allows Orleans to relocate a “grain” to another server without the rest of the system noticing the difference.
💭 Key point: “Let it crash” doesn’t mean ignoring errors: it means the supervisor, not the actor itself, is responsible for deciding what to do when something fails.
📖 Summary on Telegram: View summary
Your next step: install Erlang and Elixir, copy the Contador module from this guide into a project with mix new, and make it fail on purpose by sending it an invalid message to see how it reacts without a supervisor before adding one.
Frequently Asked Questions
Is the Actor Model the Same as Operating System Threads?
No. An actor in Erlang/OTP is a lightweight process of the BEAM virtual machine, not an operating system thread: you can run hundreds of thousands of actors on a single machine because each one uses very little memory.
What’s the Difference Between GenServer.cast and GenServer.call?
cast sends an asynchronous message and doesn’t wait for a response; call sends a message and blocks the caller until it receives a response or the timeout expires.
Can the Actor Model Be Used Outside Erlang and Elixir?
Yes: Akka implements it in Scala and Java on the JVM, and Microsoft Orleans implements it in .NET, used in the backend of Xbox Live.
What Happens if an Actor Receives Messages Faster Than It Can Process Them?
Its mailbox grows without limit by default and can exhaust the system’s memory; you need to measure the queue length and apply backpressure or spread the work across more actors.
Does the Actor Model Eliminate All Concurrency Bugs?
It doesn’t eliminate logic bugs or deadlocks if synchronous calls between actors are overused, but it does eliminate by design race conditions over shared memory, because that shared memory doesn’t exist.
How Do You Debug an Actor System in Production?
With :observer.start() in development to see processes and mailboxes live, and with structured logs by process identifier in production, since there’s no single stack trace shared between actors.
References
- Wikipedia: Actor model: history and formal definition of the model, formulated by Carl Hewitt in 1973.
- Wikipedia: Erlang (programming language): the language’s origin at Ericsson and its relationship to the actor model.
- Erlang.org: official documentation for Erlang/OTP, the most influential implementation of the model.
- HexDocs: GenServer: reference for the module used in this guide’s code examples.
- GitHub: dotnet/orleans: repository for the distributed actor framework used in the backend of Xbox Live.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Imagen destacada: Foto de Clint Adair en Unsplash
0 Comments