Modelo de Atores
Last updated
Last updated
Se você leu a primeira parte do guia, deve se lembrar das propriedades de um processo:
processos têm estado privado e não compartilham memória
processos se comunicam uns com os outros por envio de mensagens (IPC)
processos têm um identificador único no sistema (PID)
Por conta destas propriedades, um processo não está sujeito aos memos problemas de race conditions igual às threads - embora seja possível se processos utilizarem mecanismos de compartilhamento de memória de forma explícita.
Com isto, podemos pensar numa possível abstração para trabalharmos com threads sem precisarmos recorrer ao uso de locks.
É como se tivéssemos "threads especiais" que iriam possuir as mesmas propriedades de um processo: estado privado, identificador único e comunicação por envio de mensagens.
Você acertou, estamos falando do modelo de atores.
Muito bacana isso, Leandro. O kernel fornece tal estrutura?
Não. Temos de criar nossa própria abstração, a nível de user space (runtime).
Existem diversas implementações de modelo de atores em diferentes linguagens de programação. Pra mencionar alguns casos, em Java, temos implementação de modelo de atores com a biblioteca Akka. Em Erlang, a modelagem já faz parte das estruturas internas do runtime. E em Ruby (a partir da versão 3+), temos os Ractors.
Para entendermos o funcionamento do modelo de atores e como este se relaciona com Ruby, vamos pensar em como poderíamos implementar nosso próprio modelo. Não é difícil, acredite.
No exemplo acima, repare que, diferente da abstração de Thread que compartilha memória, um Ator não compartilha memória com outros atores. Isto por si só elimina problemas inerentes a condições de corrida.
Acho que entendi, Leandro. Então quer dizer que todo ator usa uma kernel thread por trás?
Depende. Algumas implementações podem fazer com que cada ator seja mapeado diretamente para uma kernel thread, enquanto que em outras, um ator é uma abstração bastante leve dentro do runtime, que faz a multiplexação de atores para kernel threads conforme outros critérios, diminuindo assim o overhead com a criação de kernel threads.
Neste tópico, para fins didáticos, vamos mapear cada ator diretamente para uma kernel thread.
Para representar o ator, vamos mapear para uma thread:
O método .value
faz a mesma coisa que o .join
, mas traz o valor da última expressão dentro do bloco.
Por enquanto o ator não tem nenhuma lógica implementada. Vamos iniciar o estado dentro da thread:
A variável state
foi criada apenas dentro do escopo da thread. Se tentarmos acessar a variável no escopo fora da thread, o programa lança uma exceção:
Para além disso, podemos passar argumentos para a thread no momento da criação, e receber os argumentos dentro do escopo do bloco da thread:
Foi passado para a thread o valor absoluto 41
, mas podemos também passar variáveis:
Repare atentamente que a variável balance
criada fora do escopo da thread não foi modificada, apesar da thread ter modificado seu estado interno. É isto que precisamos. Contudo, o quê aconteceu de fato ali?
O Ruby, para alguns tipos de dados primitivos incluindo números inteiros, faz a cópia quando estes são enviados como argumentos para métodos, ou seja, a passagem é feita por valor.
Mas para outros tipos, como os arrays e hashes, não é feita a cópia mas sim a passagem por referência, pelo que a thread iria modificar o valor original, estando sujeita à race condition.
Vamos a um exemplo de um ator que adiciona um elemento em um array:
Não é isto que queremos. O nosso "ator" está sendo capaz de modificar variáveis que foram criadas fora de seu escopo. E se forçarmos uma cópia explícita do array para a thread? Há solução pra isso em Ruby, com o método .dup
:
Prontinho, já conseguimos cumprir com o primeiro requisito para modelo de atores: estado privado. Vamos ver o próximo requisito.
Para que atores conversem uns com os outros, é preciso que cada um tenha uma identificação única, assim como os processos no SO têm PID.
Em Ruby, cada objeto tem um ID, e com Thread não é diferente:
Yay! Já temos o segundo requisito cumprido. Vamos ao terceiro requisito e não menos importante: envio de mensagens.
Levando em conta que a nossa implementação de ator é uma abstração em cima da Thread, como enviar mensagens para o ator? A classe Thread
não fornece uma forma de enviar mensagens, então temos que criar o nosso próprio mecanismo.
Pois é, simplesmente não funciona...não há nada na implementação de Thread que permita o envio de mensagens, pois a thread é apenas uma abstração em cima de kernel threads que são automaticamente escalonadas pelo sistema operacional.
Para implementar o envio de mensagens entre atores, vamos antes entender duas características principais sobre o envio: síncrono e assíncrono.
Num modelo síncrono, um processo Sender precisa enviar uma mensagem a outro processo Receiver:
Enquanto o receptor não confirma que recebeu e processou a mensagem, o processo que enviou a mensagem não consegue fazer outra tarefa, portanto fica bloqueado.
Mas o quê acontece caso o receptor este indisponível ou não tenha conseguido processar a mensagem? O processo Sender fica sem saber se a mensagem foi recebida, e a mensagem é descartada:
Ou seja, além de ter deixado o Sender bloqueado, a mensagem ficou perdida pra sempre. Para implementar modelo de atores, precisamos fazer com que o ator seja capaz de receber mensagens de forma assíncrona.
No modelo assíncrono, a ideia é fazer com que a mensagem caia numa espécie de "caixa de correio" - similar ao que temos na vida real, ou então à caixa de entrada de email -, pelo que o ator fique verificando de tempos em tempos se chegou mensagem nova.
O ator é obrigado a responder tais mensagens? Não. Ou seja, neste modelo, o processo que envia a mensagem não necessariamente precisa ter uma resposta síncrona de que foi enviada, mas precisa ter a garantia de que a mensagem se encontra na caixa de entrrada.
Neste modelo, o processo Sender não fica bloqueado, ou seja o envio de mensagem foi assíncrono. Como podemos implementar esta "caixa de entrada"?
Vamos imaginar que neste caso as mensagens precisam ser processadas na ordem em que chegaram, correto? É um modelo onde o primeiro que entra é o primeiro a sair, que em inglês significa first-in, first-out, ou FIFO.
FIFO mesmo, você acertou, vamos implementar esta caixa de entrada com filas!
Que bem sabemos, filas podem ser implementadas com arrays!
Em Ruby, poderíamos representar nossa caixa de entrada (fila) do ator como simplesmente inbox, e dentro do ator consumir mensagens desta fila com o método pop:
E para enviar mensagens para a fila a partir do processo principal, temos o método push:
Entretanto, a esta altura o ator já terminou sua execução, portanto precisamos modificar o ator para que fique em loop verificando se há mensagens na inbox:
O que temos na saída é isto:
Infinitamente. Not good. Queremos que o ator fique meio que suspenso quando não houver mensagens na fila.
Para resolver este problema, precisamos criar uma fila bloqueante, que bloqueia o ator para não ficar gastando CPU desnecessariamente num loop infinito.
A implementação de uma fila bloqueante pode ser feita com exclusão mútua, como já vimos em tópicos anteriores, onde:
a thread verifica se há mensagens na fila:
se houver, faz o pop e volta ao início do loop
se não houver, utiliza mutex para enviar um sinal à thread que precisa ficar suspensa
quando o processo Sender colocar mensagem na fila, é enviado um sinal à thread que está suspensa:
a thread retoma de onde parou, consome mensagem da fila e repete o voltando ao início do loop
Precisamos recorrer a mutex e condvar para que a fila seja thread-safe, ou seja, a fila precisa ser compartilhada de forma segura entre threads, sem criar condições de corrida.
Este conceito já foi explicado no módulo de Concorrência em C.
Além de Mutex, em Ruby também temos uma abstração para o uso de Conditions:
Yay! Que dia maravilhoso, não é mesmo? Inclusive, podemos implementar uma abstração de Inbox utilizando a técnica milenar de um array, um mutex e uma variável condicional:
Mas Leandro, sério que temos que implementar nossa própria fila bloqueante em Ruby?
Calma jovem, estamos com sorte hoje! Ruby já fornece uma classe pra isso, a Thread::Queue
, que é totalmente thread-safe:
Okay, agora que vimos os fundamentos de modelo de atores com exemplos em Ruby, já conseguimos cumprir com as principais características de um ator:
estado privado
identificação única
envio de mensagens através de fila thread-safe
A seguir, vamos trazer uma série de exemplos impementando a abstração de Ator com todas as propriedades que já exploramos.
Vamos iniciar a implementação com uma classe Ruby bastante simples:
A seguir, no método initialize definimos a fila de mensagens e a Thread que irá ficar em background processando as mensagens:
A implementação do ator é muito simples:
lê mensagem da inbox
processa mensagem:
se "exit", sai do loop e a thread é finalizada, encerrando o ator
em qualquer outro caso, imprime a mensagem recebida
Agora, no modelo de ator definimos um método send
para o envio de mensagens para a fila e outro chamado exit
que encerra o ator:
Saída esperada:
Para além da capacidade de receber mensagens em uma fila inbox, um ator também deve ser capaz de enviar mensagens para o mundo externo, sempre de forma assíncrona, utilizando uma fila de mensagens.
Mas ao invés de usar a inbox, podemos definir outra fila, que irá representar a caixa de saída, ou outbox.
O envio de mensagens deve ser sempre assíncrono, ou seja, a mensagem deve ser colocada em alguma fila
Para ilustrar isso melhor, vamos modificar o exemplo para representar uma conta bancária, que realiza depósitos e saques, mantendo um estado privado que é o saldo final da conta. Olha só que belezura fica isso em Ruby:
Este código dispensa maiores comentários, não acha?
O nosso ator atual está amarrado com a lógica de conta bancária. E se criarmos uma abstração de modelo de ator que poderia funcionar com qualquer domínio de negócio?
Utilizando o conceito maravilhoso de blocks, podemos definir um ator super genérico que repassa os argumentos (estado inicial) e também o bloco de execução dinâmico:
A seguir, vamos definir 4 métodos no ator:
send
, que coloca mensagem na inbox
receive
, que lê mensagem da inbox
yield
, que colcoa mensagem na outbox
take
, que lê mensagem da outbox
Implementação final do ator genérico:
E agora, podemos utilizar este ator para diversos cenários, como no caso de uma conta bancária:
Ou ainda para outro contexto, como no caso de pontuação em um jogo:
Que incrível, Leandro! Esse Ruby é mesmo lindo, não?
Sim, Ruby é demais. Mas calma que não paramos por aí. A partir da versão 3.0, Ruby trouxe uma abstração por cima da Thread que resolve o problema de race condition sem precisar de mutex, justamente implementando um modelo de atores, através da classe Ractor, que faz exatamente tudo o que implementamos até aqui.
É isso mesmo, Ruby 3+ traz o conceito de atores prontinho pra gente, bastando definir como será o modelo conforme nosso requisito:
Quero reforçar aqui que o Ractor em Ruby ainda está em fase experimental, portanto não deve ser usado em produção.
Entretanto, acredito que muito em breve teremos isto pronto pra produção. Atualmente temos uma excelente oportunidade para contribuir, deixando a implementação de Ractors em Ruby mais robusta ao longo do tempo.
Para que seja thread-safe, uma característica muito importante do Ractor é que não acessa valores globais:
O que lança um erro:
Uma forma de resolver é recebendo a lista no bloco, pelo que o Ruby irá copiar o valor de LIST
para dentro do Ractor:
O mesmo vale para qualquer valor passado. É sempre copiado, e não movido:
Já vimos que não é possível modificar um valor fora do contexto do Ractor pois é feita uma cópia. E se tentarmos enviar a lista como mensagem para o Ractor? Será que conseguimos?
O Ractor continua garantindo a integridade dos dados, completamente thread-safe! Entretanto, se quisermos mover a referência da lista para dentro do Ractor, podemos fazer passando o argumento move: true
:
Vamos confirmar como está o valor da lista dentro do Ractor:
Continua na mesma, então vamos ver o valor original, se foi modificado ou não:
Superb! O valor foi movido, então isto significa que após o move, não podemos mais utilizar o valor original fora do contexto do Ractor, apenas dentro dele.
Desta forma, não foi feita uma cópia, mas sim um move.
Salve Rustaceans, vocês estão se sentindo em casa agora, né?
Eu sei que você está agora só o meme da Nazaré, mas já vimos que para criar um ator, precisamos apenas de filas. Mas também podemos criar uma fila thread-safe (ou melhor, ractor-safe) baseada em Ractors.
Tudo o que precisamos é que o ator:
fique bloqueado a espera de mensagens (Ractor.receive
)
repasse as mensagens para a caixa de saída (Ractor.yield
)
No way! Acabamos de implementar uma fila utilizando Ractors! Agora me diga, caro leitor, quem vem primeiro? O ovo ou a galinha? Fica como lição de casa 😄
O modelo de atores traz um desafio crucial em sistemas baseados neste modelo. Processos assíncronos vão falhar. Não é uma questão de se, mas quando.
Quando entramos no mundo assíncrono com modelo de atores, temos que ter algum grau de tolerência a falhas, ou seja, lidar com erros e estabelecer planos de ação bem definidos para que os atores continuem operantes mesmo com falhas pontuais.
Em Ruby, infelizmente, neste momento não temos nada que implemente uma forma de supervisionar Ractors que falham.
Mas a gente é maluco e implementa a nossa própria supervisão, né?
Com certeza. Antes de encerrar este tópico em Ruby, vamos implementar um modelo muito simples de supervisão.
Ainda no exemplo de uma conta onde é possível realizar depósitos e saques, a seguir definimos como é este modelo utilizando Ractors e o mais puro suco da orientação a objetos:
uma conta começa com um saldo inicial, enviado no construtor
no construtor, temos a definição do ator que, através dos métodos públicos do objeto Account
, recebe as mensagens para alterar o estado da conta bancária
Criando um cenário "feliz", vamos manipular a conta bancária:
Tudo perfeito até aqui, mostrando o saldo corretamente na saída do programa. Agora, vamos adicionar um pouco de entropia, simulando um crash no ator:
Simulamos o crash, então a seguir um depósito e a busca do saldo:
Uh, oh! No momento de buscar o saldo, o ator já não está mais saudável. Houve um crash que o deixou em estado de erro.
Precisamos então supervisionar este ator, de modo a garantir que após o crash, o funcionamento do ator continue normalmente.
Reforçando a implementação atual de Account
:
Para supervisionar um ator, precisamos de outro ator. Se formos pensar no funcionamento deste supervisor, podemos concluir que:
o supervisor é um ator, que:
cria um worker que será supervisionado
fica em loop "monitorando" o worker:
se está OK, salva o estado do worker e o coloca na outbox
se não está OK, cria outro worker com o estado atual do worker que falhou
E o processo principal, tudo o que precisa é buscar na outbox do supervisor uma conta para poder manipular. Usando, again, OOP, conseguimos atingir isto de forma muito intuitiva:
Precisamos implementar o método ping
no worker e também adicionar a mensagem no matching de mensagens da inbox:
Agora, só falta ligar tudo, criando o worker a partir do supervisor:
Até aqui tudo normal. Na saída conseguimos ver Balance is: 50 (expected: 50)
.
A seguir, simulamos um crash no worker:
So far, so good. Vamos fazer mais um depósito de 200, pelo que queremos que o worker tenha sido reiniciado pelo supervisor e que ao final tenha o saldo atualizado para 250 (50 anteriormente + 200 de agora):
Após o crash, podemos ver que o worker foi reiniciado e o novo depósito funcionou, sendo somado com o saldo anterior do ator que falhou.
How cool is that?
Se está gostando e considera colaborar para que mais trabalhos assim sejam feitos, não deixe de entrar para a lista de investidores premium.
"Tá de sacanagem Leandro kk"
É sério, qualquer apoio financeiro via PIX é mais que bem-vindo, inclusive compartilhar o guia é uma excelente forma de apoiar o projeto também!
Ou copia e cola:
No próximo tópico, vamos entrar no mundo do I/O não-bloqueante em Ruby. Apertem os cintos, pois a jornada ainda está longe do fim.