Kevin Marques ★

Elixir | Entendendo Como GenServers Funcionam por Baixo

· Kevin Marques

Já que eu achei meio complicado entender o que são esses GenServers de Elixir que tanto falam, estou aqui pra tentar explicar o que entendi deles – já que não dá pra ser funcional (piada não intencional) na linguagem sem ter essa base.

O público alvo desse artigo é eu mesmo no futuro, quando estiver precisando de alguma referência sobre o assunto, e o pessoal que também está começando a explorar essa linguagem. Será um compilado de vários conceitos super básicos e code snippets, recomendo tente executar eles enquanto lê. Enfim, let’s go.

Conceitos Básicos Sobre a Linguagem

Antes de tentar dar um passo maior que a perna, acho bom entender antes essa dinâmica de processos do Elixir, já que meio que a linguagem foi vendida pra mim com essa promessa dela ser muito boa no assunto. Daí, de snippet em snippet, alguns conceitos e features da linguagem serão esclarecidos a um nível que fique até óbvio entender o problema que os GenServers resolve – ao menos foi pra mim, espero que seja fácil pra você também.

Esse conteúdo não é total de autoria minha, a palestra do Fábio Akita também explica as coisas nessa ordem, mas eu achei meio confuso por ter muita informação condensada em uma hora só. O artigo que me ajudou muito a entender esse conceito foi o Elixir: Understanding GenServers, escrito por Javier García em 2019.

Como Novos Processo são Criados

Tudo começa com a capacidade do Elixir de criar processos que rodam em background de um jeito muito fácil. A função spawn/1 precisa só de um argumento, uma função anônima que vai rodar em um processo separado, e ela vai retornar o PID desse processo. Uma vez com o PID em mãos, é possível monitorar o que está acontecendo com esse processo.

 1pid = spawn(fn ->
 2  IO.puts("Hello world!")
 3
 4  # Esse processo vai esperar 15 segundos antes de morrer.
 5  :timer.sleep(15_000)
 6  
 7  IO.puts("Bye world.")
 8end)
 9
10Process.alive?(pid)
11Process.info(pid)

Eu gosto muito desse Process.info/1, ele mostra o poder que o Elixir têm pra lidar processo rodando em background, dá pra ver qual função esse processo está rodando e até informações sobre o garbage colector – caso não saiba, cada processo em Elixir tem seu próprio garbage collector. Mas tá, apesar desses detalhes, isso não é muito diferente do que se dá pra fazer em outras linguagens, como as go routines em Go. Onde isso fica interessante?

Enviando e Recebendo Mensagens

Dá pra fazer muito mais que monitorar um processo pelo PID dele, também existe uma função que envia dados pra esse processo. Mas irei dar um passo pra trás aqui, vou fazer o processo atual enviar uma mensagem pra ele mesmo pra facilitar a compreensão.

Pra enviar mensagens pra um processo, basta passar o PID de quem deve receber a mensagem e a mensagem, que pode ser qualquer data type da linguagem, pra função send/2; ou Process.send_after/3 se precisar esperar algum tempo (em milissegundos) antes de enviar a mensagem. Isso é fácil.

Agora, pra receber uma mensagem enviada, a linguagem usa uma estrutura chamada receive block. Quando o runtime (a execução do código) cair nesse bloco, ele vai parar e esperar por uma mensagem. É complicado de explicar sem exemplos práticos, então vamos lá:

 1pid = self()
 2
 3send(pid, "Hello, I'm myself!")
 4
 5# Ou, use o Process.send_after/3 pra ver como o receive impede
 6# o usuário de fazer qualquer coisa no IEX.
 7Process.send_after(pid, "Hello, I'm myself!", 5_000)
 8
 9receive do
10  data ->
11    data
12end

Nesse exemplo, o IEX deve retornar – e não imprimir, como a função IO.puts/1 faz – a string "Hello, I'm myself!". Isso acontece porque o receive block funciona como o case e como as funções da linguagem, a última informação dela será o retorno do bloco todo. O receive block também usa pattern matching1 pra executar os diversos blocos de código que você quiser definir.

E essa ideia de pattern matching é importante, eu posso fazer o receive block responder, ou fazer, algo diferente dependendo do que eu envio pra ele usando o send/2.

 1pid = self()
 2
 3# Dependendo do que estiver comentado, o receive vai imprimir
 4# uma mensagem diferente na tela.
 5send(pid, :tobey)
 6send(pid, :andrew)
 7send(pid, :tom)
 8
 9receive do
10  :tobey ->
11    IO.puts("I'm the best Peter Parker!")
12
13  :andrew ->
14    IO.puts("I'm the best Spider-Man!")
15
16  :tom ->
17    IO.puts("I miss you, Mr. Stark...")
18end

Entendendo a Inbox de Mensagens

Se as três linhas forem descomentadas do exemplo anterior, o case block vai dar match no primeiro send/2 que foi executado, o restante vai permanecer acumulado na inbox. Let me explain.

Cada processo tem a sua própria inbox, e lá que as mensagens recebidas são acumuladas. Isso é um detalhe importante por que essa dinâmica de enviar e receber mensagens pode causar alguma confusão se esse conceito não estiver claro. Por exemplo, se eu enviar várias mensagens, uma depois da outra, o receive vai lidar só com uma de cada vez:

 1pid = self()
 2
 3send(pid, :steve)
 4send(pid, :thor)
 5send(pid, :tony)
 6
 7# Eu preciso copiar esse trecho 3 vezes pra esvaziar a inbox.
 8receive do
 9  :steve ->
10    IO.puts("I can do that the whole day.")
11
12  :thor ->
13    IO.puts("I'm the strongest avanger!")
14
15  :tony ->
16    IO.puts("And I am... The Iron-Man...")
17end

É só um pequeno adendo, mas que vale ser mencionado.

Uma outra curiosidade legal, é saber que função flush/0 existe. Essa função limpa a inbox do processo atual, e retorna tudo o que nela tem. Por exemplo, se o objetivo era só listar o que está acumulado na inbox até agora, daria pra fazer isso:

1pid = self()
2
3send(pid, :steve)
4send(pid, :thor)
5send(pid, :tony)
6
7flush()

As Raízes de um Servidor em Elixir

Ok, agora com o conceito de PIDs, send/2 e receive blocks, acho que dá pra dar um passo pra frente e começar a montar um servidor bem simples só com isso – e não, não estou falando de servidores web.

Um servidor em Elixir é, basicamente, uma função que fica rodando em loop recursivamente em outro processo, esperando receber alguma mensagem que vai ditar o que esse servidor deve fazer. E… Ta começando a se parecer com alguma coisa… Mas vamos por partes:

 1defmodule Counter do
 2  def new(initial_state) do
 3    # Essa função, spawn/3, permite eu passar um módulo,
 4    # função do módulo e a lista de argumentos pra essa função.
 5    spawn(Counter, :listen, [initial_state])
 6  end
 7
 8  def listen(state) do
 9    state =
10      receive do
11        :count ->
12          state + 1
13
14        :show ->
15          IO.puts("Current counter value: #{state}")
16          
17          state
18      end
19
20    # A última operação vai chamar o loop recursivo com o
21    # valor já atualizado.
22    listen(state)
23  end
24end
25
26# Como o usuário interagiria com o sistema desse servidor?
27# Assim.
28
29counter = Counter.new(0)
30
31send(counter, :count)
32send(counter, :count)
33send(counter, :count)
34
35send(counter, :show)

Nesse exemplo, o servidor Counter tem a função new/1 que só serve pra não ficar escrevendo send(fn -> blah blah blah end) o tempo todo, só pra isso que ela serve. O que importa mesmo é esse tal de loop/1.

O loop/1 vai ficar executando infinitamente, num processo em background, esse receive block que vai esperar por alguma mensagem, mensagem essa que vai atualizar (ou não) o estado do servidor – esse parâmetro que chamei de state2. E o processo atual que o IEX está rodando pode enviar coisa pra esse processo, ou seja, pode fazer esse processo atualizar o seu estado atual.

No caso, esse servidor só aceita a mensagem :count, que soma +1 no contador (o estado do servidor), e a mensagem :show, que não faz nada com o estado, mas mostra o valor dele na tela.

E, cara, é meio que isso só. Um genserver, ou generic server, é só um módulo que diversas funções que te ajudam a escrever esses servidores. Eu só implementei a função new/1 ali em cima pra ajudar, mas um GenServer de verdade têm diversas outras funções pra até evitar de ficar enviando mensagens com o send/2 + o PID do servidor.

Semelhanças com a Orientação à Objetos

Estudando o comportamento dos generic servers em Elixir, posso concluir que, na prática mesmo, eles não são muitos diferentes de objetos. Veja bem, você pode subir inúmeros processos com o mesmo módulo de GenServer, similar a uma classe criando várias instancias de objetos, é possível manter o estado inicial e ir atualizado a medida que necessário, se o estado for algo como um hash map então isso não difere muito das propriedades/atributos que um objeto pode ter.

E os métodos? Eles são as instruções que o processo executa baseado no que ele recebe no receive, no caso de Elixir esses “métodos” são chamados de callbacks – e o detalhe é que eles são diferentes de funções, eles são escolhidos pra executar via pattern matching, normalmente com um átomo que serve de nome pro método. O constructor seria o init/1 (ou new/1 no exemplo anterior), você passa o estado inicial antes de iniciar o event loop do processo, assim como você passa valores default pras propriedades quando se cria um novo objeto com new MyClass() em Javascript, por exemplo.

O destructor eu não cheguei a implementar nos meus estudos, mas sei que o GenServer do Elixir já oferece pra você definir o que fazer quando o event loop – não sei se posso chamar assim – morrer na função stop/1, se não estou enganado. Parece que no final do dia, generic servers são apenas classes usadas pra criar objetos que ficam funcionando em background ao invés de ser no mesmo processo como a maioria das outras linguagens.

Saiba Mais

Fontes de pesquisa:

Snippets de código mais completinhos, pra complementar o conteúdo do artigo – lembre-se de rodá-los no IEX e ir testando devagar.


  1. Se ficou confuso, pesquise mais sobre a sintaxe das funções e, principalmente, do case do. Essa última estrutura é uma das, se não a, mais importante da linguagem; felizmente, não é tão complicado de entender. ↩︎

  2. Se você é mais de programação funcional, esse state serve como uma espécie de accumulator↩︎

#Programming   #Learning   #Elixir   #Studying  

comments powered by Disqus