Concurrency and OTP
Concurrency Fundamentals
Section titled “Concurrency Fundamentals”The Actor Model
Section titled “The Actor Model”Elixir’s concurrency is built on the actor model, where concurrent computation is performed by lightweight, isolated processes that communicate through asynchronous message passing. Each process has its own heap, stack, and mailbox. There is no shared memory and no locks.
Key properties of Elixir processes:
- Lightweight: Each process uses approximately 2KB of memory initially
- Fast: Process creation and message passing are measured in microseconds
- Scalable: A single BEAM node can run millions of concurrent processes
- Isolated: Processes share no memory; communication is only via messages
- Preemptive: The scheduler can preempt any process (no runaway processes can starve others)
The spawn/1 function creates a new process:
## spawn/1 - takes a zero-arity functionpid = spawn(fn -> IO.puts("Hello from process") end)#PID<0.123.0>
## spawn/3 - takes module, function name, and arguments listpid = spawn(SomeModule, :some_function, [arg1, arg2])
# The spawned process runs independentlyspawn(fn -> Process.sleep(1000) IO.puts("Delayed message")end)# The caller continues immediatelyIO.puts("This prints first")Message Passing with send and receive
Section titled “Message Passing with send and receive”Processes communicate by sending messages to PIDs. Messages are placed in the recipient’s mailbox and matched with receive blocks:
defmodule Messenger do def start do pid = spawn(__MODULE__, :loop, []) pid end
def loop do receive do {:say, message} -> IO.puts("Message: #{message}") loop()
{:ping, from} -> send(from, :pong) loop()
:stop -> :ok end endend
# Usagepid = Messenger.start()send(pid, {:say, "Hello from the outside"})send(pid, {:ping, self()})receive do :pong -> IO.puts("Got pong!")endsend(pid, :stop)receive with after (Timeout)
Section titled “receive with after (Timeout)”receive do {:result, value} -> {:ok, value}
{:error, reason} -> {:error, reason}after 5000 -> {:error, :timeout}endThe after clause fires if no matching message arrives within the specified milliseconds. Use 0 for a non-blocking receive that checks for available messages without waiting:
# Non-blocking receivereceive do message -> handle(message)after 0 -> :no_messagesendThe Process Module
Section titled “The Process Module”The Process module provides functions for inspecting and controlling processes:
# Current processpid = self()
# Process informationProcess.info(pid)# Returns map with :status, :memory, :message_queue_len, :reductions, etc.
Process.info(pid, :status)# :running | :waiting | :runnable | :exiting
Process.info(pid, [:memory, :message_queue_len])# [memory: 4186, message_queue_len: 0]
# Register a process with a nameProcess.register(pid, :my_server)Process.whereis(:my_server)# #PID<0.123.0>
# Check if process is aliveProcess.alive?(pid)# true or false
# Send a messageProcess.send(pid, :hello, [])# Third argument is options (empty list = no options)
# Send after a delayProcess.send_after(pid, :check, 5000)# Returns a timer reference for cancellation
# Kill a process (non-violent)Process.exit(pid, :shutdown)
# Kill a process (violent - sends exit signal)Process.exit(pid, :kill)
# Get process dictionary (process-local storage)Process.put(:key, "value")Process.get(:key)# "value"Process.get(:missing, :default)# :defaultProcess.delete(:key)
# Spawn with linkspawn_link(fn -> ... end)
# Spawn with monitorspawn_monitor(fn -> ... end)Links and Monitors
Section titled “Links and Monitors”Linking Processes
Section titled “Linking Processes”spawn_link creates a bidirectional link between two processes. If one process exits abnormally, the exit signal propagates to the linked process:
spawn_link(fn -> raise "oops" # This exits with reason :error, propagating to the callerend)# The caller also crashes with the same reason
# Trapping exits to handle linked process failuresProcess.flag(:trap_exit, true)
spawn_link(fn -> raise "oops" end)
receive do {:EXIT, _pid, reason} -> IO.puts("Linked process exited: #{inspect(reason)}")endMonitoring Processes
Section titled “Monitoring Processes”spawn_monitor creates a one-way monitor. When the monitored process exits, a {:DOWN, ...} message is sent to the monitoring process. The monitoring process does NOT crash:
{pid, ref} = spawn_monitor(fn -> Process.sleep(100) raise "crash"end)
receive do {:DOWN, ^ref, :process, ^pid, reason} -> IO.puts("Process #{inspect(pid)} exited: #{inspect(reason)}") # {:DOWN, #Reference<...>, :process, #PID<...>, {:error, ...}}endspawn vs spawn_link vs spawn_monitor
Section titled “spawn vs spawn_link vs spawn_monitor”| Feature | spawn | spawn_link | spawn_monitor |
|---|---|---|---|
| Bidirectional link | No | Yes | No |
| One-way monitor | No | No | Yes |
| Exit propagation | None | Yes (both directions) | No (message only) |
| Process isolation | Yes | No | Yes |
| Use case | Fire-and-forget | Paired processes | Observe without coupling |
GenServer
Section titled “GenServer”GenServer (Generic Server) is the primary OTP abstraction for implementing stateful, concurrent processes. It provides a standard client/server API with synchronous (call) and asynchronous (cast) messaging.
Basic GenServer
Section titled “Basic GenServer”defmodule Stack do use GenServer
# Client API
def start_link(initial) do GenServer.start_link(__MODULE__, initial, name: __MODULE__) end
def push(item) do GenServer.call(__MODULE__, {:push, item}) end
def pop do GenServer.call(__MODULE__, :pop) end
def peek do GenServer.call(__MODULE__, :peek) end
# Server Callbacks
@impl true def init(initial) do {:ok, initial} end
@impl true def handle_call({:push, item}, _from, state) do {:reply, :ok, [item | state]} end
@impl true def handle_call(:pop, _from, []) do {:reply, {:error, :empty}, []} end
@impl true def handle_call(:pop, _from, [head | tail]) do {:reply, {:ok, head}, tail} end
@impl true def handle_call(:peek, _from, [head | _] = state) do {:reply, {:ok, head}, state} end
@impl true def handle_call(:peek, _from, []) do {:reply, {:error, :empty}, []} end
@impl true def handle_info(:timeout, state) do # Handle :timeout messages (not related to handle_call timeouts) {:noreply, state} end
@impl true def terminate(_reason, _state) do :ok endendGenServer Callbacks
Section titled “GenServer Callbacks”| Callback | Signature | Purpose |
|---|---|---|
init/1 | (args) -> {:ok, state} or {:stop, reason} | Initialize server state |
handle_call/3 | (request, from, state) -> {:reply, reply, state} | Handle synchronous calls |
handle_cast/2 | (request, state) -> {:noreply, state} | Handle asynchronous messages |
handle_info/2 | (msg, state) -> {:noreply, state} | Handle unexpected messages |
terminate/2 | (reason, state) -> :ok | Cleanup on shutdown |
code_change/3 | (old_vsn, state, extra) -> {:ok, new_state} | Hot code upgrade |
call vs cast
Section titled “call vs cast”# call - synchronous (blocks until reply){:ok, item} = GenServer.call(server_pid, :pop)# The caller waits for {:reply, reply, new_state}
# cast - asynchronous (returns :ok immediately)GenServer.cast(server_pid, {:push, 42})# Returns :ok immediately, does not wait for processing
# call with timeoutGenServer.call(server_pid, :pop, 5000)# Raises :timeout if no reply within 5000ms
# Using GenServer in a moduledefmodule Counter do use GenServer
def start_link(opts \\ []) do name = Keyword.get(opts, :name, __MODULE__) GenServer.start_link(__MODULE__, 0, name: name) end
def increment(pid \\ __MODULE__) do GenServer.cast(pid, :increment) end
def get(pid \\ __MODULE__) do GenServer.call(pid, :get) end
@impl true def init(count), do: {:ok, count}
@impl true def handle_cast(:increment, count), do: {:noreply, count + 1}
@impl true def handle_call(:get, _from, count), do: {:reply, count, count}endhandle_info for Unexpected Messages
Section titled “handle_info for Unexpected Messages”Messages that are not call or cast (such as direct send/2 messages) are handled by handle_info:
@impl truedef handle_info({:schedule, task}, state) do new_state = execute_task(task, state) {:noreply, new_state}end
@impl truedef handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do {:noreply, cleanup(state)}end
# Catch-all for unknown messages (prevents log spam)@impl truedef handle_info(_msg, state) do {:noreply, state}endSupervisors
Section titled “Supervisors”Supervisors are processes that monitor their child processes and restart them when they crash. They form supervision trees that provide fault tolerance at the system level.
Basic Supervisor
Section titled “Basic Supervisor”defmodule MyApp.Application do use Application
@impl true def start(_type, _args) do children = [ {MyApp.Repo, []}, {MyApp.Cache, []}, {MyApp.Worker, []} ]
opts = [strategy: :one_for_one, name: MyApp.Supervisor] Supervisor.start_link(children, opts) endendChild Specifications
Section titled “Child Specifications”Each child process has a specification that defines how it should be started and restarted:
# Using a module tuple (recommended){MyApp.Worker, [arg1: "value"]}
# Using a full child spec map%{ id: MyApp.Worker, start: {MyApp.Worker, :start_link, [[]]}, restart: :permanent, shutdown: 5000, type: :worker, modules: [MyApp.Worker]}Child spec fields:
| Field | Values | Default | Description |
|---|---|---|---|
id | any | Module name | Unique identifier for the child |
start | {m, f, a} | Required | Function to start the child |
restart | :permanent, :temporary, :transient | :permanent | Restart policy |
shutdown | integer, :brutal_kill, :infinity | 5000 | Shutdown timeout (ms) |
type | :worker, :supervisor | :worker | Whether child is a supervisor |
modules | [module], :dynamic | From start | Modules for hot upgrades |
Restart Strategies
Section titled “Restart Strategies”| Strategy | Description | Use Case |
|---|---|---|
:one_for_one | Restart only the crashed child | Independent workers |
:one_for_all | Restart ALL children when one crashes | Tightly coupled processes |
:rest_for_one | Restart the crashed child and all children started after it | Sequential dependencies |
# :one_for_one - each child is independentSupervisor.start_link(children, strategy: :one_for_one)
# :one_for_all - one crash restarts everythingSupervisor.start_link(children, strategy: :one_for_all)
# :rest_for_one - crash restarts this child and all subsequent children# Children started AFTER the crashed one are also restartedSupervisor.start_link(children, strategy: :rest_for_one)
# Restart policies# :permanent - always restart (default)# :temporary - never restart# :transient - restart only on abnormal exitStrategy Comparison
Section titled “Strategy Comparison”| Scenario | Recommended Strategy | Reason |
|---|---|---|
| Web server workers | :one_for_one | Workers are independent |
| DB connection pool + repo | :one_for_one | Each connection is independent |
| Cache + CacheWarmer | :rest_for_one | CacheWarmer depends on Cache |
| Socket manager + handlers | :rest_for_one | Handlers depend on manager |
| All services tightly coupled | :one_for_all | Cannot function partially |
Nested Supervisors
Section titled “Nested Supervisors”defmodule MyApp.Application do use Application
@impl true def start(_type, _args) do children = [ MyApp.Repo,
# Nested supervisor for web layer {MyApp.Web.Supervisor, []},
# Nested supervisor for background jobs {MyApp.Jobs.Supervisor, []},
# Supervisor for cache layer {MyApp.Cache.Supervisor, []} ]
Supervisor.start_link(children, strategy: :one_for_one) endend
defmodule MyApp.Web.Supervisor do use Supervisor
def start_link(init_arg) do Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) end
@impl true def init(_init_arg) do children = [ {MyApp.Web.Endpoint, []}, {MyApp.Web.SessionStore, []}, {MyApp.Web.RateLimiter, []} ]
Supervisor.init(children, strategy: :one_for_one) endendDynamicSupervisor
Section titled “DynamicSupervisor”The DynamicSupervisor is used when the number of children is not known at compile time:
defmodule MyApp.ConnectionSupervisor do use DynamicSupervisor
def start_link(init_arg) do DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) end
@impl true def init(_init_arg) do DynamicSupervisor.init(strategy: :one_for_one) end
def start_connection(config) do spec = {MyApp.Connection, config} DynamicSupervisor.start_child(__MODULE__, spec) end
def stop_connection(pid) do DynamicSupervisor.terminate_child(__MODULE__, pid) end
def list_connections do DynamicSupervisor.which_children(__MODULE__) endendApplication Module
Section titled “Application Module”Every OTP application has an application callback module that starts the supervision tree:
defmodule MyApp.Application do use Application
@impl true def start(_type, _args) do children = [ # Primary DB MyApp.Repo,
# PubSub for real-time features {Phoenix.PubSub, name: MyApp.PubSub},
# Start the Endpoint (web server) MyAppWeb.Endpoint,
# Background job processor {Oban, oban_config()} ]
opts = [strategy: :one_for_one, name: MyApp.Supervisor] Supervisor.start_link(children, opts) end
defp oban_config do [ repo: MyApp.Repo, plugins: [Oban.Plugins.Pruner], queues: [default: 10, mailers: 5] ] endendThe mix.exs file registers the application:
def application do [ extra_applications: [:logger], mod: {MyApp.Application, []} ]endTasks are fire-and-forget processes for executing concurrent work. They are built on top of GenServer.
Task.async/await
Section titled “Task.async/await”# Run a task and wait for the resultresult = Task.async(fn -> # expensive computation compute_something()end)|> Task.await()# Blocks until the task completes, default timeout 5000ms
# Multiple concurrent taskstasks = [ Task.async(fn -> fetch_user(1) end), Task.async(fn -> fetch_user(2) end), Task.async(fn -> fetch_user(3) end)]
results = Task.await_many(tasks, 10_000)# Returns list of results in the same order
# Task.yield (non-blocking)task = Task.async(fn -> long_running() end)
case Task.yield(task, 1000) do {:ok, result} -> IO.puts("Got: #{result}") nil -> IO.puts("Still running...") {:exit, reason} -> IO.puts("Crashed: #{inspect(reason)}")end
# Task.start (fire and forget, no result retrieval)Task.start(fn -> Process.sleep(1000) IO.puts("Background work done")end)Task.async_stream
Section titled “Task.async_stream”For processing collections with bounded concurrency:
1..100|> Task.async_stream(fn n -> process_item(n)end, max_concurrency: 10, timeout: 30_000)|> Enum.map(fn {:ok, result} -> result end)Agent is a simple abstraction for maintaining state that can be read and written by multiple processes:
defmodule AppConfig do def start_link(initial) do Agent.start_link(fn -> initial end, name: __MODULE__) end
def get(key) do Agent.get(__MODULE__, fn state -> Map.get(state, key) end) end
def put(key, value) do Agent.update(__MODULE__, fn state -> Map.put(state, key, value) end) end
def all do Agent.get(__MODULE__, & &1) endend
# Usage{:ok, _pid} = AppConfig.start_link(%{theme: "dark", lang: "en"})AppConfig.get(:theme)# "dark"AppConfig.put(:theme, "light")Agent is built on GenServer and provides a simpler API for state management. Use GenServer directly when you need more control over message handling.
Registry
Section titled “Registry”Registry provides a way to register processes and look them up by key:
defmodule MyApp.Application do use Application
@impl true def start(_type, _args) do children = [ {Registry, keys: :unique, name: MyApp.Registry} ]
Supervisor.start_link(children, strategy: :one_for_one) endend
# Register a processRegistry.register(MyApp.Registry, "user:123", %{role: :admin})
# LookupRegistry.lookup(MyApp.Registry, "user:123")[{#PID<0.123.0>, %{role: :admin}}]
# Dispatch to a registered processRegistry.dispatch(MyApp.Registry, "user:123", fn [{pid, meta}] -> send(pid, {:update, meta})end)
# Via names (using Registry as a process name)# Instead of name: __MODULE__# Use name: {:via, Registry, {MyApp.Registry, "user:123"}}GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {MyApp.Registry, key}})Registry supports both :unique keys (one process per key) and :duplicate keys (multiple processes per key, useful for pub/sub patterns).
”Let It Crash” Philosophy
Section titled “”Let It Crash” Philosophy”Elixir/Erlang’s approach to fault tolerance is fundamentally different from most languages. Instead of trying to prevent all errors, the philosophy is:
- Write defensive code at the boundary: Validate inputs at the edges of your system
- Let processes crash: Let internal errors propagate and crash the process
- Supervise and restart: Supervisors detect crashes and restart processes with clean state
- Isolate failures: Each process is isolated, so one crash does not cascade uncontrolled
# Anti-pattern: catching everythingdef process(data) do try do risky_operation(data) rescue _ -> :error # Swallowing errors hides bugs endend
# Idiomatic pattern: let it crash and supervisedef start_link do GenServer.start_link(__MODULE__, [], name: __MODULE__)end
# Supervisor restarts this if it crashes# Supervisor specification:# {MyModule, restart: :permanent}This philosophy works because:
- Processes are cheap to create and destroy
- Supervisors can restart processes quickly with clean state
- Crashes are isolated and do not corrupt shared state
- The system as a whole continues to function even if individual components fail
Practical Example: Building a Task Manager
Section titled “Practical Example: Building a Task Manager”defmodule TaskManager do use GenServer
def start_link(opts \\ []) do name = Keyword.get(opts, :name, __MODULE__) GenServer.start_link(__MODULE__, %{}, name: name) end
def add_task(manager \\ __MODULE__, description) do GenServer.call(manager, {:add, description}) end
def complete_task(manager \\ __MODULE__, id) do GenServer.call(manager, {:complete, id}) end
def list_tasks(manager \\ __MODULE__) do GenServer.call(manager, :list) end
@impl true def init(_), do: {:ok, %{tasks: %{}, next_id: 1}}
@impl true def handle_call({:add, description}, _from, state) do id = state.next_id task = %{id: id, description: description, status: :pending} new_tasks = Map.put(state.tasks, id, task) {:reply, {:ok, id}, %{state | tasks: new_tasks, next_id: id + 1}} end
@impl true def handle_call({:complete, id}, _from, state) do case Map.get(state.tasks, id) do nil -> {:reply, {:error, :not_found}, state}
task -> updated = %{task | status: :completed} new_tasks = Map.put(state.tasks, id, updated) {:reply, :ok, %{state | tasks: new_tasks}} end end
@impl true def handle_call(:list, _from, state) do tasks = Map.values(state.tasks) {:reply, tasks, state} endend
# Supervisor for the task managerdefmodule TaskManager.Supervisor do use Supervisor
def start_link(opts) do Supervisor.start_link(__MODULE__, opts, name: __MODULE__) end
@impl true def init(opts) do children = [ {TaskManager, opts} ]
Supervisor.init(children, strategy: :one_for_one) endendIntuition
Section titled “Intuition”OTP is a city’s emergency management system: GenServer is a government office with a reception desk (call for synchronous requests) and a suggestion box (cast for async messages). Supervisors are the emergency management agency — they watch over offices and rebuild them from scratch if they crash. The restart strategy is the emergency plan: :one_for_one fixes only the broken office, :one_for_all rebuilds the entire department, :rest_for_one rebuilds the broken office and everything that was set up after it.
Why it matters: OTP transforms concurrency from “manage shared state carefully” to “let things crash and restart from known-good state.” This is why Erlang/Elixir systems can run for years without downtime — failures are expected, isolated, and automatically recovered.
The key insight: Supervision trees turn failure from a catastrophe into a routine event — each process is disposable, and the system’s reliability comes from the restart strategy, not from preventing crashes.
Summary
Section titled “Summary”Elixir’s concurrency model is built on lightweight processes, message passing, and the OTP framework:
spawn/spawn_link/spawn_monitorcreate processes with different linking behaviorssend/receiveenable asynchronous message passing- GenServer provides the standard pattern for stateful server processes
- Supervisors manage process lifecycles with configurable restart strategies
- The
:one_for_onestrategy restarts only the crashed child - The
:one_for_allstrategy restarts all children when one crashes - The
:rest_for_onestrategy restarts the crashed child and all subsequent children - DynamicSupervisor handles dynamically spawned children
- Tasks provide async/await patterns for concurrent computations
- Agents simplify state management for shared state
- The “let it crash” philosophy relies on supervision trees for fault tolerance
Common Mistakes
Section titled “Common Mistakes”- Storing mutable state in the process dictionary: The process dictionary bypasses the standard message-passing model and makes code hard to test and debug. Use GenServer state instead for structured, supervised state management.
- Using
:one_for_allwhen:one_for_onesuffices::one_for_allrestarts every child when one crashes, which is wasteful if children are independent. Only use:one_for_allwhen children depend on each other’s state. - Ignoring process linking and monitoring:
spawncreates an unlinked process whose death is silently ignored. Usespawn_linkorspawn_monitorso the parent knows when a child crashes, enabling proper supervision. - Calling
GenServer.callfrom inside a GenServer callback: This can cause deadlocks if the target GenServer is also calling back. UseGenServer.castor schedule the call withsendto avoid circular dependencies.