Elixir: The Process Model

Everything from here on builds on one primitive: the BEAM process. Not an OS thread — a runtime construct with its own heap, its own mailbox, and a spawn cost measured in microseconds. This lesson works with raw 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 process with a mailbox receives messages one at a time, updating its private state, and replies to senders
Fig. 1 — The mailbox serializes access to state: messages queue up and the process handles one at a time.
# 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.

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

  1. Extend Echo to keep a count of pings and answer {:count, caller} messages.
  2. Link two processes, crash one, and observe the other's death in iex; then trap exits and observe again.
  3. Monitor a process and prove your receiving process stays alive after the target dies.

Next: Tasks & Agents