Skip to content

Commit d6dbbbe

Browse files
docs: improve br readme
1 parent 1d4dac5 commit d6dbbbe

File tree

1 file changed

+102
-0
lines changed

1 file changed

+102
-0
lines changed

README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<div align="center">
2+
3+
<h1><code>Elixir Queue</code></h1>
4+
</div>
5+
6+
## 🗺 Translations
7+
- [🇺🇸 English](./README.en_US.md)
8+
9+
## 🤔 Motivação
10+
O motivo principal pelo qual resolvi desenvolver essa fila de processos foi o aprendizado das estruturas e APIs do Erlang/OTP utilizando o Elixir. Provalvemente existem muitas outras filas de processamento de serviços em segundo plano espalhadas por ai bem mais eficientes do que esta que se encontra aqui. No entanto acredito que para quem está começando é demasiado interessante ter acesso a estruturas mais simples, tanto do ponto de vista de lógica de programação quanto da perspectiva operacional do OTP. Por isso optei por fazer desde a base um software para execução de processos de forma a conseguir explicar as tomadas de decisão e, eventualmente, ir corrigindo esse caminho conforme a comunidade demonstre que uma ou outra opção é melhor, através de _pull requests_ ou mesmo _issues_ abertas. Tenho certeza que será de grande ajuda para iniciantes e para mim o que for tratado aqui.
11+
12+
## 📘 Estrutura
13+
### Fluxo/Diagrama da aplicação
14+
Abaixo um diagrama completo do fluxo de dados e possibilidades de acontecimentos do sistema.
15+
![Diagrama de fluxo de aplicação](https://raw.githubusercontent.com/abmBispo/elixir-queue/master/ElixirQueue.png)
16+
17+
### ElixirQueue.Application
18+
A `Application`desta fila de processos supervisiona os processos que irão criar/consumir a fila. Eis os filhos da `ElixirQueue.Supervisor`:
19+
```ex
20+
children = [
21+
{ElixirQueue.Queue, name: ElixirQueue.Queue},
22+
{DynamicSupervisor, name: ElixirQueue.WorkerSupervisor, strategy: :one_for_one},
23+
{ElixirQueue.WorkerPool, name: ElixirQueue.WorkerPool},
24+
{ElixirQueue.EventLoop, []}
25+
]
26+
```
27+
Os filhos, de cima para baixo, representam as seguintes estruturas: `ElixirQueue.Queue` é o `GenServer` que guarda o estado da fila numa tupla; `ElixirQueue.WorkerSupervisor`é o `DynamicSupervisor` dos _workers_ adicionados dinamicamente sempre igual ou menor que o número de `schedulers` onlines; `ElixirQueue.WorkerPool`, o processo responsável por guardar os `pids` dos _workers_ e os _jobs_ executados, quer seja com sucesso ou falha; e por último o `ElixirQueue.EventLoop` que é a `Task` que "escuta" as mudanças na `ElixirQueue.Queue` (ou seja, na fila de processos) e retira _jobs_ para serem executados. O funcionamento específico de cada módulo eu explicarei conforme for me parecendo útil.
28+
29+
### ElixirQueue.EventLoop
30+
Eis aqui o processo que controla a retirada de elementos da fila. Um _event loop_ por padrão é uma função que executa numa iteração/recursão _pseudo infinita_, uma vez que não existe a intenção de se quebrar o _loop_. A cada ciclo o _loop_ busca _jobs_ adicionados na fila - ou seja, de certa forma o `ElixirQueue.EventLoop` "escuta" as alterações que ocorreram na fila e reage a partir desses eventos - os direciona ao `ElixirQueue.WorkerPool` para serem executados. Este módulo assume o _behaviour_ de `Task` e sua única função (`event_loop/0`) não retorna nenhum valor tendo em vista que é um _loop_ eterno: ela busca da fila algum elemento e pode receber ou uma tupla `{:ok, job}` com a a tarefa a ser realizada ou uma tupla `{:error, :empty}` para caso a fila esteja vazia; no primeiro caso ele envia para o `ElixirQueue.WorkerPool` a tarefa e executa `event_loop/0` novamente; no segundo caso ele apenas executa a própria função recursivamente, continuando o loop até encontrar algum evento relevante (inserção de elemento na fila).
31+
32+
### ElixirQueue.Queue
33+
O módulo `ElixirQueue.Queue` guarda o coração do sistema. Aqui os _jobs_ são enfileirados para serem consumidos mais tarde pelos _workers_. É um `GenServer` sob a supervisão da `ElixirQueue.Application`, que guarda uma tupla como estado e nessa tupla estão guardados os jobs - para entender o porquê eu preferi por uma tupla ao invés de uma lista encadeada (`List`), mais abaixo em **Análise de Desempenho** está explicado. A `Queue` é uma estrutura bem simples, com funções triviais que basicamente limpam, buscam o primeiro elemento da fila para ser executado e insere um elemento ao fim da fila, de forma a ser executado mais tarde, conforme inserção.
34+
35+
### ElixirQueue.WorkerPool
36+
Aqui está o módulo capaz de se comunicar tanto com a fila quanto com os _workers_. Quando a _Application_ é iniciada e os processos supervisionados, um dos eventos que ocorre é justamente a inciação dos _workers_ sob a supervisão do `ElixirQueue.WorkerSupervisor`, um `DynamicSupervisor`, e os seus respectivos _PIDs_ são adicionados ao estado do `ElixirQueue.WorkerPool`. Além disso, cada worker iniciado dentro do escopo do `WorkerPool` é também supervisionado por esse, o motivo disso será abordado a frente.
37+
38+
Quando o `WorkerPool` recebe o pedido para executar um _job_ ele procura por algum de seus _workers PIDs_ que esteja no estado ocioso, ou seja, sem nenhum job sendo executado no momento. Então, para cada _job_ recebido pelo `WorkerPool` através de seu `perform/1`, este vincula o _job_ a um _worker PID_, que passa para o estado de ocupado. Quando o worker termina a execução, limpa seu estado e então fica ocioso, esperando por outro _job_. No caso, isso que aqui chamamos de _worker_ nada mais é do que um `Agent` que guarda em seu estado qual job está sendo executado vinculado àquele PID; ele serve de lastro limitante uma vez que o `WorkerPool` incia uma nova `Task` para cada _job_. Imagine o cenário onde não tivessemos _workers_ para limitar a quantidade de jobs sendo executados concorrentemente: o nosso `EventLoop` inciaria `Task`s a bel prazer, podendo causar grande problemas como estouro de memória caso a `Queue` recebesse uma grande carga de _jobs_ de uma só vez.
39+
40+
### ElixirQueue.Worker
41+
Neste módulo temos os atores responsáveis por tomar os _jobs_ e executá-los. Porém o processo é um pouco mais do que apenas a execução do job; na verdade funciona da seguinte forma:
42+
1. O `Worker` recebe um pedido para performar um certo _job_, adiciona-o ao seu estado interno (estado interno do `Agent` do `PID` passado) e também a uma tabela `:ets` de backup;
43+
2. Depois segue para a execução do _job_ em si. É **extremamente necessário lembrarmos** que a execução ocorre no escopo da `Task` invocada pelo `WorkerPool`, e não no escopo do processo do Agent. Foi uma opção de implementação, poderia ser feito de outras diversas formas porém escolhi assim pela simplicidade que acredito ter ficado o código.
44+
3. Finalmente, com o _job_ finalizado, o `Worker` volta seu estado interno para ocioso e exclui o backup deste worker da tabela `:ets`.
45+
4. Ademais, com a função do `Worker` tendo sido cumprida, a trilha de execução volta para a `Task` invocada pelo `WorkerPool` e apenas completa a corrida inserindo o _job_ na lista de _jobs_ bem sucedidos.
46+
47+
#### Por que o `WorkerPool` superviosiona `Worker`s? Ou: e se `Worker` morrer, como ficamos?
48+
Como ficou claro na explicação, não existe nenhum ponto da trilha de execução dos _jobs_ onde nos preocupamos com a questão: o que acontece se uma função mal feita for passada como _job_ para a fila de execução, _ou até mesmo!_, o que ocorre caso algum processo `Agent` `Worker` simplesmente corromper a memória e morrer? Pois bem, na intenção de escrever o código da forma mais perene possível, talvez o mais _Elixir like_ o possível, o que foi feito é justamente adicionar garantias de que se os processos falharem (e falharão!) o sistema consiga reagir de tal forma que mitigue os erros.
49+
50+
Na ocasião da falha de algum worker que acarrete em sua morte via _EXIT signal_ o `WorkerPool`, que monitora todos os seus workers via `Process.monitor`, repõe este worker morto por outro, adicionando-o ao `WorkerSupervisor`. Com isso também remove o `PID` do `Worker` morto da lista de `PID`s e adiciona o novo. Porém não para por ai: o `WorkerPool` checa por algum backup criado do `Worker` morto e, encontrando, repõe o _job_ na fila com seu valor de _attempt_retry_ adicionado de um. O `WorkerPool` sempre irá adicionar o _job_ novamente na fila uma quantidade pré-determinada de vezes, definida no arquivo `mix.exs`, no _environment_ da _application_.
51+
52+
## 🏃 Análise de desempenho
53+
### Por que `Tuple` ao invés de `List`
54+
Para fila de processos funcionar normalmente eu preciso apenas de inserir no final e retirar do início. Claramente isso pode ser feito tanto com `List` quanto com `Tuple`, e acabei optando por tuple pelo simples fato de ser mais rápido. Direto do _output_ do Benchee rodando `mix run benchmark.exs` na raiz do projeto:
55+
```
56+
Operating System: Linux
57+
CPU Information: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
58+
Number of Available Cores: 8
59+
Available memory: 15.56 GB
60+
Elixir 1.10.2
61+
Erlang 22.3.2
62+
63+
Benchmark suite executing with the following configuration:
64+
warmup: 2 s
65+
time: 5 s
66+
memory time: 0 ns
67+
parallel: 1
68+
inputs: none specified
69+
Estimated total run time: 14 s
70+
71+
Benchmarking Insert element at end of linked list...
72+
Benchmarking Insert element at end of tuple...
73+
74+
Name ips average deviation median 99th %
75+
Insert element at end of tuple 4.89 K 204.42 μs ±72.94% 119.45 μs 571.85 μs
76+
Insert element at end of linked list 1.24 K 808.27 μs ±54.67% 573.68 μs 1896.52 μs
77+
78+
Comparison:
79+
Insert element at end of tuple 4.89 K
80+
Insert element at end of linked list 1.24 K - 3.95x slower +603.85 μs
81+
```
82+
83+
### Teste de estresse
84+
Preparei um teste de estresse para a aplicação que enfileira 1000 _fake jobs_, cada um ordenando uma `List` reversamente ordenada com 3 milhões de elementos, utilizando o `Enum.sort/1` (de acordo com a documentação, o algoritmo é um _merge sort_). Para executá-lo basta entrar no terminal via `iex -S mix` e rodar `ElixirQueue.Fake.populate`; a execução leva alguns minutos (e pelo menos uns 2gb de RAM), e depois você pode conferir os resultados com `ElixirQueue.Fake.spec`.
85+
86+
## 💼 Exemplos de uso
87+
Para ver a fila de processos funcionando basta executar `iex -S mix` na raiz do projeto e utilizar os comandos abaixo. A menos que você esteja em modo `test`, você verá _logs_ de informação sobre a execução do _job_.
88+
89+
### ElixirQueue.Queue.perform_later/1
90+
É possível construir a _struct_ do _job_ manualmente e passá-lo para a fila.
91+
```ex
92+
iex> job = %ElixirQueue.Job{mod: Enum, func: :reverse, args: [[1,2,3,4,5]]}
93+
iex> ElixirQueue.Queue.perform_later(job)
94+
:ok
95+
```
96+
97+
### ElixirQueue.Queue.perform_later/3
98+
Além disso também podemos passar manualmente os valores do módulo, função e argumentos para `perform_later/3`.
99+
```ex
100+
iex> ElixirQueue.Queue.perform_later(Enum, :reverse, [[1,2,3,4,5]])
101+
:ok
102+
```

0 commit comments

Comments
 (0)