Elixir: The Process Model
spawn/send/receive; later lessons build
Task, Agent, and GenServer on top.
The Actor Model on the BEAM
Each process owns its state completely. Other processes cannot read it — they can only send
messages to its mailbox, which the process consumes one message at a time in receive.
State therefore never needs locking: the mailbox is the serialization point.
# A minimal echo server with raw primitives.
defmodule Echo do
def loop do
receive do
{:ping, caller} ->
send(caller, :pong) # reply straight to the sender's mailbox
loop() # tail-call: keep waiting forever
:stop -> :ok # any other clause ends the process
end
end
end
pid = spawn(Echo, :loop, []) # starts running immediately
send(pid, {:ping, self()})
receive do # blocks THIS process until a reply arrives
:pong -> IO.puts("got pong")
after
1_000 -> IO.puts("timeout") # `after` gives receive a deadline
end
Processes Are Cheap
# Spawn 100k processes and measure — try this in iex.
:timer.tc(fn ->
Enum.each(1..100_000, fn _ -> spawn(fn -> :ok end) end)
end)
# Typically well under a second; each process starts with ~2.6 KB heap.
Process.info(self(), :memory) # inspect any process's footprint
The mapping to architecture is direct: one process per connection, per upload, per game room, per job. Isolation means one overloaded connection cannot corrupt another — and if one crashes, only that one is affected.
Links and Monitors
Two mechanisms connect process lifecycles. A link is bidirectional: when one dies, the other dies too (unless trapping exits). A monitor is one-directional and non-fatal: you get a message when the target dies. Links build supervision; monitors build request/response patterns.
# Links: the child's crash kills the parent too — by design.
child = spawn_link(fn -> raise "boom" end) # parent dies right after
# Monitors: observe without dying.
ref = Process.monitor(child)
send(child, :crash_please)
receive do
{:DOWN, ^ref, :process, ^child, reason} -> # ^ref pins the monitor
IO.puts("child down: #{inspect(reason)}")
end
# spawn_link inside a start pattern is so common it has a wrapper:
Task.start_link(fn -> work() end) # see the Tasks & Agents lesson
# Trapping exits turns death into a MESSAGE — the basis of supervisors.
defmodule Guardian do
def loop do
process_flag(:trap_exit, true) # exit signals become messages
receive do
{:EXIT, from, reason} ->
IO.puts("#{inspect(from)} exited: #{reason}")
loop()
end
end
end
Naming Processes
Pids are opaque and change on restart — code that stores them everywhere breaks. Names give stable
handles: a local name (:explorer_cache) or a Registry for names that must
scale to many instances of the same kind of process.
pid = spawn(Echo, :loop, [])
Process.register(pid, :echo) # local, one process per name
send(:echo, {:ping, self()}) # messages go to the name
# Registry: dynamic names (one process per user, session, game...).
{:ok, _} = Registry.start_link(keys: :unique, name: MyApp.Registry)
Registry.register(MyApp.Registry, "session-42", :ok)
Registry.lookup(MyApp.Registry, "session-42") #=> [{pid, :ok}]
The Process Dictionary (and Why to Avoid It)
Each process has a key/value store that dies with it. It works, it is fast, and it is a trap:
values in it are invisible to pattern matching, break :observer introspection, and
hide state flow. The one accepted use is storing process-scoped metadata (telemetry contexts,
logger metadata). For real state, use the process's own recursion arguments — the next lessons
show how.
Practice
- Extend
Echoto keep a count of pings and answer{:count, caller}messages. - Link two processes, crash one, and observe the other's death in
iex; then trap exits and observe again. - Monitor a process and prove your receiving process stays alive after the target dies.
Next: Tasks & Agents