Elixir Fundamentals: Functional Programming and OTP · Processes, OTP and Fault Tolerance

Processes and Message Passing

Spawn lightweight processes, send and receive messages, use Task for concurrent work, and see why the BEAM can run hundreds of thousands of isolated processes with no shared memory.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A price checker that queries several (simulated) suppliers at the same time in separate processes, collects replies with receive, applies a timeout, and then does the same with Task.async_stream.

The idea

A BEAM process is not an OS thread. It's tiny (a few kilobytes), starts in microseconds, has its own memory, and communicates only by messages. A single machine can run millions of them; the scheduler spreads them across all CPU cores.

  • spawn(fn -> ... end) starts a process and returns its pid.
  • send(pid, message) puts a message in that process's mailbox.
  • receive do pattern -> ... after ms -> ... end takes the first matching message, or times out.
  • self() is the current process's pid (include it so the other side can reply).
  • Task wraps the common patterns: Task.async/await for one concurrent job, Task.async_stream to process a collection concurrently with a limit on how many run at once.

Because processes share nothing, a crash in one can't corrupt another's memory, which is the foundation of Elixir's fault tolerance.

The code

Spawn, send, receive with timeout, and Task.async_stream
defmodule Supplier do
  # Simulated suppliers: name -> {price, response time in ms}
  @catalog %{"acme" => {12.40, 40}, "globex" => {11.90, 80}, "initech" => {12.10, 500}}

  def quote(name) do
    {price, delay} = Map.fetch!(@catalog, name)
    Process.sleep(delay)
    price
  end

  def names, do: Map.keys(@catalog) |> Enum.sort()
end

parent = self()

for name <- Supplier.names() do
  spawn(fn -> send(parent, {:quote, name, Supplier.quote(name)}) end)
end

collect = fn collect, acc ->
  receive do
    {:quote, name, price} -> collect.(collect, [{name, price} | acc])
  after
    200 -> acc   # stop waiting: slow suppliers miss the deadline
  end
end

quotes = collect.(collect, []) |> Enum.sort()
IO.inspect(quotes, label: "quotes within 200 ms")
{best, price} = Enum.min_by(quotes, &elem(&1, 1))
IO.puts("best: #{best} at #{price}")

results =
  Supplier.names()
  |> Task.async_stream(&{&1, Supplier.quote(&1)}, max_concurrency: 3, timeout: 200, on_timeout: :kill_task)
  |> Enum.map(fn
    {:ok, {name, price}} -> "#{name}: #{price}"
    {:exit, :timeout} -> "a supplier timed out"
  end)

IO.inspect(results, label: "async_stream")

pids = for _ <- 1..10_000, do: spawn(fn -> receive do: (:stop -> :ok) end)
IO.puts("started #{length(pids)} processes; all alive: #{Enum.all?(pids, &Process.alive?/1)}")
Enum.each(pids, &send(&1, :stop))

Output

quotes within 200 ms: [{"acme", 12.4}, {"globex", 11.9}]
best: globex at 11.9
async_stream: ["acme: 12.4", "globex: 11.9", "a supplier timed out"]
started 10000 processes; all alive: true

Run this on your computer — see “Get Started” for setup.

The 10,000 processes start in a few milliseconds. That's why Elixir apps happily use one process per user connection, per chat room or per background job.

Common mistakes

  • Forgetting after in receive, so a missing reply blocks forever.
  • Not including self() in a request message, so the other process can't reply.
  • Unbounded mailboxes. If messages arrive faster than they're processed, memory grows; apply back-pressure or limits.
  • Spawning without supervision for long-lived work; use supervised Tasks or GenServers.

Key points

  • Processes are lightweight, isolated and communicate by messages.
  • spawn, send, receive ... after (always add a timeout).
  • Task.async/await and Task.async_stream for concurrent work with limits.
  • Isolation means one crash can't corrupt another process's state.

Further reading: Processes, Task.

Practice questions

How do Elixir processes differ from operating system threads?

Answer

They're managed by the BEAM, not the OS: very lightweight (kilobytes, microseconds to start), each with its own isolated heap, communicating only by message passing. The VM schedules millions of them across CPU cores.

Why should a receive block usually have an after clause?

Answer

Without a timeout, the process waits forever if the expected message never arrives (for example because the sender crashed), so it can hang indefinitely.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?