Simultaneidade e tolerância a falhas facilitadas: um tutorial Akka com exemplos

Publicados: 2022-03-11

O desafio

Escrever programas simultâneos é difícil. Ter que lidar com threads, bloqueios, condições de corrida e assim por diante é altamente propenso a erros e pode levar a códigos difíceis de ler, testar e manter.

Muitos, portanto, preferem evitar completamente o multithreading. Em vez disso, eles empregam exclusivamente processos de thread único, contando com serviços externos (como bancos de dados, filas, etc.) para lidar com quaisquer operações simultâneas ou assíncronas necessárias. Embora essa abordagem seja, em alguns casos, uma alternativa legítima, há muitos cenários em que ela simplesmente não é uma opção viável. Muitos sistemas em tempo real – como aplicativos de negociação ou bancários, ou jogos em tempo real – não têm o luxo de esperar pela conclusão de um processo de thread único (eles precisam da resposta agora!). Outros sistemas são tão intensivos em computação ou recursos que levariam uma quantidade excessiva de tempo (horas ou mesmo dias em alguns casos) para serem executados sem introduzir a paralelização em seu código.

Uma abordagem de thread único bastante comum (amplamente usada no mundo Node.js, por exemplo) é usar um paradigma sem bloqueio baseado em eventos. Embora isso ajude o desempenho evitando alternâncias de contexto, bloqueios e bloqueios, ele ainda não aborda os problemas de usar vários processadores simultaneamente (isso exigiria o lançamento e a coordenação entre vários processos independentes).

Então, isso significa que você não tem escolha a não ser viajar profundamente nas entranhas de threads, bloqueios e condições de corrida para criar um aplicativo simultâneo?

Graças ao framework Akka, a resposta é não. Este tutorial apresenta exemplos do Akka e explora as maneiras pelas quais ele facilita e simplifica a implementação de aplicativos distribuídos simultâneos.

O que é o Akka Framework?

Este post apresenta o Akka e explora as maneiras pelas quais ele facilita e simplifica a implementação de aplicativos distribuídos simultâneos.

Akka é um kit de ferramentas e tempo de execução para construir aplicativos altamente simultâneos, distribuídos e tolerantes a falhas na JVM. Akka é escrito em Scala, com ligações de linguagem fornecidas tanto para Scala quanto para Java.

A abordagem da Akka para lidar com a simultaneidade é baseada no Actor Model. Em um sistema baseado em atores, tudo é um ator, da mesma forma que tudo é um objeto no design orientado a objetos. Uma diferença fundamental, porém – particularmente relevante para nossa discussão – é que o Modelo Ator foi especificamente projetado e arquitetado para servir como um modelo concorrente, enquanto o modelo orientado a objetos não é. Mais especificamente, em um sistema de atores Scala, os atores interagem e compartilham informações, sem nenhum pressuposto de sequencialidade. O mecanismo pelo qual os atores compartilham informações uns com os outros e executam tarefas uns aos outros é a passagem de mensagens.

Toda a complexidade de criar e agendar threads, receber e despachar mensagens e lidar com condições de corrida e sincronização é relegada à estrutura para ser tratada de forma transparente.

Akka cria uma camada entre os atores e o sistema subjacente de forma que os atores simplesmente precisem processar mensagens. Toda a complexidade de criar e agendar threads, receber e despachar mensagens e lidar com condições de corrida e sincronização é relegada à estrutura para ser tratada de forma transparente.

Akka adere estritamente ao Manifesto Reativo. Os aplicativos reativos visam substituir os aplicativos multithread tradicionais por uma arquitetura que satisfaça um ou mais dos seguintes requisitos:

  • Orientado a eventos. Usando Atores, pode-se escrever código que trata solicitações de forma assíncrona e emprega exclusivamente operações sem bloqueio.
  • Escalável. No Akka, é possível adicionar nós sem ter que modificar o código, graças à passagem de mensagens e à transparência da localização.
  • Resiliente. Qualquer aplicativo encontrará erros e falhará em algum momento. Akka fornece estratégias de “supervisão” (tolerância a falhas) para facilitar um sistema de autocura.
  • Responsivo. Muitos dos aplicativos atuais de alto desempenho e resposta rápida precisam fornecer feedback rápido ao usuário e, portanto, precisam reagir aos eventos de maneira extremamente oportuna. A estratégia sem bloqueio e baseada em mensagens da Akka ajuda a conseguir isso.

O que é um ator em Akka?

Um ator é essencialmente nada mais do que um objeto que recebe mensagens e realiza ações para tratá-las. Ele é desacoplado da fonte da mensagem e sua única responsabilidade é reconhecer adequadamente o tipo de mensagem que recebeu e agir de acordo.

Ao receber uma mensagem, um ator pode realizar uma ou mais das seguintes ações:

  • Executar algumas operações por conta própria (como realizar cálculos, persistir dados, chamar um serviço da Web externo e assim por diante)
  • Encaminhar a mensagem, ou uma mensagem derivada, para outro ator
  • Instancie um novo ator e encaminhe a mensagem para ele

Alternativamente, o ator pode optar por ignorar totalmente a mensagem (ou seja, pode optar pela inação) se julgar apropriado fazê-lo.

Para implementar um ator, é necessário estender o traço akka.actor.Actor e implementar o método receive. O método receive de um ator é invocado (por Akka) quando uma mensagem é enviada a esse ator. Sua implementação típica consiste em correspondência de padrões, conforme mostrado no exemplo Akka a seguir, para identificar o tipo de mensagem e reagir de acordo:

 import akka.actor.Actor import akka.actor.Props import akka.event.Logging class MyActor extends Actor { def receive = { case value: String => doSomething(value) case _ => println("received unknown message") } }

A correspondência de padrões é uma técnica relativamente elegante para lidar com mensagens, que tende a produzir um código “mais limpo” e fácil de navegar do que uma implementação comparável baseada em retornos de chamada. Considere, por exemplo, uma implementação de solicitação/resposta HTTP simplista.

Primeiro, vamos implementar isso usando um paradigma baseado em retorno de chamada em JavaScript:

 route(url, function(request){ var query = buildQuery(request); dbCall(query, function(dbResponse){ var wsRequest = buildWebServiceRequest(dbResponse); wsCall(wsRequest, function(wsResponse) { sendReply(wsResponse); }); }); });

Agora vamos comparar isso com uma implementação baseada em correspondência de padrões:

 msg match { case HttpRequest(request) => { val query = buildQuery(request) dbCall(query) } case DbResponse(dbResponse) => { var wsRequest = buildWebServiceRequest(dbResponse); wsCall(dbResponse) } case WsResponse(wsResponse) => sendReply(wsResponse) }

Embora o código JavaScript baseado em retorno de chamada seja reconhecidamente compacto, certamente é mais difícil de ler e navegar. Em comparação, o código baseado em correspondência de padrões torna mais imediatamente aparente quais casos estão sendo considerados e como cada um está sendo tratado.

O Sistema Ator

Pegar um problema complexo e dividi-lo recursivamente em subproblemas menores é uma boa técnica de solução de problemas em geral. Essa abordagem pode ser particularmente benéfica em ciência da computação (consistente com o Princípio da Responsabilidade Única), pois tende a produzir um código limpo e modularizado, com pouca ou nenhuma redundância, que é relativamente fácil de manter.

Em um design baseado em atores, o uso dessa técnica facilita a organização lógica dos atores em uma estrutura hierárquica conhecida como Sistema de Atores. O sistema de atores fornece a infraestrutura através da qual os atores interagem uns com os outros.

Um exemplo de como o sistema de atores funciona no framework Akka.

Em Akka, a única maneira de se comunicar com um ator é através de um ActorRef . Um ActorRef representa uma referência a um ator que impede que outros objetos acessem ou manipulem diretamente os internos e o estado desse ator. As mensagens podem ser enviadas a um ator por meio de um ActorRef usando um dos seguintes protocolos de sintaxe:

  • ! (“tell”) – envia a mensagem e retorna imediatamente
  • ? (“ask”) – envia a mensagem e retorna um Future representando uma possível resposta

Cada ator tem uma caixa de correio para a qual suas mensagens recebidas são entregues. Existem várias implementações de caixa de correio para escolher, com a implementação padrão sendo FIFO.

Um ator contém muitas variáveis ​​de instância para manter o estado durante o processamento de várias mensagens. Akka garante que cada instância de um ator seja executada em seu próprio thread leve e que as mensagens sejam processadas uma de cada vez. Dessa forma, o estado de cada ator pode ser mantido de forma confiável sem que o desenvolvedor precise se preocupar explicitamente com sincronização ou condições de corrida.

Cada ator recebe as seguintes informações úteis para realizar suas tarefas por meio da API Akka Actor:

  • sender : um ActorRef para o remetente da mensagem atualmente sendo processada
  • context : informações e métodos relacionados ao contexto no qual o ator está sendo executado (inclui, por exemplo, um método actorOf para instanciar um novo ator)
  • supervisionStrategy : define a estratégia a ser usada para recuperação de erros
  • self : o ActorRef para o próprio ator
Akka garante que cada instância de um ator seja executada em seu próprio thread leve e que as mensagens sejam processadas uma de cada vez. Dessa forma, o estado de cada ator pode ser mantido de forma confiável sem que o desenvolvedor precise se preocupar explicitamente com sincronização ou condições de corrida.

Para ajudar a unir esses tutoriais, vamos considerar um exemplo simples de contar o número de palavras em um arquivo de texto.

Para fins do nosso exemplo Akka, vamos decompor o problema em duas subtarefas; ou seja, (1) uma tarefa “filho” de contar o número de palavras em uma única linha e (2) uma tarefa “pai” de somar essas contagens de palavras por linha para obter o número total de palavras no arquivo.

O ator pai carregará cada linha do arquivo e, em seguida, delegará a um ator filho a tarefa de contar as palavras nessa linha. Quando o filho terminar, ele enviará uma mensagem de volta ao pai com o resultado. O pai receberá as mensagens com a contagem de palavras (para cada linha) e manterá um contador para o número total de palavras em todo o arquivo, que retornará ao seu invocador após a conclusão.

(Observe que os exemplos de código do tutorial Akka fornecidos abaixo são apenas didáticos e, portanto, não se preocupam necessariamente com todas as condições de borda, otimizações de desempenho e assim por diante. Além disso, uma versão compilável completa dos exemplos de código mostrados abaixo está disponível em esta essência.)

Vejamos primeiro um exemplo de implementação da classe StringCounterActor filha:

 case class ProcessStringMsg(string: String) case class StringProcessedMsg(words: Integer) class StringCounterActor extends Actor { def receive = { case ProcessStringMsg(string) => { val wordsInLine = string.split(" ").length sender ! StringProcessedMsg(wordsInLine) } case _ => println("Error: message not recognized") } }

Esse ator tem uma tarefa muito simples: consumir mensagens ProcessStringMsg (contendo uma linha de texto), contar o número de palavras na linha especificada e retornar o resultado ao remetente por meio de uma mensagem StringProcessedMsg . Observe que implementamos nossa classe para usar o ! (“tell”) método para enviar a mensagem StringProcessedMsg (ou seja, para enviar a mensagem e retornar imediatamente).

OK, agora vamos voltar nossa atenção para a classe pai WordCounterActor :

 1. case class StartProcessFileMsg() 2. 3. class WordCounterActor(filename: String) extends Actor { 4. 5. private var running = false 6. private var totalLines = 0 7. private var linesProcessed = 0 8. private var result = 0 9. private var fileSender: Option[ActorRef] = None 10. 11. def receive = { 12. case StartProcessFileMsg() => { 13. if (running) { 14. // println just used for example purposes; 15. // Akka logger should be used instead 16. println("Warning: duplicate start message received") 17. } else { 18. running = true 19. fileSender = Some(sender) // save reference to process invoker 20. import scala.io.Source._ 21. fromFile(filename).getLines.foreach { line => 22. context.actorOf(Props[StringCounterActor]) ! ProcessStringMsg(line) 23. totalLines += 1 24. } 25. } 26. } 27. case StringProcessedMsg(words) => { 28. result += words 29. linesProcessed += 1 30. if (linesProcessed == totalLines) { 31. fileSender.map(_ ! result) // provide result to process invoker 32. } 33. } 34. case _ => println("message not recognized!") 35. } 36. }

Muitas coisas estão acontecendo aqui, então vamos examinar cada uma delas com mais detalhes (observe que os números de linha referenciados na discussão a seguir são baseados no exemplo de código acima)

Primeiro, observe que o nome do arquivo a ser processado é passado para o construtor WordCounterActor (linha 3). Isso indica que o ator deve ser usado apenas para processar um único arquivo. Isso também simplifica o trabalho de codificação para o desenvolvedor, evitando a necessidade de redefinir as variáveis ​​de estado ( running , totalLines , linesProcessed e result ) quando o trabalho é concluído, pois a instância é usada apenas uma vez (ou seja, para processar um único arquivo) e depois descartado.

Em seguida, observe que o WordCounterActor trata dois tipos de mensagens:

  • StartProcessFileMsg (linha 12)
    • Recebido do ator externo que inicia inicialmente o WordCounterActor .
    • Quando recebido, o WordCounterActor primeiro verifica se não está recebendo uma solicitação redundante.
    • Se a solicitação for redundante, WordCounterActor gera um aviso e nada mais é feito (linha 16).
    • Se a solicitação não for redundante:
      • WordCounterActor armazena uma referência ao remetente na variável de instância fileSender (observe que esta é uma Option[ActorRef] em vez de uma Option[Actor] - veja a linha 9). Este ActorRef é necessário para posteriormente acessá-lo e respondê-lo ao processar o StringProcessedMsg final (que é recebido de um filho StringCounterActor , conforme descrito abaixo).
      • WordCounterActor então lê o arquivo e, à medida que cada linha do arquivo é carregada, um filho StringCounterActor é criado e uma mensagem contendo a linha a ser processada é passada para ele (linhas 21-24).
  • StringProcessedMsg (linha 27)
    • Recebido de um StringCounterActor filho ao concluir o processamento da linha atribuída a ele.
    • Quando recebido, o WordCounterActor incrementa o contador de linhas do arquivo e, se todas as linhas do arquivo tiverem sido processadas (ou seja, quando totalLines e linesProcessed são iguais), ele envia o resultado final para o fileSender original (linhas 28-31).

Mais uma vez, observe que em Akka, o único mecanismo de comunicação entre atores é a troca de mensagens. As mensagens são a única coisa que os atores compartilham e, como os atores podem acessar as mesmas mensagens simultaneamente, é importante que sejam imutáveis, para evitar condições de corrida e comportamentos inesperados.

As classes case em Scala são classes regulares que fornecem um mecanismo de decomposição recursiva por meio de correspondência de padrões.

Portanto, é comum passar mensagens na forma de classes case, pois elas são imutáveis ​​por padrão e devido à sua integração perfeita com a correspondência de padrões.

Vamos concluir o exemplo com o exemplo de código para executar todo o aplicativo.

 object Sample extends App { import akka.util.Timeout import scala.concurrent.duration._ import akka.pattern.ask import akka.dispatch.ExecutionContexts._ implicit val ec = global override def main(args: Array[String]) { val system = ActorSystem("System") val actor = system.actorOf(Props(new WordCounterActor(args(0)))) implicit val timeout = Timeout(25 seconds) val future = actor ? StartProcessFileMsg() future.map { result => println("Total number of words " + result) system.shutdown } } }
Na programação concorrente, um "futuro" é essencialmente um objeto de espaço reservado para um resultado que ainda não é conhecido.

Observe como desta vez o ? método é usado para enviar uma mensagem. Desta forma, o chamador pode usar o Future retornado para imprimir o resultado final quando este estiver disponível e sair do programa desligando o ActorSystem.

Akka tolerância a falhas e estratégias de supervisor

Em um sistema de atores, cada ator é o supervisor de seus filhos. Se um ator falha em manipular uma mensagem, ele suspende a si mesmo e todos os seus filhos e envia uma mensagem, geralmente na forma de exceção, para seu supervisor.

Na Akka, as estratégias do supervisor são o mecanismo principal e direto para definir o comportamento tolerante a falhas do seu sistema.

Em Akka, a maneira pela qual um supervisor reage e trata as exceções que chegam até ele de seus filhos é chamada de estratégia do supervisor. As estratégias do supervisor são o mecanismo primário e direto pelo qual você define o comportamento tolerante a falhas do seu sistema.

Quando uma mensagem que indica uma falha chega a um supervisor, ele pode realizar uma das seguintes ações:

  • Retome o filho (e seus filhos), mantendo seu estado interno. Essa estratégia pode ser aplicada quando o estado filho não foi corrompido pelo erro e pode continuar funcionando corretamente.
  • Reinicie o filho (e seus filhos), limpando seu estado interno. Essa estratégia pode ser usada no cenário oposto ao que acabamos de descrever. Se o estado filho foi corrompido pelo erro, é necessário redefinir seu estado antes de poder ser usado no futuro.
  • Pare a criança (e seus filhos) permanentemente. Essa estratégia pode ser empregada nos casos em que se acredita que a condição de erro não seja corrigida, mas não compromete o restante da operação que está sendo realizada, que pode ser concluída na ausência da criança com falha.
  • Parar-se e escalar o erro. Empregado quando o supervisor não sabe como lidar com a falha e por isso a encaminha para seu próprio supervisor.

Além disso, um Ator pode decidir aplicar a ação apenas aos filhos fracassados ​​ou também aos seus irmãos. Existem duas estratégias pré-definidas para isso:

  • OneForOneStrategy : aplica a ação especificada apenas ao filho com falha
  • AllForOneStrategy : Aplica a ação especificada a todos os seus filhos

Aqui está um exemplo simples, usando o OneForOneStrategy :

 import akka.actor.OneForOneStrategy import akka.actor.SupervisorStrategy._ import scala.concurrent.duration._ override val supervisorStrategy = OneForOneStrategy() { case _: ArithmeticException => Resume case _: NullPointerException => Restart case _: IllegalArgumentException => Stop case _: Exception => Escalate }

Se nenhuma estratégia for especificada, a seguinte estratégia padrão será empregada:

  • Se houve um erro ao inicializar o ator ou se o ator foi morto, o ator é interrompido.
  • Se houver algum outro tipo de exceção, o ator é simplesmente reiniciado.

A implementação fornecida pela Akka desta estratégia padrão é a seguinte:

 final val defaultStrategy: SupervisorStrategy = { def defaultDecider: Decider = { case _: ActorInitializationException ⇒ Stop case _: ActorKilledException ⇒ Stop case _: Exception ⇒ Restart } OneForOneStrategy()(defaultDecider) }

Akka permite a implementação de estratégias de supervisor personalizadas, mas como a documentação da Akka adverte, faça isso com cautela, pois implementações incorretas podem levar a problemas como sistemas de atores bloqueados (ou seja, atores permanentemente suspensos).

Transparência do local

A arquitetura Akka suporta transparência de localização, permitindo que os atores sejam totalmente agnósticos quanto à origem das mensagens que recebem. O remetente da mensagem pode residir na mesma JVM que o ator ou em uma JVM separada (executando no mesmo nó ou em um nó diferente). Akka permite que cada um desses casos seja tratado de maneira completamente transparente para o ator (e, portanto, para o desenvolvedor). A única ressalva é que as mensagens enviadas em vários nós devem ser serializáveis.

A arquitetura Akka suporta transparência de localização, permitindo que os atores sejam totalmente agnósticos quanto à origem das mensagens que recebem.

Os sistemas Actor são projetados para serem executados em um ambiente distribuído sem exigir nenhum código especializado. O Akka requer apenas a presença de um arquivo de configuração ( application.conf ) que especifica os nós para os quais enviar mensagens. Aqui está um exemplo simples de um arquivo de configuração:

 akka { actor { provider = "akka.remote.RemoteActorRefProvider" } remote { transport = "akka.remote.netty.NettyRemoteTransport" netty { hostname = "127.0.0.1" port = 2552 } } }

Algumas dicas de despedida…

Vimos como a estrutura Akka ajuda a alcançar simultaneidade e alto desempenho. No entanto, como este tutorial apontou, existem alguns pontos a serem lembrados ao projetar e implementar seu sistema para explorar ao máximo o poder do Akka:

  • Na medida do possível, cada ator deve receber a menor tarefa possível (como discutido anteriormente, seguindo o Princípio da Responsabilidade Única)
  • Os atores devem manipular eventos (ou seja, processar mensagens) de forma assíncrona e não devem bloquear, caso contrário ocorrerão trocas de contexto que podem afetar adversamente o desempenho. Especificamente, é melhor realizar operações de bloqueio (IO, etc.) em um Futuro para não bloquear o ator; ou seja:

     case evt => blockingCall() // BAD case evt => Future { blockingCall() // GOOD }
  • Certifique-se de que suas mensagens sejam todas imutáveis, uma vez que os atores que as repassam um para o outro estarão executando simultaneamente em seus próprios threads. Mensagens mutáveis ​​provavelmente resultarão em comportamento inesperado.
  • Como as mensagens enviadas entre nós devem ser serializáveis, é importante ter em mente que quanto maiores forem as mensagens, mais tempo levará para serializá-las, enviá-las e desserializá-las, o que pode afetar negativamente o desempenho.

Conclusão

Akka, escrito em Scala, simplifica e facilita o desenvolvimento de aplicativos altamente simultâneos, distribuídos e tolerantes a falhas, ocultando grande parte da complexidade do desenvolvedor. Fazer justiça total ao Akka exigiria muito mais do que este único tutorial, mas esperamos que esta introdução e seus exemplos tenham sido suficientemente cativantes para fazer você querer ler mais.

Amazon, VMWare e CSC são apenas alguns exemplos de empresas líderes que estão usando ativamente o Akka. Visite o site oficial da Akka para saber mais e explorar se a Akka também pode ser a resposta certa para o seu projeto.