Concorrência em Ruby: Guia Completo de Threads, Fibers e Ractors

A capacidade de executar múltiplas tarefas simultaneamente é uma das funcionalidades mais poderosas da programação moderna. Quando um programa precisa aguardar uma tarefa ser concluída, como uma chamada de API para um servidor lento, o multitasking permite transferir o controle para outra tarefa e realizar trabalho útil enquanto aguarda. Este tutorial abrangente explorará as três principais abstrações de concorrência do Ruby: Threads, Fibers e Ractors.

O Global Interpreter Lock (GIL) do Ruby

Antes de mergulharmos nas implementações práticas, é fundamental compreender que historicamente os programas Ruby possuem um Global Interpreter Lock (GIL), que garante que apenas uma thread seja executada pelo Ruby por vez. O GIL é uma das formas que o Ruby usa para proteger a segurança das threads, mas também significa que você não pode aproveitar múltiplas CPUs paralelas com um único interpretador Ruby (exceto com a biblioteca Ractor).

Multithreading com Threads

A classe Thread é o mecanismo de nível mais baixo no Ruby para fazer duas coisas ao mesmo tempo. Vamos começar com um exemplo prático que baixa um conjunto de páginas web em paralelo:

require "net/http"

paginas = %w[www.rubycentral.org www.pragprog.com www.google.com]

threads = paginas.map do |pagina_para_buscar|
  Thread.new(pagina_para_buscar) do |url|
    http = Net::HTTP.new(url, 80)
    print "Buscando: #{url}\n"
    resposta = http.get("/")
    print "Obtido #{url}: #{resposta.message}\n"
  end
end

threads.each { |thread| thread.join }
print "Terminamos aqui!\n"

Aspectos Importantes da Criação de Threads

Note que passamos o URL como parâmetro para o bloco, mesmo que o mesmo valor já esteja disponível como pagina_para_buscar fora do bloco. Isso é crucial para segurança de threads:

# Problemático - variável compartilhada
for pagina in paginas
  Thread.new do
    # Todas as threads compartilham a variável 'pagina'
    puts pagina # Pode imprimir valores inesperados!
  end
end

# Correto - cada thread tem sua própria cópia
for pagina in paginas
  Thread.new(pagina) do |url|
    puts url # Seguro!
  end
end

Variáveis de Thread

As threads podem armazenar variáveis locais por thread que podem ser acessadas por outras threads. Você pode tratar o objeto thread como se fosse um Hash:

contador = 0
threads = 10.times.map do
  Thread.new do
    sleep(rand(0.1))
    Thread.current[:meu_contador] = contador
    contador += 1
  end
end

threads.each do |t|
  t.join
  print t[:meu_contador], ", "
end

puts "contador = #{contador}"

Sincronização via Mutex

Um dos maiores desafios no multithreading são as condições de corrida (race conditions). Vamos ver um exemplo problemático e sua solução:

# Código problemático - condição de corrida
soma = 0
threads = 10.times.map do
  Thread.new do
    100_000.times do
      novo_valor = soma + 1
      print "#{novo_valor} " if novo_valor % 250_000 == 0
      soma = novo_valor
    end
  end
end

threads.each(&:join)
puts "\nsoma = #{soma}" # Provavelmente menor que 1.000.000!

Agora vamos corrigir isso usando um Mutex (mutually exclusive):

# Solução com Mutex
soma = 0
mutex = Thread::Mutex.new

threads = 10.times.map do
  Thread.new do
    100_000.times do
      mutex.synchronize do
        novo_valor = soma + 1
        print "#{novo_valor} " if novo_valor % 250_000 == 0
        soma = novo_valor
      end
    end
  end
end

threads.each(&:join)
puts "\nsoma = #{soma}" # Agora será exatamente 1.000.000!

Uso Avançado de Mutex

Aqui está um exemplo mais sofisticado usando try_lock para evitar bloqueio desnecessário:

mutex_taxa = Thread::Mutex.new
taxas_cambio = TaxasCambio.new

# Thread de background para atualizar taxas
Thread.new do
  loop do
    sleep(3600) # Atualiza a cada hora
    mutex_taxa.synchronize do
      taxas_cambio.atualizar_do_feed_online
    end
  end
end

# Loop principal
loop do
  print "Digite o código da moeda e valor: "
  linha = gets
  
  if mutex_taxa.try_lock
    begin
      puts(taxas_cambio.converter(linha))
    ensure
      mutex_taxa.unlock
    end
  else
    puts "Desculpe, taxas sendo atualizadas. Tente novamente em um minuto"
  end
end

Criando Fibers

Fibers são um mecanismo para denotar um bloco de código que pode ser parado e reiniciado. São cooperativamente multitarefa, o que significa que a responsabilidade de ceder controle fica com as fibers individuais. Vamos ver um exemplo prático de contagem de palavras:

# Abordagem tradicional (sem fibers)
contagens = Hash.new(0)
File.foreach("./arquivo_teste") do |linha|
  linha.scan(/\w+/) do |palavra|
    palavra = palavra.downcase
    contagens[palavra] += 1
  end
end

Agora vamos refatorar usando Fibers para separar a lógica de encontrar palavras da lógica de contá-las:

# Implementação com Fiber
palavras = Fiber.new do
  File.foreach("./arquivo_teste") do |linha|
    linha.scan(/\w+/) do |palavra|
      Fiber.yield palavra.downcase
    end
  end
  nil
end

contagens = Hash.new(0)
while (palavra = palavras.resume)
  contagens[palavra] += 1
end

contagens.keys.sort.each { |k| print "#{k}:#{contagens[k]} " }

Fibers para Sequências Infinitas

Fibers podem ser usadas para gerar valores de sequências infinitas sob demanda:

pares = Fiber.new do
  num = 2
  loop do
    Fiber.yield(num) unless num % 3 == 0
    num += 2
  end
end

10.times { print pares.resume, " " }
# => 2 4 8 10 14 16 20 22 26 28

Compreendendo Ractors

Ruby 3.0 introduziu ractors, uma implementação Ruby do padrão Actor para comportamento multithread. Ractors permitem verdadeiro paralelismo dentro de um único interpretador Ruby: cada ractor mantém seu próprio GIL, permitindo potencialmente melhor performance.

Como Funcionam os Ractors

Você pode pensar em um ractor como um pedaço de código que tem uma única porta de entrada e uma única porta de saída. Ractors interagem principalmente de quatro maneiras:

  • send - Um ractor pode enviar argumentos para outro ractor conhecido
  • take - Um ractor pode receber saída de outro ractor conhecido
  • Ractor.receive - Dentro do ractor, pode aguardar mensagens
  • Ractor.yield - Dentro do ractor, pode enviar valores para outro ractor
# Exemplo de contagem de palavras com Ractors
leitor = Ractor.new(name: "leitor") do
  File.foreach("arquivo_teste") do |linha|
    linha.scan(/\w+/) do |palavra|
      Ractor.yield(palavra.downcase)
    end
  end
  nil
end

contador = Ractor.new(leitor, name: "contador") do |fonte|
  resultado = Hash.new(0)
  while(palavra = fonte.take)
    resultado[palavra] += 1
  end
  resultado
end

contagens = contador.take
contagens.keys.sort.each { |k| print "#{k}:#{contagens[k]} " }

Como Ractors Passam Variáveis

Ractors aplicam semânticas especiais aos valores passados usando send ou yield. O mundo dos ractors divide objetos Ruby em "compartilháveis" ou "não compartilháveis":

  • Objetos compartilháveis: true, false, nil, símbolos, inteiros pequenos, objetos frozen
  • Objetos não compartilháveis: São copiados, a menos que você use move: true
# Aguardando múltiplos ractors
r1 = Ractor.new { sleep 1; "resultado1" }
r2 = Ractor.new { sleep 2; "resultado2" }
r3 = Ractor.new { sleep 0.5; "resultado3" }

# Ractor.select retorna o primeiro que terminar
ractor, valor = Ractor.select(r1, r2, r3)
puts "#{ractor} completou primeiro com: #{valor}"
# => resultado3

Executando Múltiplos Processos Externos

Ruby também oferece várias maneiras de gerar e gerenciar processos separados. Isso é útil quando você quer aproveitar múltiplos núcleos do processador ou executar um processo separado que não foi escrito em Ruby:

# Métodos básicos para spawning de processos
system("tar xzf teste.tgz")    # => true
spawn("date")                 # => 38483
resultado = `date`            # Captura a saída

# Executando processos independentes
pid = spawn("sort arquivo_teste > saida.txt")
# O sort está rodando em um processo filho
# Continuar processamento no programa principal
# ... dum di dum ...
# Depois aguardar o sort terminar
Process.wait(pid)

Comunicação com Subprocessos usando IO.popen

# Usando IO.popen para comunicação bidirecional
IO.popen("date") { |f| puts "Data é #{f.gets}" }

# Fork de um novo interpretador Ruby
novo_pipe = IO.popen("-","w+")
if novo_pipe
  novo_pipe.puts "Arranje um trabalho!"
  $stderr.puts "Sou o pai, o filho me disse '#{novo_pipe.gets.chomp}'"
else
  $stderr.puts "Sou o filho, o pai me disse '#{gets.chomp}'"
  puts "OK"
end

Melhores Práticas e Considerações

Ao trabalhar com concorrência em Ruby, algumas diretrizes importantes:

  • Thread Safety: Sempre proteja recursos compartilhados com Mutex
  • Evite Race Conditions: Use sincronização adequada para acesso a dados compartilhados
  • Fibers para Cooperação: Use fibers quando você quiser controle explícito sobre quando ceder execução
  • Ractors para Paralelismo: Use ractors quando precisar de verdadeiro paralelismo entre CPUs
  • Processos para Isolamento: Use processos externos para tarefas que precisam rodar em paralelo com isolamento completo

A escolha entre threads, fibers, ractors e processos depende das suas necessidades específicas. Threads são ideais para I/O concorrente, fibers para controle cooperativo, ractors para paralelismo real, e processos para tarefas completamente independentes que podem aproveitar múltiplos núcleos de CPU.

Comentários (0)

Nenhum comentário:

Postar um comentário