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.