Quando exploramos os pontos fortes do Elixir como linguagem de programação, dois se destacam em frente a outras linguagens do mercado:

🚀 3. Concurrency and Scalability

  • Uses actors (processes) for concurrency (each with its own memory and message queue).
  • Millions of processes can run concurrently with low overhead.
  • Built-in tools for distribution across multiple nodes.

🛠 4. Fault-Tolerance and Supervision Trees

  • “Let it crash” philosophy: Failures are expected and isolated.
  • Supervision trees: Automatically restart failing processes, making systems self-healing and resilient.

GenServer é uma abstração sobre o modelo de processos da VM do Erlang que é entregue pelo core da linguagem e se conecta diretamente aos pontos de Concorrência e Árvore de Supervisão do Elixir. Por ser um pilar comum na arquitetura de sistemas construídos em Elixir, e por ser abordado “prematuramente” nas documentações oficiais (Client server communication, OTP Concurrency), é comum que programadores recém-chegados comecem a modelar suas primeiras soluções em Elixir com o uso da abstração fornecida pelo módulo GenServer. Demora um pouco até a doc oficial fazer o primeiro disclaimer para os novos desavisados: When (not) to use a GenServer.

Apesar do aviso sobre não usar GenServers para organização do código, um outro aviso que eu considero fundamental e não temos na doc oficial (ou pelo menos eu não cheguei nessa parte da doc ainda 😅) é evitar o uso do GenServer quando ele não for estritamente necessário (e/ou até que o desenvolvedor conheça todas as especificidades que envolvem o modelo de processos do Erlang).

Apesar de a primeira vista a API do GenServer parecer bem simples (pelo menos para alguém já acostumado à syntax da linguagem), todo o ciclo de vida de um processo em Elixir tem características que não são intuitivas e muitas vezes desconhecidas. Isso faz com que códigos que foram além dos contadores simples propostos na doc comecem a gerar erros e comportamentos imprevistos em produção. Problemas esses que são difíceis de debugar e entender, principalmente para quem ainda é novo no mundo de Elixir.

Eu queria aproveitar para trazer alguns pontos importantes de serem levados em consideração na hora de modelar sua solução com o uso dessa abstração.

Let it crash (mas nem tanto 🫠)

Esse mantra é repetido aos quatro ventos quando estamos falando das capacidades da linguagem. Ela parte do princípio que todos os processos em Elixir são isolados, erros que acontecem em um processo não interferem na execução dos demais processos, e caso esse processo que deu erro seja importante, o Supervisor vai cuidar de reiniciá-lo e tudo ficará bem. Será mesmo?

De fato os processos são isolados, e um erro que ocorra em um não afetará os demais (a menos que explicitamente o desenvolvedor o faça). Outro fato é que sim, o Supervisor vai restartar processos que estão na sua árvore de supervisão caso eles crashem. O que nem todos sabem entretanto é que existe um threshold de máximo de tentativas em um intervalo de tempo (Supervisor strategies and options). Na prática, a configuração padrão de um supervisor determina que caso um processo filho dê :exit mais de 3 vezes dentro de 5 segundos, o supervisor todo vai ser encerrado e assim encerrando junto toda a árvore de supervisão. Na prática isso significa que um GenServer em “mal estado” debaixo do mesmo servidor do Phoenix e do Ecto pode encerrar toda sua aplicação. Bom, parece que um processo problemático pode sim afetar outros processos afinal 🙃.

Vamos testar como isso funciona na prática (eu recomendo uma leitura inicial da doc do GenServer porque não vamos explicar os detalhes da API). Crie um novo projeto na sua máquina, usando a flag --sup para gerar uma app Elixir com uma árvore de supervisão já configurada:

mix new genserver_study --sup

Crie um módulo novo para representar um GenServer qualquer da sua aplicação:

defmodule SafeServer do
  use GenServer

  @prefix "[SafeServer]"

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  @impl true
  def init(_) do
    IO.puts("#{@prefix} Initializing GenServer")
    {:ok, %{}, {:continue, :init}}
  end

  @impl true
  def handle_continue(:init, state) do
    IO.puts("#{@prefix} Initialized with #{inspect(self())}")
    {:noreply, state}
  end
end

Esse GenServer não processa nada, apenas loga sua inicialização com o seu PID.

Agora vamos adicionar ele à árvore de supervisão da nossa app. Abra o arquivo lib/genserver_study/application.ex e inclua o SafeServer na lista de filhos do supervisor:

def start(_type, _args) do
  children = [
    SafeServer # Adicione o módulo do SafeServer à lista de processos filhos
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

Agora vamos iniciar o iex carregando nossa aplicação junto, com o comando iex -S mix:

iex -S mix
Erlang/OTP 28 [erts-16.0] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit:ns]

[SafeServer] Initializing GenServer
Interactive Elixir (1.18.4) - press Ctrl+C to exit (type h() ENTER for help)
[SafeServer] Initialized with #PID<0.142.0>

Podemos ver pelo log que nosso SafeServer iniciou corretamente.

Agora vamos criar um GenServer problemático e ver como ele afeta nosso supervisor e o SafeServer.

defmodule BrokenServer do
  use GenServer

  @prefix "[BrokenServer]"

  def start_link(behaviour), do: GenServer.start_link(__MODULE__, behaviour, name: __MODULE__)

  @impl true
  def init(behaviour) do
    IO.puts("#{@prefix} Initializing GenServer for #{behaviour}")
    {:ok, %{}, {:continue, {:init, behaviour}}}
  end

  @impl true
  def handle_continue({:init, behaviour}, state) do
    IO.puts("#{@prefix} Initialized with #{inspect(self())}")
    Process.send_after(self(), behaviour, 100)
    {:noreply, state}
  end

  @impl true
  def handle_info(:break, _state), do: raise("Unhandled error")

  def handle_info(:stop, state) do
    {:stop, :finish, state}
  end
end

Uma breve explicação sobre o funcionamento do BrokenServer: a função init/1 retorna uma tupla com a opção {:continue, {:init, behaviour}}. O callback handle_continue/2 vai agendar o envio de uma mensagem para o próprio processo em 100ms. Nós temos dois handle_info/2: um para o comportamento de stop e outro para gerar uma exception. Vamos falar sobre a diferença de ambos mais tarde, mas o efeito colateral vai ser um :exit do processo do GenServer para os dois casos.

Agora vamos adicionar o BrokenServer à lista de filhos do supervisor, mas adicionando uma opção para a inicialização do server:

def start(_type, _args) do
  children = [
    SafeServer,
    {BrokenServer, :break} # Estamos iniciando o GenServer com a opção :break
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

Agora vamos iniciar nossa aplicação com o iex (eu limpei alguns logs para facilitar a leitura):

iex -S mix

[SafeServer] Initializing GenServer
[BrokenServer] Initializing GenServer for break
[SafeServer] Initialized with #PID<0.156.0>
[BrokenServer] Initialized with #PID<0.157.0>

15:40:22.511 [error] GenServer BrokenServer terminating
** (RuntimeError) Unhandled error
    (genserver_study 0.1.0) lib/genserver_study/broken_server.ex:22: BrokenServer.handle_info/2
    ...
Last message: :break
State: %{}

[BrokenServer] Initializing GenServer for break
[BrokenServer] Initialized with #PID<0.161.0>

15:40:22.614 [error] GenServer BrokenServer terminating
** (RuntimeError) Unhandled error
    ...

15:40:22.617 [notice] Application genserver_study exited: shutdown

Rodando na sua máquina você verá quatro vezes a exception gerada pelo nosso BrokenServer e ao final a mensagem Application genserver_study exited: shutdown.

Se você leu a seção Supervisor strategies and options, provavelmente está pensando: “Bom, a solução no caso é subir o threshold do Supervisor para o ‘infinito’ e impedir que ele mate toda a aplicação”. Minha resposta para você é: depende. Esse comportamento do supervisor existe para evitar que ao entrar em um estado de “não retorno” sua aplicação fique em um looping infinito de crash e restart do processo. O racional é que ao encerrar toda a app um processo externo (lifecycle do k8s, por exemplo) refaça todo o ambiente e isso eventualmente reestabeleça a saúde da sua aplicação. Obviamente você pode escolher valores mais permissivos do que os default, mas precisa ter muito cuidado para não “maquiar” um problema real. Afinal, se o processo não está se mantendo vivo, na prática sua aplicação não está funcionando, então qual seria o benefício de manter uma app zumbi?

Mas então como fica o “Let it crash”? Eu pessoalmente acho a escolha do slogan um equívoco. Ele induz a acreditar que deixar o código quebrar by design é a forma correta fazer as coisas em Elixir, e na prática não é bem assim. A ideia correta da tolerância a falhas no Elixir é entender que erros inesperados não vão inicialmente derrubar toda a sua aplicação. Existe uma camada de segurança na linguagem que isola os processos e vai tentar se recuperar de eventuais anomalias ocasionais ou temporárias. Sendo assim, é sempre importante tratar todos os casos de erro conhecidos dentro da execução de um GenServer (e qualquer processo supervisionado), e deixar que sejam geradas exceptions apenas para casos não previstos e inesperados.

Exception vs Gracefully stop

Você pode reparar que no exemplo de código do BrokenServer nós temos um fluxo de handle_info que retorna uma tupla de stop ao invés de gerar uma exception:

def handle_info(:stop, state) do
  {:stop, :finish, state} # Gracefully stop
end

Essa opção indica que o processo do GenServer deve ser encerrado. Essa opção normalmente é utilizada quando temos um fluxo onde não é mais necessário manter o processo do GenServer rodando, muitas vezes porque ele não será mais utilizado. Mas como o Supervisor se comporta quando o GenServer envia um sinal de exit de forma “controlada”? Vamos alterar a configuração na nossa árvore de supervisão para ativar o fluxo com o stop:

def start(_type, _args) do
  children = [
    SafeServer,
    {BrokenServer, :stop} # Alteramos a opção :break para :stop
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

Então temos os logs (algumas linhas foram omitidas para melhorar a leitura):

iex -S mix

[SafeServer] Initializing GenServer
[BrokenServer] Initializing GenServer for stop

[BrokenServer] Initialized with #PID<0.157.0>
[SafeServer] Initialized with #PID<0.156.0>

10:12:32.292 [error] GenServer BrokenServer terminating
** (stop) :finish
Last message: :stop
State: %{}

[BrokenServer] Initializing GenServer for stop
[BrokenServer] Initialized with #PID<0.159.0>

...

10:12:32.604 [notice] Application genserver_study exited: shutdown

Você vai perceber que novamente após reiniciar o processo 4 vezes o supervisor encerra com a mensagem Application genserver_study exited: shutdown.

Isso acontece porque a configuração padrão do Supervisor é permanentemente tentar restartar um processo quando ele encerra, independente dos motivos. Esse comportamento padrão pode ser alterado para um GenServer específico nas opções do use GenServer, restart: :transient (How to supervise), ou nas configurações “globais” do supervisor: Restart values (:restart).

É importante entretanto ter em mente que caso o supervisor esteja configurado para restartar os processos de forma transient, caso um GenServer encerre a execução com um :stop e seja necessário reiniciar o mesmo, o processo precisa ser feito manualmente via start_child/2.

Fila de mensagens — mailbox

Outro aspecto muito importante dos processos em Elixir e portanto também uma característica dos GenServers é que todas as mensagens (send/2, GenServer.call/2, GenServer.cast/2) são sempre enfileiradas na message queue (mailbox) de cada processo e podem ser lidas uma por vez. A existência da mailbox é citada brevemente na seção de envio de mensagens da doc do Elixir: Sending and receiving messages, mas é explicada de forma mais detalhada na documentação oficial do Erlang: Signals — Adding Messages to the Message Queue.

Entender esse aspecto do funcionamento dos processos é importante porque normalmente ele se torna o motivo de duas falhas muito comuns em arquiteturas em Elixir: bottleneck e perda de mensagens. Imagine que você criou um GenServer responsável por controlar o rate limit de uso da API: cada request recebida o controller envia uma mensagem para esse GenServer com o IP de origem, o GenServer precisa salvar a data e hora que esse IP tentou acessar o endpoint, e buscar todas as últimas tentativas para calcular se o limite foi excedido. Agora imagine que sua API recebe milhões de requisições por segundo, todas elas enviam mensagens para esse GenServer que precisa salvar e calcular os limites para responder para o controller se deve aceitar ou não a request. Perceba que esse processo do GenServer virou um gargalo para toda a API — todas as requests ficam aguardando a resposta desse processo centralizado que é capaz de responder apenas uma mensagem por vez. E o mais preocupante: o que acontece com a mailbox caso esse GenServer dê um exit por conta de uma exception? Vamos testar.

Crie um novo módulo de GenServer com o seguinte código:

defmodule MsgServer do
  use GenServer

  @prefix "[MsgServer]"

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  @impl true
  def init(_) do
    IO.puts("#{@prefix} Initializing GenServer")
    {:ok, %{count: 0}}
  end

  def message(id), do: GenServer.call(__MODULE__, {:msg, id})
  def stop, do: GenServer.call(__MODULE__, :stop)
  def break, do: GenServer.call(__MODULE__, :break)

  @impl true
  def handle_call({:msg, id}, _from, %{count: count}) do
    # Fake heavy computation
    :timer.sleep(200)
    IO.puts("#{@prefix} Finish process msg #{id}")

    {:reply, {:ok, id}, %{count: count + 1}}
  end

  @impl true
  def handle_call(:stop, _from, state) do
    IO.puts("#{@prefix} Sending stop message")

    send(self(), :restart_connection)
    {:reply, {:ok, :stoping}, state}
  end

  @impl true
  def handle_call(:break, _from, state) do
    raise("Unhandled Error")

    {:noreply, state}
  end

  @impl true
  def handle_info(:restart_connection, state) do
    IO.puts("#{@prefix} Handling stop message")
    {:stop, :publish_error, state}
  end

  @impl true
  def terminate(reason, state) do
    IO.puts("#{@prefix} Terminating reason: #{inspect(reason)}, state: #{inspect(state)}")

    :normal
  end
end

Esse GenServer tem uma API com apenas 3 funções: uma para simular um processamento “pesado” de uma mensagem, uma para simular um erro com exception e outra para simular um exit com stop do GenServer.

Vamos alterar nossa árvore de supervisão para iniciar esse novo GenServer:

def start(_type, _args) do
  children = [
    MsgServer # Removemos os outros GenServers e adicionamos o MsgServer
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

Vamos criar um módulo para simular o envio de mensagens para nosso GenServer:

defmodule MsgTest do
  def stop do
    Enum.each(0..15, fn index ->
      if index == 3 do
        spawn(fn -> MsgServer.stop() |> IO.inspect(label: "stop") end)
      else
        spawn(fn -> message(index) end)
      end

      :timer.sleep(50)
    end)
  end

  def break do
    Enum.each(0..15, fn index ->
      if index == 3 do
        spawn(fn -> MsgServer.break() |> IO.inspect(label: "break") end)
      else
        spawn(fn -> message(index) end)
      end

      :timer.sleep(50)
    end)
  end

  defp message(id) do
    IO.puts("[Teste] Sending message #{id}")

    case MsgServer.message(id) do
      {:ok, _count} ->
        IO.puts("[Teste] Result for message #{id} is ok")

      error ->
        IO.puts("[Teste] Result for message #{id} is #{inspect(error)}")
    end

    IO.puts("[Teste] Gracefully ending message #{id}")
  catch
    :exit, error ->
      IO.puts("[Teste] Catch error #{inspect(error)}")
  end
end

Esse módulo de teste implementa duas funções públicas: uma para simular um cenário onde o GenServer encerra com uma exception (break) e outro para o GenServer encerrar com um stop/exit. O mecanismo do teste é subir 16 processos com o spawn; desses processos, o quarto vai enviar uma mensagem de break ou stop, para simular uma quebra/encerramento do GenServer. A ideia é acompanhar pelos logs o que acontece com as mensagens que estavam na mailbox do GenServer quando ele encerrar.

Vamos subir a aplicação e rodar MsgTest.break para simular a interrupção via exception:

iex(1)> MsgTest.break()
[Teste] Sending message 0
[Teste] Sending message 1
[Teste] Sending message 2
[MsgServer] Finish process msg 0
[Teste] Result for message 0 is ok
[Teste] Gracefully ending message 0
[Teste] Sending message 4
[Teste] Sending message 5
[Teste] Sending message 6
...
[MsgServer] Finish process msg 2
[Teste] Result for message 2 is ok
[Teste] Gracefully ending message 2
[Teste] Sending message 12

12:18:43.864 [error] GenServer MsgServer terminating
** (RuntimeError) Unhandled Error
    ...

[MsgServer] Initializing GenServer
[Teste] Catch error { {%RuntimeError{message: "Unhandled Error"}, ...}, {GenServer, :call, [MsgServer, {:msg, 6}, 5000]}}
[Teste] Catch error { {%RuntimeError{message: "Unhandled Error"}, ...}, {GenServer, :call, [MsgServer, {:msg, 4}, 5000]}}
... (mais 7 mensagens semelhantes)
[Teste] Sending message 13
[Teste] Sending message 14
[Teste] Sending message 15
[MsgServer] Finish process msg 13
[Teste] Result for message 13 is ok
[Teste] Gracefully ending message 13
[MsgServer] Finish process msg 14
[Teste] Result for message 14 is ok
[Teste] Gracefully ending message 14
[MsgServer] Finish process msg 15
[Teste] Result for message 15 is ok
[Teste] Gracefully ending message 15

Nós podemos perceber pelos logs que, até acontecer a exception, foram enviadas as mensagens até o índice 12. Dessas mensagens, foram processadas as mensagens 0, 1 e 2: [Teste] Gracefully ending message 2. Todas as demais mensagens que estavam no mailbox foram logadas pelo catch (vamos falar melhor sobre esse ponto). Os processos das mensagens 13, 14, 15 que não foram enviadas inicialmente ficaram aguardando o reinicio do GenServer (comportamento padrão quando usamos GenServer.call/2) e assim que ele reiniciou elas foram enviadas e processadas corretamente. Em resumo, por conta da exception gerada durante a execução do GenServer, 9 mensagens que estavam aguardando execução na mailbox foram perdidas, mesmo com a recuperação posterior do GenServer pelo Supervisor.

O que acontece com as mensagens que foram “descartadas” no encerramento do GenServer? Vamos imaginar o exemplo inicial, de um GenServer que controla o rate limit de uma API. Cada request feita para essa API é tratada em um processo isolado, e cada processo vai fazer um GenServer.call/2, então imagine que o controller da nossa API tem o seguinte código:

def show(conn, params) do
  with :ok <- GenServer.call(RateLimit, {:get, conn}) do
    process_show(params)
  end
end

Nesse código, quando o processo que está executando a função show/2 chamar a linha com o GenServer.call/2, ele vai enviar essa mensagem para o GenServer RateLimit e vai ficar aguardando por uma mensagem de resposta. Além de aguardar a resposta, ele vai ficar observando por sinais de exit do processo do GenServer. Quando a exception acontecer, o sinal de exit vai ser propagado para todos os processos que estão aguardando por uma mensagem. Como nesse código não tem um catch para tratar o sinal de exit, o processo do controller também vai dar um exit sem continuar a execução da próxima linha que seria o process_show(params). Esse exit seria capturado pelo Phoenix, e nós teríamos um erro 500 da API.

O catch utilizado no módulo de teste MsgTest é uma forma de capturar esse sinal de exit e controlar o fluxo de execução. No caso do nosso exemplo, nós não terminamos o processo que chamou o GenServer.call/2, nós apenas logamos a informação e seguimos com a execução normal.

Você pode testar agora o que acontece chamando o fluxo de exit com o stop: MsgTest.stop(). Você vai notar que o comportamento é o mesmo. Mesmo sendo um encerramento controlado, todas as mensagens na mailbox são descartadas e os processos que chamaram o GenServer recebem o sinal de exit.

Mitigando bottlenecks e perda de mensagens com Poolboy

Existem algumas estratégias que podem ser utilizadas para mitigar os problemas que mostramos. Uma delas é isolar a execução do GenServer do gerenciamento da fila e paralelizar a execução das mensagens em mais de um processo. Isso nem sempre é simples — no exemplo do rate limit seria um pouco mais complexo pelo fato de que você pode ter várias requests em paralelo do mesmo IP, seria necessária uma camada adicional de “roteamento” das mensagens para garantir a consistência dos dados. Mas para a maioria dos problemas mais simples, um aliado para ajustar essa arquitetura é a lib Poolboy, que te permite controlar essa fila e paralelizar a execução utilizando uma estratégia de pool de workers (muito similar ao que o Ecto faz com as conexões do banco). Utilizando o Poolboy, ele vai gerenciar a fila e vai enviar apenas uma mensagem para cada worker; dessa forma, quando um worker morrer, os demais podem continuar atendendo as demandas e nenhuma mensagem “extra” será perdida.