concorrencia101
  • Introdução
  • First things first
  • Agradecimentos
  • Parte I - Concorrência no sistema operacional
    • O que é o programa no sistema operacional
    • Escalonador preemptivo de tarefas
    • Uma nota sobre escalonamento cooperativo
    • Propriedades de um processo
    • Clone de processo (forking)
    • Clone leve de processo (thread)
    • Todo processo tem uma thread principal
    • Uma nota sobre paralelismo
    • Principais desafios em cenário de concorrência
      • Race condition
      • Sincronização com locks
      • Modelo de atores
    • E o I/O?
      • Latência de CPU vs Latência de I/O
      • Chamadas bloqueantes
      • Chamadas não-bloqueantes
      • Assincronismo e escalonamento cooperativo
    • Vamos colocar em prática...
  • PARTE II - Concorrência em diferentes linguagens
    • Definindo ambientes de execução
    • Concorrência em C
      • Forking de processos
      • Threads
      • Race condition e sincronização de threads com mutex
      • Desafios com o uso de threads
      • Thread Pool em C
      • Green threads
      • Modelo de Atores
      • Trabalhando com I/O
    • Concorrência em Ruby
      • Forking de processos
      • Threads
      • Race condition, YARV, GVL e paralelismo em Ruby
      • Modelo de Atores
      • Trabalhando com I/O
Powered by GitBook
On this page
  • Funcionamento do modelo de atores
  • 1. O ator deve ter estado privado
  • 2. O ator deve ter identificação única
  • 3. O ator deve se comunicar por envio de mensagens
  • 3.1. Envio de mensagens síncrono
  • 3.2. Envio de mensagens assíncrono
  • 3.3. Fila de mensagens
  • 3.4. Fila bloqueante (e thread-safe) de mensagens
  • Um ator que recebe mensagens
  • Um ator que recebe mas também envia mensagens
  • Um ator ultra genérico em Ruby
  • Modelo de atores com Ractors
  • Ractors não acessam valores globais ou fora do contexto
  • Movendo valores para dentro de um Ractor (alô, Rust!)
  • Filas podem ser construídas a partir de atores
  • Tolerância a falhas em Ruby com Ractors
  • Um ator que representa uma conta bancária
  • Supervisionando um ator
  • Está gostando deste trabalho?
  1. PARTE II - Concorrência em diferentes linguagens
  2. Concorrência em Ruby

Modelo de Atores

PreviousRace condition, YARV, GVL e paralelismo em RubyNextTrabalhando com I/O

Last updated 4 months ago

Se você leu a primeira parte do guia, deve se lembrar das :

  • 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.

Funcionamento do modelo de atores

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.

1. O ator deve ter estado privado

Para representar o ator, vamos mapear para uma thread:

actor = Thread.new do
  # Lógica do ator aqui
end

actor.value # nil

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:

actor = Thread.new do
  state = 41
  # Faz coisas...
  state += 1
  state
end

actor.value # 42

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:

state
# undefined local variable or method `state' for main:Object (NameError)

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:

actor = Thread.new(41) do |state|
  state += 1
  state
end

actor.value # 42

Foi passado para a thread o valor absoluto 41 , mas podemos também passar variáveis:

balance = 41

actor = Thread.new(balance) do |state|
  state += 1
  state
end

actor.value # 42 - a thread modificou seu estado interno
balance     # 41 - Wow! a thread não modificou o valor original de `balance`

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:

inbox = []

actor = Thread.new(inbox) do |queue|
  queue.push(42)
end

inbox # [42] - Ouch! O array original foi modificado pela thread! Not good...

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 :

inbox = []

# No momento do dup, é literalmente feita uma cópia do array inteiro e passada 
# como argumento para a thread
actor = Thread.new(inbox.dup) do |queue|
  queue.push(42)
end

inbox # []

Prontinho, já conseguimos cumprir com o primeiro requisito para modelo de atores: estado privado. Vamos ver o próximo requisito.

2. O ator deve ter identificação única

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:

t1 = Thread.new {}
t2 = Thread.new {}
t3 = Thread.new {}

t1.object_id # 1594340
t2.object_id # 1594341
t3.object_id # 1594342

Yay! Já temos o segundo requisito cumprido. Vamos ao terceiro requisito e não menos importante: envio de mensagens.

3. O ator deve se comunicar por 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.

actor = Thread.new do
  # Implementação
end

# undefined method `send_message' for #<Thread (irb):1 run> (NoMethodError)
actor.send_message("Hello")

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.

3.1. Envio de mensagens sí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.

3.2. Envio de mensagens assíncrono

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"?

3.3. Fila de mensagens

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:

inbox = []

actor = Thread.new(inbox) do |inbox|
  inbox.pop
end

actor.value # nil

E para enviar mensagens para a fila a partir do processo principal, temos o método push:

inbox.push(42)

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:

inbox = []

actor = Thread.new(inbox) do |inbox|
  loop do 
    puts "Message: #{inbox.pop}"
  end
end

actor.join

O que temos na saída é isto:

Message:
Message:
Message:
Message:
Message:
Message:
Message:
Message:
Message:
....................

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.

3.4. Fila bloqueante (e thread-safe) de mensagens

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.

inbox = []                                  # Representação da fila
mutex = Mutex.new                           # Mutex
condvar = ConditionVariable.new             # Condition

actor = Thread.new(inbox) do |inbox|
  # Thread fica em loop infinito
  loop do 
    mutex.synchronize do
      # Aqui, é enviado um "sinal" para a thread ficar em estado de WAIT
      #  no caso da fila estar vazia
      condvar.wait(mutex) if inbox.empty?

      # Quando a thread recebe o sinal de WAKE, continua o processamento
      puts "Received message: #{inbox.pop}"
    end
  end
end

mutex.synchronize do
  # Adiciona mensagem na fila e envia sinal de WAKE à thread que detém o mutex
  inbox.push(42)
  condvar.signal
end

# Espera 5 segundos antes de finalizar o programa, a título de vermos a mensagem
#  ser processada a tempo no terminal
sleep 5
Received message: 42

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:

class ThreadSae
  def initialize
    @queue = []
    @mutex = Mutex.new
    @condvar = ConditionVariable.new
  end

  def send(message)
    @mutex.synchronize do
      @queue.push(message)
      @condvar.signal
    end
  end

  def receive
    @mutex.synchronize do
      @condvar.wait(@mutex) if @queue.empty?
      @queue.pop
    end
  end
end

inbox = BlockingQueue.new
inbox.send(42)

inbox.receive # 42

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:

queue = Thread::Queue.new

actor = Thread.new(queue) do |inbox|
  loop do 
    puts "Received message: #{inbox.pop}"
  end
end

queue.push(42)

sleep 3

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.


Um ator que recebe mensagens

Vamos iniciar a implementação com uma classe Ruby bastante simples:

class Actor
end

A seguir, no método initialize definimos a fila de mensagens e a Thread que irá ficar em background processando as mensagens:

class Actor 
  def initialize
    @inbox = Thread::Queue.new

    Thread.new do 
      loop do
        # Implementação
      end
    end
  end
end

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

......
    Thread.new do 
      loop do
        message = @inbox.pop

        case message
        when :exit
          break
        else
          puts "Received message: #{message}"
        end
      end
    end
.....

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:

class Actor 
  def initialize
    @inbox = Thread::Queue.new

    Thread.new do 
      loop do
        message = @inbox.pop

        case message
        when :exit
          break
        else
          puts "Received message: #{message}"
        end
      end
    end
  end

  def send(message)
    @inbox.push(message)
  end

  def exit
    @inbox.push(:exit)
  end
end

##############################

actor = Actor.new
actor.send(42)     # Envia mensagem para o ator (async)

sleep 3

Saída esperada:

Received message: 42

Um ator que recebe mas também envia mensagens

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:

class Account
  def initialize
    @inbox = Thread::Queue.new    # Caixa de entrada
    @outbox = Thread::Queue.new   # Caixa de saída
    @balance = 0                  # Estado inicial do ator

    Thread.new do 
      loop do
        message = @inbox.pop      # Consome mensagem

        # OMG! Pattern matching é mesmo lindo, não?
        case message
        in deposit: amount  then @balance += amount
        in withdraw: amount then @balance -= amount
        in :balance         then @outbox.push(@balance) # Coloca saldo na caixa de saída
        end
      end
    end
  end

  def deposit(amount)
    @inbox.push(deposit: amount)
  end

  def withdraw(amount)
    @inbox.push(withdraw: amount)
  end

  def balance
    @inbox.push(:balance)
    @outbox.pop # Lê a mensagem que o ator deixou na caixa de saída
  end
end

account = Account.new
account.deposit(100)
account.withdraw(50)

puts "Balance is: #{account.balance} (expected: 50)"

Este código dispensa maiores comentários, não acha?


Um ator ultra genérico em Ruby

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:

class Actor
  def initialize(*args, &block)
    # Define as filas de mensagens (entrada e saída)
    @inbox  = Queue.new
    @outbox = Queue.new

    # Definição da thread em background, que vai avaliar o bloco dinâmico passado para o ator
    Thread.new do
      # Execução do block e resultado de retorno, enviando os argumentos
      #  que foram passados para o ator. Estes argumentos definem o "estado"
      #  do ator e vão ser repassados para o bloco
      result = block.call(self, *args)

      # Utiliza o retorno do bloco para chamar um método que "transfere"
      #  o controle assíncrono, ou seja, coloca o resultado na caixa de saída
      self.yield(result)
    end

    # Após a instanciação do objeto do ator, retorna a própria instância para
    #  ser usada em outro contexto
    self
  end
end

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:

class Actor
  def initialize(*args, &block)
    @inbox  = Queue.new
    @outbox = Queue.new

    Thread.new do
      result = block.call(self, *args)

      self.yield(result)
    end

    self
  end

  def receive
    @inbox.pop
  end

  def send(element)
    @inbox.push(element)
    self
  end

  def yield(element)
    @outbox.push(element)
  end

  def take
    @outbox.pop
  end
end

E agora, podemos utilizar este ator para diversos cenários, como no caso de uma conta bancária:

account = Actor.new(0) do |instance, balance|
  loop do
    message = instance.receive

    case message
    in deposit: value  then balance += value.to_i
    in withdraw: value then balance -= value.to_i
    in :balance        then instance.yield(balance)
    end
  end
end

100.times do
  account.send(deposit: 1)
end

account.send(:balance)
balance = account.take

puts "Balance is: #{balance} (expected: 100)"

Ou ainda para outro contexto, como no caso de pontuação em um jogo:

game = Actor.new(0) do |instance, score|
  loop do
    message = instance.receive

    case message
    in :score then instance.yield(score)
    in :increment then score += 1
    end
  end
end

42.times do
  game.send(:increment)
end

score = game.send(:score).take
puts "Score is: #{score} (expected: 42)"

Que incrível, Leandro! Esse Ruby é mesmo lindo, não?


Modelo de atores com Ractors

É isso mesmo, Ruby 3+ traz o conceito de atores prontinho pra gente, bastando definir como será o modelo conforme nosso requisito:

account = Ractor.new(0) do |balance|
  loop do
    message = Ractor.receive # Lê mensagem da inbox

    case message
    in :balance        then Ractor.yield(balance) # Coloca mensagem na outbox
    in deposit: value  then balance += value.to_i
    in withdraw: value then balance -= value.to_i
    end
  end
end

account.send({ deposit: 100 }) # Envia mensagem para a inbox do ator
account.send({ withdraw: 50 }) # Envia mensagem para a inbox do ator

account.send(:balance) # Envia mensagem para a inbox do ator
balance = account.take # Lê mensagem da outbox do ator

puts "Balance is: #{balance} (expected: 50)"

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.

Ractors não acessam valores globais ou fora do contexto

Para que seja thread-safe, uma característica muito importante do Ractor é que não acessa valores globais:

LIST = []

Ractor.new { LIST.push(42) }

O que lança um erro:

#<Thread:0x0000000102ec5740 run> terminated with exception (report_on_exception is true):
(irb):35:in `block in <top (required)>': can not access non-shareable objects in constant Object::LIST by non-main Ractor. (Ractor::IsolationError)

Uma forma de resolver é recebendo a lista no bloco, pelo que o Ruby irá copiar o valor de LIST para dentro do Ractor:

Ractor.new(LIST) { |list| list.push(42) }

# LIST segue como array vazio, pois foi copiado inteiramente para dentro do Ractor
LIST

O mesmo vale para qualquer valor passado. É sempre copiado, e não movido:

original_list = [1, 2, 3]

ractor = Ractor.new(original_list) do |copied_list|
  copied_list.push(42) # Modifica a cópia recebida
  copied_list
end

puts "Original LIST: #{original_list.inspect}" # Não foi alterada
puts "Modified list inside Ractor: #{ractor.take.inspect}" # A cópia foi alterada

Movendo valores para dentro de um Ractor (alô, Rust!)

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?

original_list = [1, 2, 3]

ractor = Ractor.new do
    list = Ractor.receive
    list.push(42)
end

ractor.send(original_list)

original_list # [1, 2, 3]   Original não modificado (ainda bem!)
ractor.take # [1, 2, 3, 42] Cópia modificada

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 :

original_list = [1, 2, 3]

ractor = Ractor.new do
    list = Ractor.receive
    list.push(42)
end

ractor.send(original_list, move: true)

Vamos confirmar como está o valor da lista dentro do Ractor:

ractor.take # [1, 2, 3, 42]

Continua na mesma, então vamos ver o valor original, se foi modificado ou não:

original_list # #<Ractor::MovedObject:0x0000000107265810>

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é?

Filas podem ser construídas a partir de atores

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)

queue = Ractor.new do 
  loop do 
    Ractor.yield(Ractor.receive)
  end
end

queue.send(42)
queue.take # 42

Tolerância a falhas em Ruby com Ractors

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.

Um ator que representa uma conta bancária

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:

class Account 
  def initialize(balance)
    @actor = Ractor.new(balance) do |balance|
      loop do
        message = Ractor.receive

        case message
        in deposit: amount  then balance += amount
        in withdraw: amount then balance -= amount
        in :balance         then Ractor.yield({ balance: balance })
        end
      end
    end
  end

  def deposit(amount)
    @actor.send({ deposit: amount })
  end

  def withdraw(amount)
    @actor.send({ withdraw: amount })
  end

  def balance
    @actor.send(:balance)
    @actor.take[:balance]
  end
end
  • 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:

account = Account.new(0)
account.deposit(100)
account.withdraw(50)

puts "Balance is: #{account.balance} (expected: 50)"

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:

# Adicionando mais matches na mensagem da inbox:

case message
in deposit: amount  then balance += amount
in withdraw: amount then balance -= amount
in :balance         then Ractor.yield({ balance: balance })
in :crash           then raise "Crash!" # <------------------------
end

.........
  def simulate_crash!
    @actor.send(:crash)
  end
.........

Simulamos o crash, então a seguir um depósito e a busca do saldo:

account.simulate_crash!
account.deposit(200)

puts "Balance is: #{account.balance} (expected: 250)"
Balance is: 50 (expected: 50)

#<Thread:0x0000000103170468 run> terminated with exception (report_on_exception is true):
actor-supervisor.rb:11:in `block (2 levels) in initialize': Crash! (RuntimeError)
        from actor-supervisor.rb:4:in `loop'
        from actor-supervisor.rb:4:in `block in initialize'
<internal:ractor>:698:in `take': thrown by remote Ractor. (Ractor::RemoteError)
        from actor-supervisor.rb:28:in `balance'
        from actor-supervisor.rb:77:in `<main>'
actor-supervisor.rb:11:in `block (2 levels) in initialize': Crash! (RuntimeError)
        from actor-supervisor.rb:4:in `loop'
        from actor-supervisor.rb:4:in `block in initialize'

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.

Supervisionando um ator

Reforçando a implementação atual de Account :

class Account 
  def initialize(balance)
    @actor = Ractor.new(balance) do |balance|
      loop do
        message = Ractor.receive

        case message
        in deposit: amount  then balance += amount
        in withdraw: amount then balance -= amount
        in :balance         then Ractor.yield({ balance: balance })
        in :crash           then raise "Crash!"
        end
      end
    end
  end

  def deposit(amount)
    @actor.send({ deposit: amount })
  end

  def withdraw(amount)
    @actor.send({ withdraw: amount })
  end

  def balance
    @actor.send(:balance)
    @actor.take[:balance]
  end

  def simulate_crash!
    @actor.send(:crash)
  end
end

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:

class AccountSupervisor
  def initialize
    @actor = Ractor.new do
      # Define o estado inicial (que é o atual também)
      #  e uma conta que será o "worker" supervisionado
      current_balance = 0
      account = Account.new(current_balance)

      loop do
        begin
          # Verifica se o worker está saudável
          response = account.ping
        
          if response && response[:msg] == 'PONG'
            # Se estiver saudável, atualiza o estado atual do worker no supervisor
            # e coloca o worker na caixa de saída (outbox)
            current_balance = response[:balance]
            Ractor.yield(account)
          else 
            # Caso contrário, lança um erro (não vamos tratar este erro por enquanto)
            raise "Account is not responding"
          end
        rescue Ractor::RemoteError, Ractor::ClosedError => e
          # Caso o worker não esteja saudável, como por exemplo sofreu um _crash_, 
          #  cria um novo worker utilizando o estado atual
          puts "[Supervisor] Account crashed with error: #{e.message}. Restarting..."
          account = Account.new(current_balance)
        end
      end
    end
  end

  # Método público que vai ser utilizado pelo processo princial para
  #  manipular uma conta (worker)
  def account
    @actor.take
  end
end

Precisamos implementar o método ping no worker e também adicionar a mensagem no matching de mensagens da inbox:

class Account ......

case message
in deposit: amount  then balance += amount
in withdraw: amount then balance -= amount
in :balance         then Ractor.yield({ balance: balance })
in :crash           then raise "Crash!"
in :ping            then Ractor.yield({ msg: 'PONG', balance: balance })
class Account .....

def ping
  @actor.send(:ping)
  @actor.take
end

Agora, só falta ligar tudo, criando o worker a partir do supervisor:

supervisor = AccountSupervisor.new
supervisor.account.deposit(100)
supervisor.account.withdraw(50)

puts "Balance is: #{supervisor.account.balance} (expected: 50)"

Até aqui tudo normal. Na saída conseguimos ver Balance is: 50 (expected: 50) .

A seguir, simulamos um crash no worker:

supervisor.account.simulate_crash!

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):

supervisor.account.deposit(200)
puts "Balance is: #{supervisor.account.balance} (expected: 250)"
Balance is: 50 (expected: 50)

#<Thread:0x0000000104e4fd18 run> terminated with exception (report_on_exception is true):
actor-supervisor.rb:11:in `block (2 levels) in initialize': Crash! (RuntimeError)
        from actor-supervisor.rb:4:in `loop'
        from actor-supervisor.rb:4:in `block in initialize'
        
[Supervisor] Account crashed with error: thrown by remote Ractor.. Restarting...
Balance is: 250 (expected: 250)

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?


Está gostando deste trabalho?

"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:

00020126850014BR.GOV.BCB.PIX013638ee4bde-574b-4197-b10f-68742087b00b0223Gratidão pelo cafezinho5204000053039865802BR5925Leandro Freitas Maringolo6009SAO PAULO62140510qrN6Ov1wRl63041A3C

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.

Este conceito já foi explicado no módulo de .

Além de Mutex, em Ruby também temos uma abstração para o uso de :

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 , que faz exatamente tudo o que implementamos até aqui.

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

Se está gostando e considera colaborar para que mais trabalhos assim sejam feitos, não deixe de entrar para a .

😄
Concorrência em C
Conditions
classe Ractor
lista de investidores premium
propriedades de um processo
modelo de atores
representação final do ator Account