[Elixir] GenServer Process management/registry

While I was learning Elixir basics, I was confused about how to manage process registration and discovery. I got tons of ideas from the book Elixir in Action by Saša Juric. Here is my study note on it.

There are multiple ways to manage processes.

1. no registration; remember the pid

  • We need to remember the pid that is returned when a genserver process is started.
  • We can create as many processes as we want from the same module.
  • We need to be aware that a pid will be changed when a process is terminated and recreated.
defmodule MyApp.HelloServer do
  use GenServer

  def start_link(id) do
    GenServer.start_link(__MODULE__, id)
  end

  def hello(pid) do
    GenServer.call(pid, :hello)
  end

  @impl true
  def init(id) do
    {:ok, %{id: id}}
  end

  @impl true
  def handle_call(:hello, _from, state) do
    {:reply, "hello", state}
  end
end

iex> {:ok, pid} = MyApp.HelloServer.start_link(123)
{:ok, #PID<0.123.0>}

iex> MyApp.HelloServer.hello(pid)
"Hello"

2. using module name as local alias

  • This is suitable when we need only one process from a module.
  • Generally, we just use the module name as a local alias.
defmodule MyApp.HelloServerLocalName do
  use GenServer

  def start_link(id) do
    GenServer.start_link(__MODULE__, id, name: __MODULE__)
  end

  def hello do
    GenServer.call(__MODULE__, :hello)
  end

  @impl true
  def init(id) do
    {:ok, %{id: id}}
  end

  @impl true
  def handle_call(:hello, _from, state) do
    {:reply, "hello", state}
  end
end

iex> MyApp.HelloServerLocalName.start_link(123)
{:ok, #PID<0.205.0>}

iex> MyApp.HelloServerLocalName.hello()
"Hello"

iex> MyApp.HelloServerLocalName.start_link(123)
{:error, {:already_started, #PID<0.205.0>}}

3. using dynamic tuple as local alias (BAD)

When we want to register multiple processes, we may get tempted to generate an atom dynamically (I did); however it is not a good practice. Erlang has a limit on the number of atoms we can create. Also atoms are not garbage-collected.

defmodule MyApp.HelloServerDynamicName do
  use GenServer

  def process_name(id) do
    String.to_atom("#{__MODULE__}_#{id}")
  end

  def start_link(id) do
    GenServer.start_link(__MODULE__, id, name: process_name(id))
  end

  def hello(id) do
    GenServer.call(process_name(id), :hello)
  end

  @impl true
  def init(id) do
    {:ok, %{id: id}}
  end

  @impl true
  def handle_call(:hello, _from, state) do
    {:reply, "hello", state}
  end
end

iex> MyApp.HelloServerDynamicName.start_link(123)
{:ok, #PID<0.164.0>}

iex> MyApp.HelloServerDynamicName.hello(123)
"Hello"

iex> :erlang.system_info(:atom_limit)
1048576

iex> :erlang.system_info(:atom_count)
15849

iex> (1..99) |> Enum.each(fn x -> MyApp.HelloServerDynamicName.start_link(x) end)
:ok

iex> :erlang.system_info(:atom_count)
16115

4. using Registry and via tuple

  • We can use via_tuple in place of pid.
  • By using a composite key, many processes can be registered from the same module without creating extra atoms.
  • Obviously, a registry process needs to be started before the registration.
defmodule MyApp.ProcessRegistry do
  def via_tuple(key) when is_tuple(key) do
    {:via, Registry, {__MODULE__, key}}
  end

  def whereis_name(key) when is_tuple(key) do
    Registry.whereis_name({__MODULE__, key})
  end

  def start_link() do
    Registry.start_link(keys: :unique, name: __MODULE__)
  end
end

defmodule MyApp.HelloServerViaTuple do
  use GenServer

  def via_tuple(id) do
    MyApp.ProcessRegistry.via_tuple({__MODULE__, id})
  end

  def whereis(id) do
    case MyApp.ProcessRegistry.whereis_name({__MODULE__, id}) do
      :undefined -> nil
      pid -> pid
    end
  end

  def start_link(id) do
    GenServer.start_link(__MODULE__, id, name: via_tuple(id))
  end

  def hello(id) do
    GenServer.call(via_tuple(id), :hello)
  end

  @impl true
  def init(id) do
    {:ok, %{id: id}}
  end

  @impl true
  def handle_call(:hello, _from, state) do
    {:reply, "hello", state}
  end
end

iex> MyApp.ProcessRegistry.start_link()
{:ok, #PID<0.421.0>}

iex> MyApp.HelloServerViaTuple.start_link(123)
{:ok, #PID<0.164.0>}

iex> MyApp.HelloServerViaTuple.hello(123)
"Hello"

iex> MyApp.HelloServerViaTuple.whereis(123)
#PID<0.164.0>

5. using global alias

  • A cluster-wide lock is set so the processes can be shared across multiple nodes.
defmodule MyApp.HelloServerGlobalName do
  use GenServer

  def whereis(id) do
    case :global.whereis_name({__MODULE__, id}) do
      :undefined -> nil
      pid -> pid
    end
  end

  def register_process(pid, id) do
    case :global.register_name({__MODULE__, id}, pid) do
      :yes -> {:ok, pid}
      :no -> {:error, {:already_started, pid}}
    end
  end

  def start_link(id) do
    case whereis(id) do
      nil ->
        {:ok, pid} = GenServer.start_link(__MODULE__, id)
        register_process(pid, id)
      pid ->
        {:ok, pid}
    end
  end

  def hello(id) do
    GenServer.call(whereis(id), :hello)
  end

  @impl true
  def init(id) do
    {:ok, %{id: id}}
  end

  @impl true
  def handle_call(:hello, _from, state) do
    {:reply, "hello", state}
  end
end

iex> MyApp.HelloServerGlobalName.start_link(123)
{:ok, #PID<0.205.0>}

iex> MyApp.HelloServerGlobalName.hello(123)
"Hello"

iex> MyApp.HelloServerGlobalName.start_link(123)
{:error, {:already_started, #PID<0.205.0>}}

Starting a cluster

  • Open two iex shells
  • Turn BEAM instances (iex) into nodes
  • Make a cluster connecting those notes
iex --sname node1@localhost
iex(node2@localhost)> _
iex --sname node2@localhost
iex(node2@localhost)> Node.connect(:node1@localhost)
true

We can confirm processes are shared in two iex shells (BEAM instances).

Screen Shot 2020-12-10 at 8 55 20 AM

Finally

With this much info, I feel confident about handling processes. I'll keep on updating the post as soon as I learn something new that is relevant.

Happy coding!