The Actor Model: processes and message passing
How the BEAM turns the Actor Model into a runtime: millions of share-nothing processes that spawn, send, and selectively receive.
Most languages bolt concurrency on top of threads, locks, and shared memory. The BEAM does the opposite: concurrency is the runtime. Every unit of work is a process - a lightweight, isolated actor with its own heap and its own mailbox - and processes never touch each other's memory. They cooperate by one means only: sending immutable messages.
This is Carl Hewitt's Actor Model, made real and made cheap. An actor can do three things in response to a message: create more actors, send messages to actors it knows, and decide how it will handle the next message. On the BEAM those three capabilities are spelled spawn, send (!), and receive. Everything else - OTP, supervision trees, GenServers, distribution across nodes - is built on that trio.
This article walks the primitives in all five Eubeamgua languages. Erlang, Elixir, and LFE expose them raw; Gleam wraps them in a statically typed envelope; Luerl, being Lua, has no processes of its own and must borrow them from its host.
Lightweight processes, not OS threads
A BEAM process is not an operating-system thread. It is a green process scheduled by the VM, starting at roughly 300 words of memory (a couple of kilobytes on a 64-bit system), and you can run millions of them on a single machine. Spawning one takes microseconds; an OS thread costs orders of magnitude more in both memory and creation time.
The scheduler is preemptive and fair. Each process is given a budget of reductions (roughly, function calls); when it exhausts that budget it is suspended and another process runs. This means a single runaway process cannot starve the rest - there are no cooperative-yield points to forget, and a tight loop will still be interrupted. The BEAM runs one scheduler per CPU core by default and migrates processes between them to balance load.
Crucially, each process has its own heap. Garbage collection is per-process, so collecting one actor's garbage never pauses the whole system. When a process dies, its heap is reclaimed wholesale. This is what makes "let it crash" practical: a process is a cheap, disposable unit of failure.
No shared memory: isolation by construction
In a threads-and-locks world, two threads share a heap and you protect it with mutexes. Forget a lock and you get a data race; take locks in the wrong order and you get a deadlock. The BEAM removes the entire category of bug by removing the shared heap.
Processes share nothing. When you send a message, the data is copied from the sender's heap into the receiver's heap (large binaries are the notable exception - they live in a shared, reference-counted off-heap area and are passed by reference). Because everything on the BEAM is immutable, copying is safe: the receiver cannot mutate what the sender still holds, and neither can observe a half-written value. There is nothing to lock because there is nothing to share.
The trade-off is explicit and honest: message passing has a cost (the copy), and you reason about it as such. In exchange you get isolation strong enough that a crash in one process cannot corrupt another's state.
spawn / send / receive
The three primitives form a complete vocabulary for concurrency.
spawnstarts a new process running a function and immediately returns its PID (process identifier). The new process runs concurrently; the spawner does not wait.send(the!operator in Erlang and LFE) delivers a message to a PID's mailbox. It is asynchronous and non-blocking: send always "succeeds" locally and returns immediately, even if the target is dead or the mailbox is enormous. There is no back-pressure built in.receivetakes the next matching message out of the current process's mailbox, blocking until one arrives.
Here is the canonical example: a parent spawns one worker per number 1..5, each worker computes its square and sends it back, and the parent receives the five replies and sums them to 55.
-module(squares).
-export([run/0]).
run() ->
Self = self(),
%% spawn one process per number; each sends {N, N*N} back to us.
lists:foreach(
fun(N) ->
spawn(fun() -> Self ! {N, N * N} end)
end,
lists:seq(1, 5)),
collect(5, 0).
%% receive exactly Count messages, summing the squares.
collect(0, Sum) -> Sum;
collect(Count, Sum) ->
receive
{_N, Square} -> collect(Count - 1, Sum + Square)
end.
%% squares:run() =:= 55
self() returns the current process's PID, captured in the closure so each worker knows where to reply. spawn/1 launches the worker; Self ! {N, N * N} sends a tuple; collect/2 loops receive five times to drain the mailbox. Note that the workers run concurrently and may finish in any order - the result is the same because addition commutes and we count messages, not workers.
Elixir gives the same trio friendlier names (send/2, receive) and the pipe operator, but the semantics are identical:
defmodule Squares do
def run do
parent = self()
# spawn one process per number; each sends {n, n*n} back.
1..5
|> Enum.each(fn n ->
spawn(fn -> send(parent, {n, n * n}) end)
end)
# collect 5 replies and sum the squares.
Enum.reduce(1..5, 0, fn _i, sum ->
receive do
{_n, square} -> sum + square
end
end)
end
end
# Squares.run() == 55
# Real code usually reaches for Task.async_stream/2 instead of raw spawn/send.
LFE expresses the very same primitives as S-expressions. spawn takes a lambda, (! pid msg) is the send operator, and receive pattern-matches the incoming tuple:
(defmodule squares
(export (run 0)))
(defun run ()
(let ((self (self)))
;; spawn one process per number; each sends (tuple n (* n n)) back.
(lists:foreach
(lambda (n)
(spawn (lambda () (! self (tuple n (* n n))))))
(lists:seq 1 5))
(collect 5 0)))
;; receive Count messages, summing the squares.
(defun collect
((0 sum) sum)
((count sum)
(receive
((tuple _n square) (collect (- count 1) (+ sum square))))))
;; (squares:run) => 55
Erlang, Elixir, and LFE are three syntaxes over one runtime: the spawn/!/receive here compile to the same BEAM operations.
Mailboxes and selective receive
Every process owns a private FIFO queue - its mailbox. send appends to the tail; receive scans from the head. The subtlety, and one of the BEAM's signature features, is selective receive: receive does not just take the first message, it takes the first message that matches one of its patterns. Messages that match no clause are left in the mailbox, in order, and considered again on the next receive.
This lets a process pull out the message it cares about now while leaving others for later - for example, prioritizing a control message over a flood of data:
%% Drain a priority {high, _} message before any normal {low, _} ones,
%% even if the low-priority messages arrived first.
priority_loop() ->
receive
{high, Msg} ->
handle(high, Msg),
priority_loop()
after 0 ->
%% no high-priority message waiting; fall back to anything.
receive
{_Prio, Msg} ->
handle(normal, Msg),
priority_loop()
end
end.
The same selective matching, in Elixir, where receive clauses look like a case:
def loop(state) do
receive do
{:set, key, value} ->
loop(Map.put(state, key, value))
{:get, key, from} ->
send(from, {:reply, Map.get(state, key)})
loop(state)
{:stop, from} ->
send(from, :stopped)
# no recursive call: the process ends, its mailbox and heap reclaimed.
end
end
This is, in miniature, exactly what a GenServer is: a receive loop carrying state, recursing with the next state on each message. "How to handle the next message" - Hewitt's third actor capability - is just the argument you pass to the next call of loop/1.
The cost of selective receive
Selective receive is powerful but not free. If a needed message is near the back of a large mailbox, receive scans every message ahead of it on each pass, which can degrade to O(n) per message - the classic "selective receive performance trap." The BEAM optimizes the common case with a receive mark: when it can prove (via a freshly created reference) that the wanted message cannot have arrived before a known point, it starts scanning from that mark instead of the head. This is precisely how OTP's request/reply does it - tagging each call with a unique make_ref() so the matching reply is found without rescanning the whole mailbox:
call(Pid, Request) ->
Ref = make_ref(), %% unique tag for this call
Pid ! {self(), Ref, Request},
receive
{Ref, Reply} -> Reply %% only OUR reply matches this Ref
after 5000 ->
exit(timeout)
end.
Timeouts with after
A bare receive blocks forever. The after clause adds a timeout in milliseconds; after 0 means "check the mailbox but never block," which is the idiom used for the priority drain above.
receive do
{:reply, value} -> {:ok, value}
after
1_000 -> {:error, :timeout}
end
Gleam: the Actor Model, statically typed
Gleam runs on the BEAM and uses the same processes and mailboxes - but Gleam is statically typed, and an untyped mailbox would be a hole in the type system. Its answer is the Subject: a typed handle to a mailbox that can only ever carry one message type. A Subject(Int) accepts and yields Ints and nothing else, checked at compile time. And because a blocking receive could fail, process.receive returns a Result with a mandatory timeout rather than blocking forever.
import gleam/erlang/process.{type Subject}
import gleam/int
import gleam/io
import gleam/list
pub fn run() -> Int {
// A Subject is a typed channel: it only ever carries Int here.
let inbox: Subject(Int) = process.new_subject()
// spawn one process per number; each sends back its square.
list.each([1, 2, 3, 4, 5], fn(n) {
process.spawn(fn() { process.send(inbox, n * n) })
})
// receive 5 typed messages and fold them into a sum.
list.fold([1, 2, 3, 4, 5], 0, fn(sum, _i) {
case process.receive(inbox, within: 1000) {
Ok(square) -> sum + square
Error(_) -> sum
}
})
}
pub fn main() {
io.println(int.to_string(run()))
// prints "55"
}
For real systems Gleam offers gleam/otp/actor, a typed wrapper over OTP's GenServer where a single message type (often a custom union) defines the actor's whole protocol - so adding a message variant the handler forgets becomes a compile error rather than a silently-ignored mailbox entry. The runtime model is unchanged; only the guarantees are stronger.
Luerl: Lua on the BEAM, without its own processes
Luerl is a Lua 5.x implementation that runs on the BEAM, but it is plain Lua - and plain Lua has no concept of a process, a mailbox, spawn, or message passing. Inside a single Luerl interpreter, "concurrency" is just sequential code:
-- Luerl runs Lua on the BEAM, but plain Lua has no processes,
-- no spawn, no mailbox, and no message passing of its own.
-- So "concurrency" here is just a sequential loop over the numbers:
local function squares()
local sum = 0
for n = 1, 5 do
sum = sum + n * n -- the work a BEAM worker would do, inline
end
return sum
end
print(squares()) --> 55
-- To get *real* concurrency you must cross back into the host:
-- the embedding Erlang/Elixir app spawns the processes and runs
-- a small Lua chunk inside each one via luerl:do/2, then collects
-- the results on the BEAM side. The parallelism lives in Erlang,
-- not in the Lua code.
The way to get genuine actors with Luerl is to embed it: the host Erlang or Elixir program spawns real BEAM processes and runs an independent Luerl interpreter (each interpreter is itself an immutable Lua state) inside each one, then collects results over normal BEAM message passing. The actor model is fully available - it just lives in the host, with Lua supplying the per-actor logic. This makes Luerl an excellent scripting surface for an actor system rather than an actor system in its own right.
How this scales: from primitives to OTP
You rarely write raw spawn/receive loops in production Erlang or Elixir. OTP packages the recurring patterns - a stateful receive loop becomes a GenServer, a process that turns trouble into a clean crash sits under a supervisor, and supervisors compose into supervision trees. But every one of those abstractions is, underneath, the same thing this article showed: isolated processes, private mailboxes, selective receive, and immutable messages copied between share-nothing heaps.
That uniformity is the BEAM's quiet superpower. The model that lets you sum five squares is the identical model that runs a telecom switch with nine nines of uptime or a chat server with two million connections per node. Learn spawn, send, and receive, and you have learned the whole machine.