The Problem: Ephemeral Process IDs
In distributed systems, processes are the fundamental units of concurrency. Elixir leverages the Erlang Virtual Machine (BEAM) for its robust concurrency model, where processes are lightweight and isolated. When building fault-tolerant systems, a common pattern involves supervisors restarting crashed worker processes. However, a critical limitation arises: each new incarnation of a worker process receives a brand new Process Identifier (PID). As demonstrated in the previous part of this series, a supervisor might restart a worker, and its PID changes from something like #PID<0.102.0> to #PID<0.105.0>. This PID instability is a significant hurdle when trying to maintain stable communication endpoints for services that need to remain accessible even after restarts.
PIDs are excellent for direct, short-lived communication. You can send a message, monitor a specific process instance, or establish links between two processes. But PIDs are not stable addresses. If a process dies and a new one replaces it, any other process holding the old PID will be trying to communicate with a ghost. This makes PIDs unsuitable for public interfaces or services that must maintain a consistent discoverable endpoint.
The Solution: Named Processes
To overcome PID instability, Elixir provides a mechanism for giving processes stable, discoverable names. This is achieved through the {:registered_name, name} tuple, commonly referred to as named processes. Instead of relying on ephemeral PIDs, we can register a process with a unique atom (like :worker). This atom then acts as a stable alias, a consistent address that other processes can use to find and communicate with the service, regardless of whether the underlying process has been restarted.
The core functions for managing named processes are found in the Kernel module. Specifically, Kernel.register/2 allows you to associate an atom with a PID, and Kernel.whereis/1 lets you look up the PID associated with a registered name. If a process dies and a new one is registered with the same name, whereis/1 will automatically return the PID of the new incarnation. This provides the stable addressing required for robust distributed systems.
Let's explore how this works with a few examples.
Example 1: Simple Named Worker
First, we define a simple worker module that will perform some task. For this example, let's create a worker that simply stores a value and can retrieve it. We'll use Agent, a built-in Elixir abstraction for managing shared state, which itself is a named process.
defmodule MyWorker do
use Agent
def start_link(initial_value) do
Agent.start_link(fn -> initial_value end, name: __MODULE__)
end
def get_value do
Agent.get(__MODULE__, fn value -> value end)
end
def update_value(new_value) do
Agent.update(__MODULE__, fn _ -> new_value end)
end
end
In this module, MyWorker uses Agent.start_link/2. The crucial part is the name: __MODULE__ option. This registers the agent process with the atom :my_worker (since __MODULE__ evaluates to MyWorker). Now, any other process can interact with this worker using its name, without needing its PID.
To start this worker, we would call:
MyWorker.start_link("initial data")
After starting, we can interact with it:
iex(1)> MyWorker.get_value()
"initial data"
iex(2)> MyWorker.update_value("updated data")
:ok
iex(3)> MyWorker.get_value()
"updated data"
The Agent module handles the registration and lookup internally. If the agent crashes and is restarted by a supervisor, the new agent process will automatically be registered under the :my_worker name. Any subsequent calls to MyWorker.get_value/0 or MyWorker.update_value/1 will be routed to the new process.
Example 2: Manual Registration and Lookup
While abstractions like Agent handle registration automatically, it's instructive to see how manual registration works. We can use Kernel.register/2 to explicitly name a process.
Consider a simple worker that just pings back its PID. We'll start it and then register it manually.
defmodule PingWorker do
def start do
spawn(fn ->
pid = self()
IO.puts("PingWorker started with PID: #{inspect(pid)}")
# Register this process with the name :ping_service
Kernel.register(pid, :ping_service)
# Keep the process alive and responsive
receive do
{:ping, from} ->
send(from, {:pong, pid})
loop(from)
after
:infinity
end
end)
end
defp loop(from) do
receive do
{:ping, from} ->
send(from, {:pong, self()})
loop(from)
after
:infinity
end
end
end
Now, let's start this worker and then try to communicate with it using its registered name:
iex(1)> pid = PingWorker.start()
PingWorker started with PID: #PID<0.123.0>
#PID<0.123.0>
iex(2)> Kernel.whereis(:ping_service)
#PID<0.123.0>
iex(3)> send(:ping_service, {:ping, self()})
:ok
iex(4)> receive do
...(4)> {:pong, worker_pid} ->
...(4)> IO.puts("Received pong from #{inspect(worker_pid)}")
...(4) end
Received pong from #PID<0.123.0>
:ok
Here, Kernel.register(pid, :ping_service) associates the PID returned by spawn/1 with the atom :ping_service. When we later call Kernel.whereis(:ping_service), it correctly returns the PID of the running process. Crucially, if this PingWorker process were to crash and be restarted (perhaps by a supervisor), and the new process was also registered as :ping_service, Kernel.whereis/1 would automatically return the new PID. This is the core of stable addressing.
Example 3: Supervision and Named Processes
The real power of named processes becomes apparent when combined with supervisors. A supervisor can ensure that a named process is always running. If the named process crashes, the supervisor restarts it, and the new process is re-registered under the same name, maintaining discoverability.
Let's create a supervisor for our PingWorker.
defmodule PingWorkerSupervisor do
use Supervisor
def start_link do
Supervisor.start_link(__MODULE__, :ok)
end
def init(:ok) do
children = [
%{id: :ping_service, start: {PingWorker, :start, []}, type: :worker}
]
supervise(children, strategy: :one_for_one)
end
end
In this supervisor, we define a child specification with id: :ping_service. This ID is used by the supervisor to refer to the child. When the supervisor starts, it calls PingWorker.start/0. The PingWorker module, as defined previously, registers itself with the name :ping_service. If the PingWorker process dies, the supervisor, using the :one_for_one strategy, will restart it.
Let's see this in action. Start the supervisor:
iex(1)> PingWorkerSupervisor.start_link()
{:ok, #PID<0.100.0>}
# In another iex session or after a crash:
iex(2)> Kernel.whereis(:ping_service)
#PID<0.123.0> # This PID will be different if restarted
iex(3)> send(:ping_service, {:ping, self()})
:ok
iex(4)> receive do
...(4)> {:pong, worker_pid} ->
...(4) IO.puts("Received pong from #{inspect(worker_pid)} (registered as :ping_service)")
...(4) end
Received pong from #PID<0.123.0> (registered as :ping_service)
:ok
Now, intentionally crash the worker process. You can do this by sending it a message it doesn't expect, or by manually killing it from another shell if you know its PID. For example, if you have the PID from Kernel.whereis(:ping_service), you could send it a poison pill. The supervisor will detect the crash and restart it. After the restart, Kernel.whereis(:ping_service) will return the PID of the newly started process, and communication will continue seamlessly.
This combination of supervisors and named processes is a cornerstone of building resilient, fault-tolerant distributed applications in Elixir. It allows services to be consistently addressed and automatically recovered, abstracting away the complexities of ephemeral PIDs.
The Unanswered Question: Global Registration Scope
While named processes provide stable addresses within a single Elixir node, what happens when you need a globally unique name across an entire distributed cluster? Elixir's built-in registry is node-local. For global registration, developers typically resort to external services like Consul, etcd, or build their own distributed registry mechanisms. The exact implications and best practices for managing global name resolution in highly dynamic, ephemeral clusters, especially concerning failover and discovery latency, remain a rich area for further exploration and standardization.
