DEV Community

Maiqui Tom√© ūüáßūüá∑
Maiqui Tom√© ūüáßūüá∑

Posted on

ūüíßūüí™Concorr√™ncia em Elixir #1: M√≥dulo Task

Para melhor entendimento desse post, indico a leitura de: Olá mundo da programação concorrente.

Este post é uma tradução da parte que fala sobre o módulo Task do livro Concurrent Data Processing in Elixir de Svilen Gospodinov.

Conforme o livro Concurrent Data Processing in Elixir, o processamento de dados √© uma parte essencial de muitas aplica√ß√Ķes de software. Na verdade, a maioria dos engenheiros nem sequer pensa nisso como algo separado da programa√ß√£o. Mas se voc√™ est√° transformando informa√ß√Ķes de alguma forma, por exemplo, ao fazer relat√≥rios, agrega√ß√£o de dados ou an√°lises, ent√£o voc√™ est√° fazendo processamento de dados.

Graças à Máquina Virtual Erlang (também conhecida como BEAM), todos que usam Elixir se beneficiam de seu incrível modelo de concorrência, sendo particularmente adequado para tarefas concorrentes de longa duração. Como resultado, você verá que a linguagem Elixir oferece mais maneiras de realizar trabalhos concorrentes do que outras linguagens.

Embora isto seja uma coisa boa, também pode ser um desafio encontrar a ferramenta certa para o trabalho. Algumas tarefas são uma excelente escolha para Flow, enquanto outras são perfeitas para Broadway. Às vezes é mais fácil usar apenas o GenStage, ou mesmo o módulo Task.

As técnicas certas o ajudarão a simplificar seu produto, melhorar o desempenho de seu código e tornar sua aplicação mais resistente a erros e ao aumento da carga de trabalho.

Introdução

Desde o in√≠cio da ind√ļstria da computa√ß√£o, os fabricantes de hardware e cientistas da computa√ß√£o t√™m tentado tornar os computadores mais r√°pidos na execu√ß√£o de programas. No in√≠cio, a multithreading era a √ļnica maneira de alcan√ßar a concorr√™ncia, sendo a capacidade de executar duas ou mais tarefas de programa√ß√£o e alternar entre elas para coletar os resultados. Foi assim que os computadores pareciam estar fazendo muitas coisas ao mesmo tempo, quando, na verdade, eram simplesmente multitarefas.

As CPUs multi-core mudaram isso. Trouxeram paralelismo e permitiram que as tarefas funcionassem lado a lado, independentemente, o que aumentou significativamente o desempenho dos sistemas. Seguiram-se arquiteturas de multiprocessadores, permitindo ainda maior concorr√™ncia e paralelismo ao suportar duas ou mais CPUs em uma √ļnica m√°quina. A figura abaixo mostra uma compara√ß√£o simples entre a concorr√™ncia em uma CPU de n√ļcleo √ļnico e em uma CPU de n√ļcleo duplo que permite o paralelismo:

Image description

Naturalmente, as ferragens de √ļltima gera√ß√£o v√™m sempre com uma etiqueta de pre√ßo elevado. Mas com o advento da computa√ß√£o em nuvem, as coisas mudaram mais uma vez. Hoje em dia, √© poss√≠vel executar c√≥digo em servi√ßos na nuvem usando m√°quinas virtuais com dezenas de n√ļcleos de CPU, sem a necessidade de comprar e manter qualquer hardware f√≠sico.

Estes avan√ßos s√£o importantes para n√≥s como engenheiros de software. Queremos escrever um software que tenha bom desempenho e funcione rapidamente. Afinal de contas, ningu√©m gosta de carregar telas e esperar que o computador termine. Entretanto, a execu√ß√£o do c√≥digo em um sistema de processador multi-core n√£o o torna automaticamente eficiente. De modo a aproveitar ao m√°ximo os recursos computacionais dispon√≠veis, precisamos escrever software tendo em mente a concorr√™ncia e o paralelismo. Felizmente, as modernas linguagens de programa√ß√£o tentam nos ajudar o m√°ximo poss√≠vel, e a linguagem Elixir n√£o √© exce√ß√£o. De fato, gra√ßas ao Erlang, a M√°quina Virtual Erlang (BEAM) e a Plataforma de Telecomunica√ß√Ķes Abertas (OTP - Open Telecom Platform), o Elixir √© uma excelente escolha para a constru√ß√£o de aplica√ß√Ķes e processamento de dados concorrentes.

Vamos abordar as ferramentas mais populares para a realiza√ß√£o de trabalhos concorrentes usando Elixir. Vamos verificar os pr√≥s e contras de cada uma e ver como funcionam na pr√°tica. Algumas delas, como o m√≥dulo Task e o GenServer, j√° v√™m com Elixir. Os outros - GenStage, Flow e Broadway - est√£o dispon√≠veis como bibliotecas aut√īnomas no registro de pacotes Hex.pm. Saber como utilizar cada uma destas ferramentas ajudar√° voc√™ a alavancar a concorr√™ncia da maneira mais eficaz e resolver at√© mesmo os problemas mais desafiadores. Ao longo do caminho, voc√™ tamb√©m aprender√° como construir aplica√ß√Ķes tolerantes a falhas, recuperar-se de falhas, usar a contrapress√£o para lidar com recursos limitados do sistema e muitas outras t√©cnicas √ļteis.

Primeiro, vamos analisar o módulo Task, que faz parte da biblioteca padrão da linguagem Elixir. Ele possui um poderoso conjunto de características que o ajudarão a executar o código concorrentemente. Você também vai ver como lidar com erros e evitar que a aplicação trave quando uma tarefa concorrente trava.


Concorrência fácil com o módulo Task

Para executar o código concorrentemente em Elixir, você tem que iniciar um processo e executar seu código dentro desse processo. Você também pode precisar recuperar o resultado e usá-lo para algo mais. Elixir fornece uma função de baixo nível e uma macro para fazer isso - spawn/1 e receive. Entretanto, usá-los pode ser complicado na prática e você provavelmente acabará com um monte de código repetitivo.

Elixir também vem com um módulo chamado Task, que simplifica significativamente o início de processos concorrentes. Ele fornece uma abstração para executar o código concorrentemente, recuperando resultados, manipulando erros e iniciando uma série de processos. Ele possui muitas características com uma API concisa, de modo que raramente (ou nunca) há a necessidade de usar spawn/1 e receive.

Vamos conhecer tudo o que o módulo Task tem a oferecer:

  • Como iniciar tarefas;
  • Diferentes maneiras de recuperar resultados;
  • Como lidar com processamento de grandes listas de dados;
  • Como lidar com falhas;
  • Como funciona a liga√ß√£o de processos em Elixir;
  • Como usar um dos m√≥dulos Supervisor integrados para isolar falhas de processo;
  • Discuss√£o sobre a abordagem de Elixir para o tratamento de erros.

Antes de mergulharmos, vamos criar um projeto Elixir para trabalhar primeiro e nos familiarizarmos com algumas das ferramentas de desenvolvimento que vamos usar ao longo deste post.

O que é um processo de Elixir?

Os processos na Elixir são processos Erlang, uma vez que a Elixir funciona na Máquina Virtual Erlang. Ao contrário dos processos do sistema operacional, eles são muito leves em termos de uso de memória e rápidos de iniciar. A VM de Erlang sabe como executá-los concorrentemente e em paralelo (quando uma CPU multi-core está presente). Como resultado, ao utilizar processos, você obtém concorrência e paralelismo de graça.

Criando o projeto

Vamos criar uma aplicação chamada sender e fingir que estamos enviando e-mails para endereços de e-mail reais. Vamos usar o módulo Task mais tarde para desenvolver algumas de suas funcionalidades.

Primeiramente, vamos usar a ferramenta de linha de comando mista para a montagem de nosso novo projeto:

$ mix new sender --sup
Enter fullscreen mode Exit fullscreen mode

Isto cria um diretório para o nosso projeto sender com um monte de arquivos e pastas dentro. Note que também utilizamos o argumento --sup, que criará uma aplicação com uma árvore de supervisão. Veremos sobre árvores de supervisão mais adiante.

Para manter nosso projeto e nossos exemplos simples, n√£o vamos enviar e-mails de verdade. Entretanto, ainda precisamos de alguma l√≥gica comercial para nossas experi√™ncias. Podemos usar a fun√ß√£o Process.sleep/1 para fingir que estamos enviando um e-mail, que normalmente √© uma opera√ß√£o lenta e pode levar alguns segundos para ser conclu√≠da. Quando chamado com um n√ļmero inteiro, o Process.sleep/1 interrompe o processo atual durante um determinado per√≠odo em milissegundos. Isto √© muito √ļtil, porque voc√™ pode us√°-lo para simular um c√≥digo que leva muito tempo para ser completo. Voc√™ tamb√©m pode us√°-lo para testar v√°rios casos extremos, como veremos mais tarde. Naturalmente, em aplica√ß√Ķes de produ√ß√£o real, voc√™ substituir√° isto por sua l√≥gica comercial real. Mas por enquanto, vamos fingir que estamos fazendo um trabalho muito intensivo.

Abra seu arquivo sender/lib/sender.ex e adicione o seguinte:

def send_email(email) do
  Process.sleep(3000)
  IO.puts("Email to #{email} sent")
  {:ok, "email_sent"}
end
Enter fullscreen mode Exit fullscreen mode

Ao chamar esta fun√ß√£o, a execu√ß√£o ser√° suspensa por tr√™s segundos e uma mensagem ser√° impressa, o que ser√° √ļtil para a depura√ß√£o. Tamb√©m retorna uma tupla {:ok, "email_sent"} para indicar que o email foi enviado com sucesso.

Agora que tudo está pronto, podemos começar. Sugiro que você mantenha uma sessão de terminal com o IEx aberta e seu editor de texto favorito ao lado, para você poder fazer e executar mudanças à medida que avançamos.

Iniciando Tasks e Recuperando Resultados

Antes de saltar para o módulo Task, vamos ver como as coisas estão funcionando. Vamos chamar a função send_email/1 do IEx, e passar um endereço de email fictício como um argumento.

iex> Sender.send_email("hello@world.com")
Email to hello@world.com sent
{:ok, "email_sent"}
Enter fullscreen mode Exit fullscreen mode

Você notou o atraso/delay? Tivemos que esperar três segundos até vermos a produção e o resultado impresso. Na verdade, mesmo o iex> prompt não estava aparecendo. Vamos adicionar outra função, notify_all/1:

Em sender/lib/sender.ex:

def notify_all(emails) do
  Enum.each(emails, &send_email/1)
end
Enter fullscreen mode Exit fullscreen mode

A função notify_all/1 usa Enum.each/2 para iterar sobre os emails variados, que é uma lista de strings. Para cada item da lista, vamos chamar a função send_email/1. Vamos testar esta função no IEx, mas primeiro precisamos de alguns dados de teste. Crie um arquivo .iex.exs na pasta principal do projeto sender no mesmo local onde está o mix.exs. Adicione o seguinte:

Em sender/.iex.exs:

emails = [
  "hello@world.com",
  "hola@world.com",
  "nihao@world.com",
  "konnichiwa@world.com",
]
Enter fullscreen mode Exit fullscreen mode

Salve o arquivo, saia do IEx e inicie-o novamente com iex -S mix. Digite emails e pressione enter:

iex(1)> emails
["hello@world.com", "hola@world.com", "nihao@world.com",
"konnichiwa@world.com"]
Enter fullscreen mode Exit fullscreen mode

Isto lhe poupar√° muita digita√ß√£o. Todo o c√≥digo Elixir em .iex.exs ser√° executado quando o IEx iniciar, portanto, isto persistir√° entre as sess√Ķes do IEx. Agora vamos usar os dados de teste com notify_all/1. Voc√™ pode adivinhar quanto tempo levar√° para enviar todos os emails? Vamos descobrir:

iex> Sender.notify_all(emails)
Email to hello@world.com sent
Email to hola@world.com sent
Email to nihao@world.com sent
Email to konnichiwa@world.com sent
:ok
Enter fullscreen mode Exit fullscreen mode

Foram necessárias quatro chamadas para send_email/1 e cerca de doze segundos para completar. Só de esperar a saída no IEx parecia uma eternidade. Isto não é nada bom!!! À medida que a nossa base de usuários cresce, levará uma eternidade para enviar nossos emails.

Não se desespere - podemos acelerar significativamente nosso código usando o módulo Task. No entanto, antes de saltarmos para dentro dele, vamos tirar um momento para falar sobre dois conceitos importantes na programação: código síncrono e assíncrono.

Código síncrono e assíncrono

Tanto o código síncrono como assíncrono são executados aos mesmo tempo, a diferença é que o código assíncrono não há uma ordem específica e por isso é considerado concorrente, diferentemente do síncrono que há uma sequencia específica.

Por padrão, quando você executa algum código em Elixir, você tem que esperar que ele seja concluído. Você não pode fazer nada enquanto isso, e você obtém o resultado assim que o código tiver terminado. O código é executado de forma síncrona e é algumas vezes chamado de código bloqueador (blocking code).

O oposto disto é executar o código de forma assíncrona. Neste caso, você pede ao tempo de execução da programação para executar o código, mas continua com o resto do programa. O código assíncrono roda em segundo plano porque a aplicação continua rodando, como se nada tivesse acontecido. Eventualmente, quando o código assíncrono termina, você pode recuperar o resultado. Como o código assíncrono não bloqueia a execução principal do programa, ele é chamado de código não-bloqueador (non-blocking code). O código assíncrono também é concorrente, já que nos permite continuar fazendo outros trabalhos. A figura abaixo ilustra a diferença entre código síncrono e assíncrono em um programa de panificação:

Image description

  1. Mix flour = Misture a farinha
  2. Rest the dough = Descanse a massa
  3. Pre-heat oven = Pré-aqueça o forno
  4. Bake = Assar

Uma vez que apenas a etapa final de cozimento (Bake) requer que o forno seja pr√©-aquecido (Pre-heat), voc√™ pode fazer o pr√©-aquecimento do forno de forma ass√≠ncrona e fazer algo mais enquanto isso. Em compara√ß√£o com a vers√£o s√≠ncrona, isto diminui significativamente o tempo necess√°rio para completar o conjunto de instru√ß√Ķes.

Voc√™ j√° deve ter adivinhado que a fun√ß√£o notify_all/1 est√° enviando cada email de forma s√≠ncrona. √Č por isso que est√° demorando tanto para ser conclu√≠da. Para melhorar isto, vamos converter nosso c√≥digo para ser executado de forma ass√≠ncrona. Gra√ßas ao m√≥dulo Task do Elixir, precisaremos apenas de algumas pequenas mudan√ßas para conseguir isso. Vamos ver como funciona.

Iniciando processos

O m√≥dulo Task cont√©m uma s√©rie de fun√ß√Ķes muito √ļteis para executar c√≥digo de forma ass√≠ncrona e concorrente. Uma delas √© start/1. Ele aceita uma fun√ß√£o como um argumento, e dentro dessa fun√ß√£o devemos fazer todo o trabalho que pretendemos fazer. Vamos experimentar isso rapidamente no IEx:

iex> Task.start(fn -> IO.puts("Hello async world!") end)
Hello async world!
{:ok, #PID<0.266.0>}
Enter fullscreen mode Exit fullscreen mode

Voc√™ viu a mensagem impressa instantaneamente porque √© uma opera√ß√£o r√°pida, mas o resultado real foi {:ok, #PID<0.266.0>}. Retornar uma tupla como {:ok, result} ou {:erro, message} √© uma pr√°tica comum no Elixir. O resultado foi :ok para o sucesso e #PID<0.266.0>. PID significa identificador de processo (process identifier) - um n√ļmero que identifica de forma √ļnica um processo Elixir.

Já temos o send_email/1 pronto, por isso podemos usar o Task.start/1 para chamá-lo. Vamos fazer algumas mudanças:

Em sender/lib/sender.change1.ex:

def notify_all(emails) do
  Enum.each(emails, fn email ->
    Task.start(fn ->
    send_email(email)
    end)
  end)
end
Enter fullscreen mode Exit fullscreen mode

Em seguida, recompilar e executar notify_all/1:

iex> Sender.notify_all(emails)
:ok
Email to hello@world.com sent
Email to hola@world.com sent
Email to nihao@world.com sent
Email to konnichiwa@world.com sent
Enter fullscreen mode Exit fullscreen mode

Isto deve ser significativamente mais r√°pido - de fato, quatro vezes mais r√°pido! Todas as fun√ß√Ķes foram chamadas concorrentemente e terminadas ao mesmo tempo, imprimindo a mensagem de sucesso como esper√°vamos.

Recuperando o resultado de uma tarefa

Task.start/1 tem uma limita√ß√£o por projeto: n√£o retorna o resultado da fun√ß√£o que foi executada. Isto pode ser √ļtil em alguns casos, mas na maioria das vezes voc√™ precisa do resultado para alguma outra coisa. Seria √≥timo se modific√°ssemos nosso c√≥digo e retorn√°ssemos um resultado significativo quando todos os emails forem enviados com sucesso.

Para recuperar o resultado de uma função, você tem que usar o Task.async/1. Ela retorna uma estrutura %Task{} que você pode atribuir a uma variável para uso posterior. Você pode experimentá-la no IEx assim:

iex> task = Task.async(fn -> Sender.send_email("hello@world.com") end)
%Task{
  owner: #PID<0.145.0>,
  pid: #PID<0.165.0>,
  ref: #Reference<0.713486762.1657274369.63141>
}
Enter fullscreen mode Exit fullscreen mode

O código send_email/1 está agora rodando em segundo plano. Enquanto isso, estamos livres para fazer outros trabalhos, conforme necessário. Você pode adicionar mais lógica comercial ou até mesmo iniciar outras tarefas. Quando você precisar do resultado real da tarefa, você pode recuperá-la usando a variável task. Vamos dar uma olhada mais de perto no que esta variável contém:

  • owner (propriet√°rio) √© o PID do processo que iniciou o processo Task.
  • pid √© o identificador do pr√≥prio processo Task.
  • ref √© a refer√™ncia do monitor de processo (process monitor reference).

N√£o vamos nos aprofundar no monitoramento de processos (Process monitoring) aqui. Entretanto, vale a pena saber que voc√™ pode monitorar um processo e receber notifica√ß√Ķes dele usando um valor de refer√™ncia - por exemplo, quando e como um processo sai.

Para recuperar o resultado da tarefa, você pode usar tanto o Task.await/1 ou o Task.yield/1 que aceita uma estrutura Task como um argumento. Há uma diferença importante na forma de trabalho do await/1 e do yield/1, portanto, você tem que escolher sabiamente. Ambos param o programa e tentam recuperar o resultado da tarefa. A diferença vem da maneira como eles lidam com os tempos de espera do processo (process timeouts).

Os tempos de espera do processo garantem que os processos não fiquem presos para sempre. Para mostrar como eles funcionam, vamos aumentar o tempo que a função send_email/1 leva para 30 segundos, apenas temporariamente:

def send_email(email) do
  Process.sleep(30_000)
  IO.puts("Email to #{email} sent")
  {:ok, "email_sent"}
end
Enter fullscreen mode Exit fullscreen mode

Em seguida, recompile/0 no IEx . Agora vamos executar o código de forma assíncrona e canalizar o resultado da task em await/1:

iex> Task.async(fn -> Sender.send_email("hi@world.com") end) |> Task.await()
Enter fullscreen mode Exit fullscreen mode

Após cinco segundos, você receberá uma exceção semelhante a esta:

** (exit) exited in: Task.await(%Task{owner: #PID<0.144.0>,
  pid: #PID<0.151.0>,
  ref: #Reference<0.2297312895.3696492546.156249>}, 5000)
    ** (EXIT) time out
    (elixir) lib/task.ex:607: Task.await/2
Enter fullscreen mode Exit fullscreen mode

Ao utilizar o await/1, esperamos que uma tarefa termine dentro de um certo período de tempo. Por padrão, este tempo é definido para 5000ms, que é de cinco segundos. Você pode mudar isso passando um inteiro com a quantidade de milissegundos como segundo argumento, por exemplo Task.await(task, 10_000). Você também pode desativar o tempo limite passando o átomo :infinity.

Em comparação, o Task.yield/1 simplesmente retorna nulo se a tarefa não tiver sido concluída. O timeout de yield/1 também é de 5000ms, mas não causa uma exceção e um travamento (crash). Você também pode fazer Task.yield(task) repetidamente para verificar um resultado, o que não é permitido por await/1. Uma tarefa concluída retornará {:ok, resultado} ou {:exit, reason}. Você pode ver isto em ação:

iex> task = Task.async(fn -> Sender.send_email("hi@world.com") end)
%Task{
owner: #PID<0.135.0>,
pid: #PID<0.147.0>,
ref: #Reference<0.3033103973.1551368196.24818>
}
iex> Task.yield(task)
nil
iex> Task.yield(task)
nil
Email to hi@world.com sent
iex> Task.yield(task)
{:ok, {:ok, "email_sent"}}
iex> Task.yield(task)
nil
Enter fullscreen mode Exit fullscreen mode

Esta saída ilustra o que acontece quando você chama Task.yield/1 enquanto a tarefa ainda está em execução - você recebe nil (nulo). Assim que a mensagem de sucesso é impressa, recebemos {:ok, {:ok, "e-mail_sent"}} como esperado. Você também pode usar yield/2 e fornecer seu próprio timeout (tempo de espera), de modo semelhante a await/2, mas a opção :infinity não é permitida. Note que você só receberá o resultado de yield/1 uma vez, e as chamadas subsequentes retornarão nil.

Voc√™ pode estar se perguntando o que acontece se nossa tarefa est√° emperrada e nunca termina? Enquanto await/1 se encarrega de parar a tarefa, o yield/1 a deixar√° em funcionamento. √Č uma boa id√©ia parar a tarefa manualmente, chamando o Task.shutdown(task). A fun√ß√£o shutdown/1 tamb√©m aceita um timeout e d√° ao processo uma √ļltima chance de ser conclu√≠do, antes de par√°-lo. Se ele for conclu√≠do, voc√™ receber√° o resultado normalmente. Voc√™ tamb√©m pode parar um processo imediatamente (e de forma bastante violenta) usando o √°tomo :brutal_kill como segundo argumento.

Como você pode ver, usar yield/1 e shutdown/1 é um pouco mais trabalhoso do que await/1. Qual deles usar depende de seu caso de uso. Muitas vezes, o timeout (tempo de espera) da tarefa justifica uma exceção, caso em que o await/1 será mais conveniente de usar. Sempre que você precisar de mais controle sobre o timeout e o encerramento, você pode mudar para yield/1 e shutdown/1. A parte importante é que, após utilizar async/1, você deve sempre processar o resultado, usando ou await/1, ou yield/1 seguido de shutdown/1.

Para a nossa lógica notify_all/1, vamos usar o await/1 para simplificar. Lembre-se de reverter nossa mudança anterior no send_email/1 e definir Process.sleep/1 de volta para 3000ms:

def send_email(email) do
  Process.sleep(3000)
Enter fullscreen mode Exit fullscreen mode

Agora vamos trocar Task.start/1 para Task.async/1:

Em sender/lib/sender.change2.ex:

def notify_all(emails) do
  emails
  |> Enum.map(fn email ->
    Task.async(fn ->
      send_email(email)
    end)
  end)
  |> Enum.map(&Task.await/1)
end
Enter fullscreen mode Exit fullscreen mode

Observe que tamb√©m estamos usando Enum.map/2 em vez de Enum.each/2, porque queremos mapear cada seq√ľ√™ncia de e-mails para sua estrutura de tarefas correspondente. A estrutura √© necess√°ria para recuperar o resultado de cada processo.

Usamos aqui a sintaxe da função shorthand (abreviação) do Elixir - &Task.await/1. Se você não está familiarizado com ela, é simplesmente equivalente à escrita:

Enum.map(fn task ->
  Task.await(task)
end)
Enter fullscreen mode Exit fullscreen mode

Vamos experimentar as √ļltimas mudan√ßas no IEx:

iex> Sender.notify_all(emails)
Email to hello@world.com sent
Email to hola@world.com sent
Email to nihao@world.com sent
Email to konnichiwa@world.com sent
[ok: "email_sent", ok: "email_sent", ok: "email_sent", ok: "email_sent"]
Enter fullscreen mode Exit fullscreen mode

A função retornou uma lista de resultados. Para cada tarefa, você tem uma tupla ok: "email_sent", que é o que a função send_email/1 retorna. Em sua lógica comercial, você pode retornar um {:erro, "mensagem de erro"} quando algo der errado. Então você será capaz de coletar o erro e potencialmente tentar novamente a operação ou fazer algo mais com o resultado.

A cria√ß√£o de tarefas a partir de listas de itens √© na verdade muito comum em Elixir. Em seguida, vamos usar uma fun√ß√£o especificamente projetada para fazer isso. Ela tamb√©m oferece uma s√©rie de recursos adicionais, especialmente √ļteis quando se trabalha com grandes listas.

Keyword Lists em Elixir

Elixir tem uma notação especial para listas contendo tuplas de chave-valor, também conhecidas como keyword lists (listas de palavras-chave). Cada item da lista de palavras-chave deve ser uma tupla de dois elementos. O primeiro elemento é o nome da chave, que deve ser um átomo. O segundo é o valor e pode ser de qualquer tipo.

Elas são exibidas no IEx sem as chaves (curly braces), assim [ok: "email_sent"]. Isto é equivalente a [{:ok, "email_sent"}].

Gerenciamento de séries de tarefas

Imaginemos que temos um milhão de usuários, e queremos enviar um e-mail a todos eles. Podemos usar Enum.map/2 e Task.async/1 como fizemos antes, mas iniciar um milhão de processos colocará uma pressão repentina sobre os recursos de nosso sistema. Isso pode degradar o desempenho do sistema e potencialmente fazer com que outros serviços não respondam. Nosso provedor de serviços de e-mail também não ficará feliz, pois também colocamos muita pressão sobre a infra-estrutura de e-mail deles.

Por outro lado, não queremos enviar e-mails um a um, porque é lento e ineficiente. Parece que estamos em uma encruzilhada e, seja qual for o caminho que tomemos, acabamos em perigo. Não queremos escolher entre desempenho e confiabilidade - queremos ser capazes de executar processos do módulo Task para alavancar a concorrência, mas garantir que não sobrecarregamos nossos recursos de sistema à medida que dimensionamos nosso produto e aumentamos nossa base de usuários.

A solu√ß√£o para nossos problemas √© async_stream/3. √Č outra fun√ß√£o muito √ļtil do m√≥dulo Task que √© projetado para criar processos de tarefas a partir de uma lista de itens. Para cada item da lista, async_stream/3 iniciar√° um processo e executar√° a fun√ß√£o que fornecemos para processar o item. Funciona como Enum.map/2 e Task.async/2 combinados, com uma grande diferen√ßa: voc√™ pode definir um limite no n√ļmero de processos em execu√ß√£o ao mesmo tempo. A figura abaixo ilustra como isto funciona:

Image description

Neste exemplo, o limite de concorrência é fixado em quatro, portanto, mesmo que você tenha uma lista de cem itens, no máximo apenas quatro processos serão executados de forma concorrente em um determinado momento. Este é um exemplo de tratamento de contrapressão/retaguarda (back-pressure), que podemos discutir em profundidade em um próximo post, falando sobre pipelines de processamento de dados com GenStage. Por enquanto, tudo o que você precisa saber é que esta estratégia de manuseio de processos é ótima para evitar picos repentinos de uso do sistema. Em outras palavras, você obtém a concorrência e o desempenho sem sacrificar a confiabilidade.

Image description

Como o nome da fun√ß√£o sugere, async_stream/3 retorna um Stream. Streams em Elixir s√£o estruturas de dados que realizam uma ou mais opera√ß√Ķes que n√£o funcionam imediatamente, somente quando explicitamente informado. √Č por isso que √†s vezes s√£o chamados de listas pregui√ßosas. Vamos ver o que acontece quando executamos esta fun√ß√£o a partir do IEx:

iex> Task.async_stream(emails, &Sender.send_email/1)
#Function<1.35903181/2 in Task.build_stream/3>
Enter fullscreen mode Exit fullscreen mode

Em vez do resultado habitual, recebemos uma fun√ß√£o, que vai criar um Stream. √Č importante entender como funcionam os fluxos, ent√£o vamos dar outro exemplo r√°pido no IEx:

iex> Stream.map([1, 2, 3], & &1 * 2)
#Stream<[
  enum: [1, 2, 3],
  funs: [#Function<49.33009823/1 in Stream.map/2>]
]>
Enter fullscreen mode Exit fullscreen mode

Se tiv√©ssemos usado Enum.map/2 a fun√ß√£o teria retornado [2, 4, 6]. Em vez disso, o fluxo de resultados cont√©m simplesmente a entrada inicial e uma lista de opera√ß√Ķes funs. Estas opera√ß√Ķes podem ser executadas posteriormente. Como tanto o Stream quanto o Enum implementam o protocolo Enumerable, muitas fun√ß√Ķes Enum t√™m uma alternativa pregui√ßosa no m√≥dulo Stream.

Uma maneira de executar um fluxo √© usar a fun√ß√£o Stream.run/1. Entretanto, o Stream.run/1 sempre retorna :ok, ent√£o s√≥ √© √ļtil quando voc√™ n√£o est√° interessado no resultado final. Ao inv√©s disso, voc√™ pode usar Enum.to_list/1, que tentar√° converter o fluxo para uma estrutura de dados List. Como resultado desta convers√£o, todas as opera√ß√Ķes no fluxo ser√£o executadas, e o resultado ser√° retornado como uma lista. Outras fun√ß√Ķes no m√≥dulo Enum tamb√©m for√ßar√£o o fluxo a ser executado, como Enum.reduce/3. Voc√™ pode us√°-las se pretender trabalhar mais com o resultado.

Agora, vamos atualizar o notify_all/1 para usar o async_stream/3. Desta vez, porém, vamos executar o stream usando Enum.to_list/1:

Em sender/lib/sender.change3.ex:

def notify_all(emails) do
  emails
  |> Task.async_stream(&send_email/1)
  |> Enum.to_list()
end
Enter fullscreen mode Exit fullscreen mode

E dê uma olhada no IEx, mas não se esqueça de rodar recompile/0 primeiro:

iex> Sender.notify_all(emails)
Email to hello@world.com sent
Email to hola@world.com sent
Email to nihao@world.com sent
Email to konnichiwa@world.com sent
[
  ok: {:ok, "email_sent"},
  ok: {:ok, "email_sent"},
  ok: {:ok, "email_sent"},
  ok: {:ok, "email_sent"}
]
Enter fullscreen mode Exit fullscreen mode

Por que async_stream retorna um Stream?

Os Streams s√£o projetados para emitir uma s√©rie de valores, um por um. Assim que o Task.async_stream/3 descobre que um processo de tarefa terminou, ele emitir√° o resultado e tomar√° o pr√≥ximo elemento da entrada, iniciando um novo processo. Isto significa que ele pode manter uma s√©rie de eventos concorrentes, o que √© um dos benef√≠cios do async_stream/3. Voc√™ tamb√©m pode usar todas as outras fun√ß√Ķes do m√≥dulo Stream para compor fluxos complexos de processamento de dados.

Como voc√™ pode ver, a sa√≠da √© semelhante √† do exemplo Task.async/2. Entretanto, dependendo de quantos n√ļcleos l√≥gicos (threads) sua m√°quina tem, o tempo que leva para que a fun√ß√£o seja conclu√≠da pode ser diferente.

Como mencionamos anteriormente, async_stream/3 mant√©m um limite em quantos processos podem ser executados ao mesmo tempo. Por padr√£o, este limite √© definido para o n√ļmero de n√ļcleos l√≥gicos dispon√≠veis no sistema. Anteriormente utiliz√°vamos o Task.async/2 para iniciar manualmente um processo para cada um dos quatro itens. Isto significa que se voc√™ tiver uma CPU com menos de quatro n√ļcleos l√≥gicos, o async_stream/3 parecer√° mais lento. Voc√™ pode alterar facilmente este comportamento padr√£o atrav√©s do par√Ęmetro opcional max_concurrency. Vamos definir o max_concurrency para 1 temporariamente:

|> Task.async_stream(&send_email/1, max_concurrency: 1)
Enter fullscreen mode Exit fullscreen mode

Quando voc√™ tentar as novas mudan√ßas novamente, voc√™ ver√° que os e-mails s√£o enviados um a um. Isto n√£o √© muito √ļtil na pr√°tica, mas demonstra como funciona o max_concurrency. Voc√™ pode reverter a mudan√ßa ou defini-la para um n√ļmero ainda maior. Em aplica√ß√Ķes de produ√ß√£o, voc√™ pode comparar (benchmark) se o max_concurrency funciona melhor para seu caso de uso, levando em considera√ß√£o os recursos de seu sistema e a necessidade de desempenho.

Outra opção que precisa ser mencionada é :ordered. Atualmente, async_stream/3 assume que queremos os resultados na mesma ordem em que foram originalmente ordenados. Esta preservação da ordem pode potencialmente retardar nosso processamento, pois async_stream/3 esperará que um processo lento seja concluído antes de passar para o próximo.

Em nosso caso, só precisamos dos resultados para verificar se um e-mail foi enviado com sucesso ou não. Não necessariamente precisamos deles exatamente na mesma ordem. Podemos potencialmente agilizar as coisas desativando a ordenação assim:

|> Task.async_stream(&send_email/1, ordered: false)
Enter fullscreen mode Exit fullscreen mode

Agora o async_stream/3 n√£o ficar√° parado se um processo estiver demorando mais do que outros.

Processos iniciados por async_stream/3 tamb√©m est√£o sujeitos a timeouts (tempo de resposta), assim como aqueles iniciados por start/1 e async/2. O par√Ęmetro opcional :timeout √© suportado e o valor padr√£o √© de 5000ms. Quando uma tarefa atinge o timeout, ela produzir√° uma exce√ß√£o, interrompendo o stream/fluxo e interrompendo o processo atual. Este comportamento pode ser alterado usando um argumento opcional :on_timeout, que voc√™ pode definir para :kill_task. Este argumento √© similar ao :brutal_kill suportado pelo Task.shutdown/2. Segue um exemplo:

|> Task.async_stream(&send_email/1, on_timeout: :kill_task)
Enter fullscreen mode Exit fullscreen mode

Como resultado do uso da opção :kill_task, quando um processo termina com um timeout, o async_stream/3 irá ignorá-lo e continuará normalmente.

Um processo pode falhar por muitas raz√Ķes. Falamos de exce√ß√Ķes de timeout, que acontecem quando os processos levam muito tempo para serem conclu√≠dos. Isto pode ser porque o trabalho que ele est√° fazendo consome muito tempo ou porque ele est√° esperando por uma API de terceiros lenta para responder. √Äs vezes √© um erro inesperado que causa uma exce√ß√£o. O importante √© que quando um processo trava, ele tamb√©m pode travar o processo que o iniciou, o que por sua vez trava seu processo pai, desencadeando uma rea√ß√£o em cadeia que pode, em √ļltima inst√Ęncia, travar toda a sua aplica√ß√£o.

Isto parece um desastre de programa√ß√£o esperando para acontecer, mas a boa not√≠cia √© que em Elixir, gra√ßas a Erlang, tem um conjunto de ferramentas poderosas para gerenciar processos, pegar acidentes e se recuperar rapidamente. Na pr√≥xima se√ß√£o, vamos explicar por que esta rea√ß√£o em cadeia √© uma coisa boa, como isol√°-la e como utiliz√°-la a nosso favor. Voc√™ tamb√©m come√ßar√° a aprender como construir as aplica√ß√Ķes tolerantes a falhas pelas quais a linguagem Elixir √© famosa.

Ligação de processos

Os processos em Elixir podem ser ligados entre si, e os processos do Task s√£o normalmente ligados automaticamente ao processo que os iniciou. Este v√≠nculo √© simplesmente chamado de liga√ß√£o de processos (process link). As liga√ß√Ķes de processos t√™m um papel importante na constru√ß√£o de aplica√ß√Ķes concorrentes e tolerantes a falhas. Elas nos ajudam a desligar imediatamente partes do sistema, ou mesmo todo o sistema quando necess√°rio, impedindo que a aplica√ß√£o funcione em um p√©ssimo estado. Vamos ver por que as liga√ß√Ķes de processos s√£o √ļteis e como funcionam, observando alguns exemplos. A figura a seguir mostra tr√™s processos ligados entre si:

Image description

Como você pode ver, o Processo C acabou de falhar. Imagine que o Processo B continua funcionando, sem saber do acidente. Ele espera receber um resultado do Processo C e se reportar ao Processo A. Entretanto, isto nunca acontecerá, portanto este sistema é mantido em um péssimo estado.

Isto pode ser evitado graças à ligação de processos. Quando dois processos estão ligados, eles formam uma relação especial - assim que um sai, o outro será notificado. Quando se tem uma cadeia de processos interligados, todos eles serão eventualmente notificados quando ocorrer um acidente. Por padrão, os processos interligados terminarão e limparão a memória que eles utilizavam, evitando outros problemas potenciais no decorrer da linha. Isto significa que o acidente do Processo C desencadeará uma reação em cadeia e o Processo B terminará, seguido pelo Processo A.

Entretanto, uma reação em cadeia como esta só faz sentido quando ocorre um erro grave. Dê uma olhada nesta figura, que mostra os processos em execução para uma aplicação web bancária:

Image description

A maioria das aplica√ß√Ķes web dependem muito de um banco de dados, e o acesso ao banco de dados √© geralmente gerenciado por um processo. Neste exemplo, o processo de Repo tem esta responsabilidade. Se o Repo falhar, porque o banco de dados se torna indispon√≠vel, esta aplica√ß√£o n√£o poder√° funcionar de forma alguma. Se isso acontecer, voc√™ pode deixar todos os processos terminarem, o que deixar√° o site offline.

Agora, vamos considerar o processo Sender, que é responsável pelo envio de e-mails. Ao contrário do processo de Repo , não é essencial conceder aos usuários acesso à sua conta bancária. Se o processo do Sender falhar por qualquer razão, não queremos encerrar a aplicação inteira. Queremos conter esta falha e deixar o resto do sistema continuar.

Você pode isolar os acidentes configurando um processo para capturar as saídas. Capturar uma saída significa reconhecer a mensagem de saída de um processo vinculado, mas continuar a funcionar em vez de terminar. Isto também significa que a mensagem de saída não será propagada mais para outros processos. Voltando ao nosso exemplo original - se o Processo B estiver configurado para capturar as saídas, ele continuará funcionando após a queda do Processo C, mantendo o Processo A seguro também. Você pode configurar um processo para capturar saídas manualmente, mas normalmente você quer usar um tipo especial de processo chamado supervisor. Vamos falar sobre supervisors a seguir.

Como voc√™ pode ver, as liga√ß√Ķes de processo s√£o um mecanismo essencial para detectar sa√≠das e lidar com falhas inesperadas de processo. A maioria das fun√ß√Ķes de utilidade no m√≥dulo Task cria links de processo automaticamente, ligando-se ao processo atual, mas h√° algumas que n√£o o fazem. Voc√™ tem a op√ß√£o de ligar ou n√£o ao processo atual, mas voc√™ tem que escolher a fun√ß√£o correta.

Por exemplo, quando usamos async/1 e async_stream/3, foi criado um link de processo para cada novo processo. Task.start/1, por outro lado, n√£o cria um link de processo, mas existe o Task.start_link/1 que faz exatamente isso.

A maioria das fun√ß√Ķes do m√≥dulo Task que se ligam ao processo atual por padr√£o t√™m uma fun√ß√£o alternativa, geralmente terminando com _nolink. Task.Supervisor.async_nolink/3 √© a alternativa ao Task.async/1. Task.async_stream/3 pode ser substitu√≠do pelo Task.Supervisor.async_stream_nolink/4. Todas as fun√ß√Ķes no m√≥dulo Task.Supervisor s√£o projetados para serem ligados a um supervisor.

A seguir, vamos aprender sobre supervisors e como podemos usar um quando iniciarmos processos de tarefas.


Conhecendo o Supervisor

Image description

Assim como no local de trabalho, onde os supervisores s√£o respons√°veis por grupos de funcion√°rios, os supervisors (supervisores) de Elixir s√£o respons√°veis pelos processos a eles atribu√≠dos. Os processos subordinados tamb√©m s√£o obrigados a se reportar a seu supervisor, que tem que garantir que tudo esteja funcionando sem problemas. Para isso, os supervisors v√™m com um conjunto de caracter√≠sticas que lhes permitem gerenciar de forma eficaz outros processos. Eles podem iniciar e interromper processos e reinici√°-los em caso de erros imprevistos no sistema. Eles s√£o configurados para capturar as sa√≠das, assim, quando um processo supervisionado sai com um erro, esse erro ser√° isolado e n√£o se propagar√° mais. Tudo isso faz dos supervisors um importante bloco de constru√ß√£o para aplica√ß√Ķes tolerantes a falhas.

Image description

Os processos supervisionados são chamados de child processes (processos filhos). Qualquer processo OTP pode ser supervisionado, e você também pode adicionar um supervisor como filho de outro supervisor. Tudo o que você tem que fazer é pedir ao supervisor que inicie o processo que você quer que seja gerenciado. Isto nos permite construir facilmente uma hierarquia de processos também chamada de supervision tree (árvore de supervisão).

Nossa aplicação sender já tem um supervisor no local, que você pode ver em application.ex:

Em sender/lib/sender/application.ex:

def start(_type, _args) do
  children = [
    # Starts a worker by calling: Sender.Worker.start_link(arg)
    # {Sender.Worker, arg}
  ]
  # See https://hexdocs.pm/elixir/Supervisor.html
  # for other strategies and supported options
  opts = [strategy: :one_for_one, name: Sender.Supervisor]
  Supervisor.start_link(children, opts)
end
Enter fullscreen mode Exit fullscreen mode

A fun√ß√£o start/2 tem algum c√≥digo padr√£o (boilerplate code) e algumas instru√ß√Ķes j√° em vigor. No final da fun√ß√£o temos Supervisor.start_link(children, opts) que inicia o supervisor principal da aplica√ß√£o. Como a vari√°vel children √© apenas uma lista vazia, na verdade n√£o h√° processos filhos para supervisionar. Esta configura√ß√£o √© o resultado de usarmos o argumento --sup quando chamamos o comando mix new para criar nosso projeto.

Agora que voc√™ sabe como os supervisores s√£o √ļteis, vamos ver como podemos usar um para nossos processos do m√≥dulo Task. N√£o queremos que nosso processo atual falhe se uma √ļnica tarefa falhar, ent√£o isolaremos erros potenciais iniciando os processos das tarefas sob um supervisor.

Adicionando um Supervisor

Elixir fornece um Supervisor behaviour para criar processos de supervisão, que você mesmo tem que implementar. Entretanto, há também alguns supervisores embutidos que podemos usar sem escrever (quase) qualquer código, e um deles é o Task.Supervisor. Ele é feito especificamente para trabalhar com processos de tarefas, por isso é uma excelente escolha para nós. Abra o application.ex e atualize a lista de filhos:

Em sender/lib/sender/application.change1.ex:

def start(_type, _args) do
  children = [
    {Task.Supervisor, name: Sender.EmailTaskSupervisor}
  ]
  opts = [strategy: :one_for_one, name: Sender.Supervisor]
  Supervisor.start_link(children, opts)
end
Enter fullscreen mode Exit fullscreen mode

O elemento que acrescentamos √© referido como um child specification (especifica√ß√£o de filho). Existem diferentes tipos de formatos para escrever __child specifications, mas na maioria das vezes √© um tuple, contendo informa√ß√Ķes sobre o processo do filho. Esta informa√ß√£o √© usada pelo supervisor para identificar, iniciar e ligar o processo.

Como o Task.Supervisor √© um m√≥dulo incorporado, nosso child specification √© simplesmente o nome do m√≥dulo, seguido por uma lista de op√ß√Ķes. Usamos a op√ß√£o :name para dar-lhe um nome √ļnico de nossa escolha, que usaremos mais tarde. Nomear um processo √© conhecido como name registration (registro de nome). √Č comum anexar "Supervisor" ao nomear supervisores, por isso o chamamos Sender.EmailTaskSupervisor.

O child specification tamb√©m poderia ser um map. Podemos reescrever a √ļltima mudan√ßa e usar um map como este:

def start(_type, _args) do
  children = [
    %{
      id: Sender.EmailTaskSupervisor,
      start: {
        Task.Supervisor,
        :start_link,
        [[name: Sender.EmailTaskSupervisor]]
      }
    }
  ]
  opts = [strategy: :one_for_one, name: Sender.Supervisor]
  Supervisor.start_link(children, opts)
end
Enter fullscreen mode Exit fullscreen mode

Usar um map √© mais verboso, mas permite definir outras op√ß√Ķes de configura√ß√£o no map, al√©m dos valores :id e :start. H√° tamb√©m uma fun√ß√£o de ajuda Supervisor.child_spec/1¬≤ que retorna um map e permite que voc√™ sobreponha apenas as chaves que voc√™ precisa.

Vamos manter o formato tuple por enquanto, mas os outros formatos também são abordados no livro Concurrent Data Processing in Elixir.

Nosso supervisor est√° agora pronto para uso. √Č t√£o simples quanto isso, e n√£o h√° necessidade de criar nenhum arquivo ou escrever mais c√≥digo. Esta simples mudan√ßa nos permite usar toda uma s√©rie de fun√ß√Ķes encontradas no m√≥dulo Task.Supervisor, algumas das quais introduzimos anteriormente.

Usando Task.Supervisor

Antes de passarmos a usar o EmailTaskSupervisor, vamos ver o que acontece quando ocorre um erro e não há supervisor no local. Vamos simular um erro, levantando uma exceção ao enviar um de nossos e-mails falsos. Edite sender.ex e adicione a seguinte cláusula de função pouco antes de enviar_email/1:

Em sender/lib/sender.change4.ex:

def send_email("konnichiwa@world.com" = email), do:
 raise "Oops, couldn't send email to #{email}!"

def send_email(email) do
 Process.sleep(3000)
 IO.puts("Email to #{email} sent")
 {:ok, "email_sent"}
end
Enter fullscreen mode Exit fullscreen mode

Usando pattern matching, vamos abrir uma exceção somente quando o endereço de e-mail for konnichiwa@world.com. Vamos ver o impacto deste erro na prática. Reinicie seu IEx shell e execute a função embutida self/0:

iex(1)> self()
#PID<0.136.0>
Enter fullscreen mode Exit fullscreen mode

Voc√™ obt√©m um identificador de processo para o processo atual, que √© o pr√≥pria shell do IEx. Seu n√ļmero pode ser diferente do meu, mas tudo bem. Tome nota disso.

Agora, vamos executar nossa função notify_all/1 e ver o que acontece:

iex(2)> Sender.notify_all(emails)
[error] Task #PID<0.154.0> started from #PID<0.136.0> terminating
** (RuntimeError) Oops, couldn't send email to konnichiwa@world.com!
 (sender) lib/sender.ex:8: Sender.send_email/1
 (elixir) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2
 (elixir) lib/task/supervised.ex:35: Task.Supervised.reply/5
 (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Function: &:erlang.apply/2
Args: [#Function<0.104978293/1 in Sender.notify_all/1>,
["konnichiwa@world.com"]]
** (EXIT from #PID<0.136.0>) shell process exited with reason:
an exception was raised:
  ** (RuntimeError) Oops, couldn't send email to konnichiwa@world.com!
   (sender) lib/sender.ex:8: Sender.send_email/1
   (elixir) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2
   (elixir) lib/task/supervised.ex:35: Task.Supervised.reply/5
   (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
iex(1)>
Enter fullscreen mode Exit fullscreen mode

Essa √© uma grande mensagem de erro. Na verdade, h√° duas mensagens de erro que parecem ser quase id√™nticas. Lembra-se do que dissemos sobre as liga√ß√Ķes de processos e como as falhas se propagam entre processos interligados? Neste caso, um processo de tarefa vinculado ao IEx levanta uma exce√ß√£o. Devido a este link, criado pelo async_stream/3 , o IEx tamb√©m trava com a mesma mensagem de exce√ß√£o. Voc√™ pode verificar isto executando self() novamente:

iex(1)> self()
#PID<0.155.0>
Enter fullscreen mode Exit fullscreen mode

Temos um novo identificador de processo, o que significa que um novo processo IEx foi iniciado e o antigo se quebrou.

Podemos evitar isto usando o novo EmailTaskSupervisor. Alterar notify_all/1 para usar Task.Supervisor.async_stream_nolink/4 em vez de Task.async_stream/3:

Em sender/lib/sender.change4.ex:

def notify_all(emails) do
  Sender.EmailTaskSupervisor
  |> Task.Supervisor.async_stream_nolink(emails, &send_email/1)
  |> Enum.to_list()
end
Enter fullscreen mode Exit fullscreen mode

O novo código é muito parecido com o antigo. O primeiro argumento de async_stream_nolink/4 é um módulo supervisor para os processos de tarefa - é onde você pode usar Sender.EmailTaskSupervisor. O resto dos argumentos são idênticos ao Task.async_stream/3. Vamos executar recompile() e notificar_tudo/1 novamente:

iex> Sender.notify_all(emails)
[error] Task #PID<0.173.0> started from #PID<0.155.0> terminating
** (RuntimeError) Oops, couldn't send email to konnichiwa@world.com!
  (sender) lib/sender.ex:8: Sender.send_email/1
  (elixir) lib/task/supervised.ex:90: Task.Supervised.invoke_mfa/2
  (elixir) lib/task/supervised.ex:35: Task.Supervised.reply/5
  (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Function: &:erlang.apply/2
Args: [#Function<0.32410907/1 in Sender.notify_all/1>,
["konnichiwa@world.com"]]
[info] Email to hello@world.com sent
[info] Email to hola@world.com sent
[info] Email to nihao@world.com sent
[
  ok: {:ok, "email_sent"},
  ok: {:ok, "email_sent"},
  ok: {:ok, "email_sent"},
  exit: {%RuntimeError{
    message: "Oops, couldn't send email to konnichiwa@world.com!"
  },
  [
    {Sender, :send_email, 1, [file: 'lib/sender.ex', line: 8]},
    {Task.Supervised, :invoke_mfa, 2,
      [file: 'lib/task/supervised.ex', line: 90]},
    {Task.Supervised, :reply, 5, [file: 'lib/task/supervised.ex', line: 35]},
    {:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 249]}
  ]}
]
Enter fullscreen mode Exit fullscreen mode

A exceção familiar ainda está impressa, mas desta vez obtivemos um resultado significativo. Podemos ver que todas as tarefas foram concluídas com sucesso, exceto uma. A tarefa que se quebrou retornou um tuple :exit contendo a mensagem de erro e até mesmo um traço de pilha. O mais importante é que nosso processo atual foi isolado do acidente - você pode verificar isso executando o self() novamente.

√Č f√°cil ver que esta √© uma enorme melhoria em rela√ß√£o a nossa vers√£o anterior. O supervisor que introduzimos trata de processos e erros simult√Ęneos, enquanto ainda estamos colhendo os benef√≠cios de desempenho do async_stream.

Voc√™ j√° deve ter ouvido falar de uma filosofia associada a Erlang chamada Let It Crash (deixe falhar). As aplica√ß√Ķes Erlang podem se recuperar rapidamente de erros reiniciando partes de seu sistema. Elixir tamb√©m adota esta filosofia. Infelizmente, esta abordagem tamb√©m √© freq√ľentemente mal compreendida. Na pr√≥xima se√ß√£o vamos explicar o que realmente significa Let It Crash e como os supervisores podem reiniciar processos para tornar nossos sistemas ainda mais resilientes.

Entendendo o Let it Crash

Usamos o Task.Supervisor para isolar uma falha de processo, mas pode parecer estranho que n√£o tenhamos evitado a falha simplesmente adicionando o tratamento de erros na fun√ß√£o send_email/1. Neste caso espec√≠fico, fizemos isso de prop√≥sito, apenas para simular uma exce√ß√£o inesperada. Na pr√°tica, voc√™ deve providenciar o tratamento de erros quando espera que um erro ocorra, e deixar o resto para o supervisor como √ļltimo recurso.

Ao discutir o tratamento de erros para o Elixir, a frase let it crash √© freq√ľentemente usada. Como resultado, algumas pessoas assumem que let it crash significa que Erlang e os desenvolvedores Elixir n√£o fazem nenhuma manipula√ß√£o de erros, o que n√£o √© o caso. A correspond√™ncia de padr√Ķes e as macro em Elixir facilitam o trabalho com {:ok, resultado} e {:erro, msg} tuples, e esta abordagem √© amplamente utilizada na comunidade. Elixir tamb√©m tem try e rescue para capturar exce√ß√Ķes, semelhante √† try e catch em outras linguagens.

Entretanto, por mais que tentemos como engenheiros, sabemos que erros podem acontecer. Isto muitas vezes leva a algo chamado Defensive programming (programação defensiva). Ela descreve a prática de tentar incessantemente cobrir todos os cenários possíveis de falha, mesmo quando alguns cenários são muito improváveis de acontecer, e não vale a pena lidar com eles.

Erlang e Elixir adotam uma abordagem diferente para a programação defensiva. Como todo código é executado em processos e processos que são leves, eles se concentram em como o sistema pode se recuperar de falhas versus como evitar todas as falhas. Você pode optar por permitir que uma parte (ou mesmo o todo) da aplicação se quebre e reinicie, mas você mesmo tratará dos outros erros. Esta mudança no pensamento e no projeto do software é a razão pela qual Erlang ficou famoso por sua confiabilidade e escalabilidade.

√Č a√≠ que entram em jogo os supervisores. Vimos que os supervisores podem isolar as colis√Ķes, mas tamb√©m podem reiniciar os processos filhos. H√° tr√™s valores diferentes de reinicializa√ß√£o dispon√≠veis para n√≥s:

  • :temporary nunca reiniciar√° os processos filhos.
  • :transient reiniciar√° os processos filhos, mas somente quando eles terminam com um erro.
  • :permanent reinicia sempre os filhos, mantendo-os em funcionamento, mesmo quando elas tentam desligar-se sem um erro.

‚ĄĻÔłŹ Esclarecimento sobre a terminologia utilizada

A palavra reinício/restart é um pouco enganosa no contexto dos processos em Elixir. Uma vez que um processo termina, ele não pode ser trazido de volta à vida. Portanto, reiniciar um processo resulta em iniciar um novo processo para tomar o lugar do antigo, usando a mesma child especification.

Dependendo da freq√ľ√™ncia do acidente, o pr√≥prio supervisor tamb√©m pode terminar quando um processo filho n√£o puder ser recuperado. Lembre-se de que a responsabilidade do supervisor √© de zelar por seus processos. Se for usado um valor de restart :transient ou :permanent e um processo continuar a falhar, o supervisor ir√° sair, porque falhou em reiniciar esse processo. Isso √© discutido com mais detalhes no livro Concurrent Data Processing in Elixir no Cap√≠tulo 2, Processos de Longa Dura√ß√£o Usando o GenServer, na p√°gina 25.

O Task.Supervisor incorporado que usamos at√© agora j√° est√° usando o valor de rein√≠cio :temporary para processos filhos. Este √© um padr√£o sensato, pois evita que todos os processos de tarefas se quebrem se outra tarefa sair com um erro. No entanto, estas op√ß√Ķes ser√£o √ļteis no pr√≥ximo cap√≠tulo do livro quando √© construido √°rvores de supervis√£o mais complexas.


Conclus√£o

Cobrimos os processos do Elixir e usamos o m√≥dulo Task para fazer o trabalho concorrentemente e em paralelo, evitando facilmente erros de timeout e exce√ß√Ķes inesperadas. Voc√™ tamb√©m aprendeu sobre supervisores, que s√£o a base da constru√ß√£o de aplica√ß√Ķes Elixir tolerantes a falhas.

Embora o m√≥dulo Task seja muito poderoso e vers√°til, ele s√≥ √© √ļtil para executar func√Ķes concorrentes ocasionais. Como resultado, os processos do m√≥dulo Task tem vida curta, vivem e terminam assim que s√£o conclu√≠dos. No pr√≥ximo cap√≠tulo do livro, √© introduzido outro tipo de processo, que √© capaz de funcionar e manter o estado durante o tempo que for necess√°rio. Vai mudar completamente o jeito como voc√™ constr√≥i aplica√ß√Ķes para sempre, e isto n√£o √© um exagero.

O próximo capítulo do livro fala sobre GenServer e você pode continuar lendo nele, ou quem sabe em um próximo post por aqui mesmo...

at√© logo ūüôā

Top comments (2)

Collapse
 
postelxpro profile image
Elxpro

como sempre, excelente artigos :D

Collapse
 
maiquitome profile image
Maiqui Tom√© ūüáßūüá∑

valeu mestre :)