Actor model
Os modelos anteriores enfrentaram os perigos da memória compartilhada de três formas: disciplinando o acesso com locks, ou reduzindo o custo das tarefas que a compartilham com green threads e event loops. O actor model faz uma pergunta mais radical: e se ninguém compartilhasse memória, para começar? Se a raiz de races, deadlocks e da agonia do modelo de memória é que duas threads tocam o mesmo dado, remova a premissa. Que cada unidade de concorrência tenha estado estritamente privado, e que a única forma de uma influenciar a outra seja mandando uma mensagem. Elimina-se, de uma tacada, a categoria inteira de bugs que o documento sobre sincronização catalogou, não porque você os evita com cuidado, mas porque eles se tornam impossíveis de expressar.
O que é um ator
Section titled “O que é um ator”Um ator é a fusão de três coisas: um estado privado, uma caixa de correio (mailbox) e um comportamento. O estado é só dele, e nenhum outro ator consegue lê-lo ou escrevê-lo diretamente. A mailbox é uma fila onde chegam as mensagens que outros atores lhe enviam. O comportamento é o código que processa essas mensagens, uma de cada vez.
Essa última cláusula, uma de cada vez, é o que faz o modelo funcionar. Um ator processa suas mensagens sequencialmente: pega a primeira da mailbox, processa até o fim, só então pega a próxima. Como o estado do ator só é tocado pelo seu próprio comportamento, e o comportamento roda uma mensagem por vez, nunca há dois fluxos mexendo no mesmo estado simultaneamente. A exclusão mútua que o mutex tentava impor com disciplina externa aqui é uma propriedade estrutural, grátis: o ator é serial por dentro, então não há seção crítica a proteger. Você ganha o efeito de um lock em torno de todo o estado do ator, sem escrever lock nenhum e sem poder esquecer de escrevê-lo.
Ao processar uma mensagem, um ator pode fazer três coisas: alterar seu próprio estado, enviar mensagens a outros atores (dos quais conheça o endereço), e criar novos atores. Nada mais. Repare no que está ausente: não há “ler o estado de outro ator”. Se o ator A quer um dado que vive no ator B, ele manda uma mensagem pedindo, e B responde com outra mensagem. A comunicação é sempre assíncrona e por cópia: a mensagem que A envia não é uma referência compartilhada ao dado de A, é uma cópia que passa a pertencer a B. É o mesmo trade-off dos processos do nível de sistema, isolamento ao preço de cópia, agora como princípio organizador da linguagem inteira.
Atores não são channels
Section titled “Atores não são channels”Como o documento sobre green threads já marcou ao falar de CSP, é fácil confundir o actor model com a comunicação por channels de Go, já que ambos trocam mensagens em vez de compartilhar memória. A distinção vale repetir do lado dos atores, porque ela define o caráter do modelo.
A diferença está em quem tem nome. No actor model, o ator tem identidade: cada um possui um endereço estável, e você envia uma mensagem para aquele ator específico. A mailbox pertence ao ator. No CSP, é o channel que tem nome, e os processos são anônimos: você envia para um channel, e qualquer processo pode estar do outro lado recebendo. Dessa inversão decorrem consequências práticas. O envio de um ator é assíncrono e sem espera, a mensagem cai na mailbox do destinatário e o remetente segue em frente; o envio num channel CSP sem buffer é síncrono, o remetente espera até que alguém receba. E como o ator tem endereço próprio e estável, ele é um alvo natural para endereçamento remoto, o que abre a porta para a distribuição transparente que aparece mais adiante. O channel anônimo não tem essa vocação para virar endereço de rede. Nenhum dos dois é superior em abstrato; são geometrias diferentes de onde colocar a identidade, e cada uma se presta melhor a um tipo de sistema.
Let it crash
Section titled “Let it crash”A consequência mais surpreendente do actor model não é sobre concorrência, é sobre tolerância a falhas, e é o que faz sistemas construídos sobre ele alcançarem disponibilidade que beira o folclórico. A filosofia se chama “let it crash”, deixe quebrar, e ela inverte a atitude usual diante de erros.
O raciocínio parte do isolamento. Como cada ator tem estado privado e não compartilha nada, um ator que encontra um erro e morre não corrompe ninguém mais: seu estado inconsistente morre com ele, sem vazar para o resto do sistema. Isso torna seguro adotar uma postura que seria temerária num programa de memória compartilhada: em vez de encher cada função de tratamento defensivo para todo erro imaginável, deixe o ator simplesmente falhar quando algo sai do previsto. Um estado corrompido não se propaga; ele é descartado junto com o ator.
O que recupera o sistema é a supervisão. Os atores organizam-se em árvores de supervisão: atores supervisores cuja função não é fazer o trabalho, mas vigiar atores filhos e decidir o que fazer quando um deles morre. A resposta típica é reiniciar o filho num estado inicial limpo e conhecido. A intuição, emprestada da engenharia de telecom que deu origem ao modelo, é que a esmagadora maioria dos erros em produção é transitória, uma condição de corrida rara, um dado malformado pontual, um recurso momentaneamente indisponível, e que reiniciar do zero um componente isolado resolve o problema com muito mais frequência do que qualquer tentativa de tratar cada erro no ponto onde ele aconteceu. Em vez de prever e tratar toda falha possível, você aceita que falhas acontecem, contém o estrago no ator isolado, e restaura a partir de um ponto são. É uma teoria de confiabilidade construída sobre o isolamento, e ela só é viável porque o isolamento é real.
Erlang e o BEAM
Section titled “Erlang e o BEAM”O actor model tem uma encarnação de referência, e boa parte do que se atribui ao modelo em abstrato vem, na verdade, das escolhas concretas dessa implementação. Erlang nasceu na Ericsson nos anos 1980 para programar centrais telefônicas, sistemas que precisavam rodar por anos sem parar e degradar com graça sob falha. Essas exigências moldaram tanto a linguagem quanto sua máquina virtual, o BEAM, e explicam por que o modelo funciona tão bem ali.
No BEAM, os atores são chamados de processos, e a palavra é escolhida a dedo: eles são isolados como processos do sistema operacional, cada um com seu próprio estado e sua própria pilha, mas são baratíssimos, muito mais leves que uma thread do SO ou até que uma goroutine. Um processo BEAM começa com algumas centenas de bytes, e um sistema Erlang roda rotineiramente com milhões deles vivos ao mesmo tempo. Cada processo tem seu heap privado, o que reforça o isolamento até no nível da memória: não há heap compartilhado onde dois processos pudessem colidir, e por isso o coletor de lixo opera por processo, sem as pausas globais que assombram outras plataformas.
O escalonamento do BEAM merece nota porque resolve, de um jeito peculiar, o problema do laço sem yield que assombra os modelos cooperativos vistos antes. O BEAM roda um escalonador M:N, uma thread do SO por núcleo escalonando os processos leves, mas seu escalonamento é preemptivo de um modo próprio: ele conta “reduções”, uma unidade aproximada de trabalho equivalente a uma chamada de função, e após um número fixo de reduções o processo é forçado a ceder a vez, tenha ele chamado I/O ou não. Isso dá ao BEAM uma garantia rara entre runtimes de green threads: nenhum processo, nem mesmo um laço apertado de puro cálculo, consegue monopolizar um escalonador e matar de fome os outros. É a preempção do kernel reproduzida em espaço de usuário, e é o que sustenta a fama de latência previsível dos sistemas Erlang, capazes de manter milhões de conexões responsivas sem picos de latência causados por um processo guloso.
Elixir, OTP e a distribuição transparente
Section titled “Elixir, OTP e a distribuição transparente”Elixir é uma linguagem moderna, de sintaxe mais acessível e influência de Ruby, que roda sobre o mesmo BEAM e herda todo o modelo de Erlang: os mesmos processos leves, o mesmo escalonamento, a mesma tolerância a falhas. Sua chegada trouxe o modelo de atores do BEAM a uma audiência muito maior, sobretudo no desenvolvimento web, através de frameworks como o Phoenix.
Boa parte do que torna esses sistemas robustos não está na linguagem crua, mas no OTP (Open Telecom Platform), um conjunto de bibliotecas e convenções que cristaliza os padrões que a comunidade Erlang descobriu ao longo de décadas. O OTP oferece abstrações prontas e testadas em produção para os papéis recorrentes: o GenServer para um processo que mantém estado e responde a chamadas, o Supervisor para as árvores de supervisão, e comportamentos padronizados para tarefas comuns. Escrever um sistema resiliente no BEAM é, na prática, compor peças de OTP, e é essa camada, mais do que a sintaxe, que representa o valor acumulado do ecossistema.
O modelo de atores desdobra-se numa propriedade que os outros modelos deste conjunto não têm como oferecer: a transparência de localização. Como um ator já se comunica apenas por mensagens endereçadas a um identificador, e nunca por acesso direto à memória, o mecanismo de envio funciona igual quer o destinatário esteja no mesmo processo do SO, quer esteja noutra máquina do outro lado da rede. Enviar uma mensagem a um processo local e a um processo remoto é o mesmo gesto no código. Isso faz da distribuição uma extensão natural do modelo em vez de uma reescrita: um sistema BEAM pode espalhar-se por um cluster de máquinas quase com a mesma facilidade com que usa vários núcleos, e é essa continuidade entre concorrência local e distribuída que explica por que o modelo domina em telecomunicações, mensageria e sistemas distribuídos de alta disponibilidade. A mesma abstração que elimina os locks num núcleo estende-se, sem costura, para a rede.
Akka e o modelo fora do BEAM
Section titled “Akka e o modelo fora do BEAM”O actor model não está preso a Erlang, embora prospere melhor onde a plataforma foi desenhada para ele. Na JVM, o Akka trouxe os atores para Scala e Java, com mailboxes, supervisão e distribuição inspiradas de perto no que o BEAM oferece. Há bibliotecas de atores para praticamente toda linguagem popular.
A ressalva honesta é que a maioria dessas implementações fora do BEAM herda o modelo sem herdar as garantias da plataforma. Numa JVM, os atores rodam sobre threads e um heap compartilhado, então o isolamento de estado é uma convenção que a biblioteca pede, não uma propriedade que a máquina impõe: nada no compilador impede você de colocar uma referência mutável dentro de uma mensagem e, com isso, reabrir a porta para a memória compartilhada que o modelo existia para fechar. Falta também o escalonamento preemptivo por redução e o coletor de lixo por ator, então as garantias de latência e de contenção de falhas são mais frágeis. O actor model como padrão de design você adota em qualquer lugar; o actor model com as garantias que o tornam lendário é, em boa medida, uma propriedade do BEAM, e essa distinção importa na hora de decidir o que esperar de uma implementação.
Os trade-offs
Section titled “Os trade-offs”Seria desonesto encerrar como se o actor model fosse a resposta final, porque ele compra suas virtudes a um preço real. O primeiro custo é a cópia: como nada é compartilhado, mensagens são copiadas, e para dados grandes ou trocados com altíssima frequência isso é mais caro que a leitura direta de uma memória comum. Em cargas onde threads compartilhando um buffer sob um lock seriam imbatíveis, os atores pagam a fatura da cópia.
O segundo custo é a assincronia e a complexidade de raciocínio que ela traz. A comunicação por mensagens assíncronas significa que não há garantia sobre a ordem em que mensagens de remetentes diferentes chegam, e o fluxo de uma operação que atravessa vários atores fica espalhado por várias mailboxes, mais difícil de seguir do que uma pilha de chamadas linear. Depurar “por que este ator recebeu esta mensagem nesta ordem” é um gênero próprio de dificuldade. Deadlocks, curiosamente, não desaparecem de todo: dois atores podem ficar cada um esperando a resposta do outro, um abraço mortal em nova roupa, ainda que sem a corrupção de memória que o deadlock de locks arrisca.
O que se troca, no fim, é a dor da sincronização de memória pela dor da coordenação de mensagens. Para sistemas cuja prioridade máxima é resiliência, disponibilidade e distribuição, sistemas que precisam ficar de pé por anos e degradar com graça, o actor model, sobretudo na sua encarnação BEAM, oferece um conjunto de garantias que nenhum dos outros modelos deste conjunto alcança. Para cálculo numérico intenso num núcleo, para latência mínima em memória compartilhada, ele é a ferramenta errada. Como todo o resto neste tema, a escolha não é sobre qual modelo é melhor, é sobre qual dor você prefere carregar, dado o que o seu sistema precisa acima de tudo.
Palavras-chave
Section titled “Palavras-chave”Conceitos: actor model, ator, mailbox, estado privado, troca de mensagens, isolamento, transparência de localização, let it crash, árvore de supervisão, escalonamento por redução BEAM: processo, heap por processo, escalonamento preemptivo, coletor de lixo por processo, cluster Erlang/Elixir: OTP, GenServer, Supervisor, Phoenix Outros: Akka, JVM, CSP vs atores Trade-offs: custo de cópia, ordenação de mensagens, deadlock entre atores