Eu fiz um tour pela concorrência do Ruby enquanto preparava uma palestra para a RubyConf sobre Ractors. Ao começar, eu conhecia o Puma, o Falcon e o Unicorn. Ao terminar, eu tinha adicionado o Pitchfork a essa lista – e notei algo que não tinha visto antes: cada primitiva de concorrência do Ruby tem pelo menos um servidor web de produção construído sobre ela.
Puma é o caso para threads. Pitchfork é o caso para processos. Falcon é o caso para fibers. Todos os três respondem à mesma pergunta: como sirvo a próxima requisição antes que esta termine? – e eles respondem isso de três maneiras completamente diferentes.
Exceto os Ractors. A quarta primitiva de concorrência do Ruby – a única que oferece código Ruby realmente paralelo em múltiplos núcleos dentro de um único processo – não tem nenhum servidor web de produção construído sobre ela. Nenhum. Ao final deste post, você entenderá exatamente o porquê, e o que o Ruby 4.0 começa a mudar.
O que um servidor web realmente faz
Um servidor web é a coisa que fica entre a internet e seu app Ruby. As requisições chegam, o servidor as entrega ao seu código, seu código retorna uma resposta e o servidor a envia de volta. Puma, Falcon e Pitchfork fazem exatamente esse trabalho.

A pergunta interessante – a pergunta sobre a qual este post realmente trata – é o que acontece quando mais de um cliente quer atendimento ao mesmo tempo. O servidor mais simples possível consegue lidar com apenas uma requisição por vez: até que seu código retorne, nada mais passa. Todos os outros esperam na fila.
O que "paralelo" realmente significa em Ruby
Antes de olharmos para os três servidores, há um fato que molda cada decisão que eles tomam.
Ruby – a implementação CRuby que você quase certamente está usando – possui um Global VM Lock, o GVL. A regra é simples e brutal: apenas uma thread executa bytecode Ruby por vez, por processo. Crie 100 threads. O SO irá agendá-las alegremente. Mas dentro da VM do Ruby, exatamente uma está executando código Ruby em qualquer instante dado. As demais esperam sua vez.
Então, dado o GVL, como você realmente executa mais de uma coisa ao mesmo tempo em Ruby? Três respostas.
Primeiro, o GVL é liberado em I/O. No momento em que uma thread bloqueia em uma leitura de socket, uma consulta ao banco de dados, uma leitura de arquivo – qualquer coisa que chame o kernel e espere – ela solta o GVL e outra thread o assume. Threads proporcionam concorrência em I/O, embora não proporcionem paralelismo em CPU.
Segundo, o GVL é por processo. Dois processos têm dois GVLs independentes. Eles realmente rodam em paralelo, em dois núcleos, sem contenção. É por isso que fork é uma estratégia de concorrência séria em Ruby e não apenas um truque antigo do Unix – se você quer duas coisas limitadas por CPU rodando ao mesmo tempo, dois processos é como você consegue isso.
Terceiro, fibers nem sequer precisam do SO para agendá-las. Uma fiber é uma corrotina – uma unidade minúscula de execução que pausa e retoma em Ruby puro, sem thread de SO própria. Milhares de fibers podem viver dentro de uma thread, compartilhando um GVL, trocando o controle cooperativamente. Desde o Ruby 3.0, um agendador de fibers pode suspender automaticamente uma fiber em I/O e retomar outra – que é como você lida com dez mil conexões ociosas sem criar dez mil threads.

Mantenha esta imagem na sua cabeça:
- Paralelismo na CPU -> múltiplos processos (ou Ractors, falaremos mais sobre eles depois)
- Concorrência em I/O -> threads são suficientes
- Concorrência em muitas conexões inativas -> fibers fazem isso de forma mais barata que threads
Puma, Falcon e Pitchfork cada um adotou um desses padrões e construiu um servidor web em torno dele. Nenhum deles é "mais avançado" que os outros – são três formatos diferentes. Vamos percorrê-los na mesma ordem, começando pela unidade de concorrência supostamente mais pesada: o processo do SO. Toda a razão de existência do Pitchfork é tornar esse rótulo menos verdadeiro do que parece.
Pitchfork – fork the world
A resposta mais antiga no Unix: quando você precisa fazer outra coisa, copie o processo.
A família Unicorn – Unicorn e depois Pitchfork – inicia um processo mestre, abre o socket de escuta e então fork N workers filhos. Cada filho herda o socket de escuta. Cada filho faz um loop em accept. O kernel decide qual filho acorda para cada nova conexão.
Toda a "concorrência" é o escalonador do SO executando N cópias do Ruby em N núcleos. Sem threads. Sem fibers. Sem estado mutável compartilhado. Cada requisição recebe uma VM Ruby nova, previsível e isolada. O GVL não importa porque cada worker é seu próprio GVL.
WORKERS = 4
listener = TCPServer.new(4000)
WORKERS.times do
fork do
loop do
conn = listener.accept
handle_request(conn, app)
conn.close
end
end
end
Process.waitallA força é total: workers não podem corromper o estado uns dos outros porque não compartilham estado. Um worker com vazamento de memória? Mate-o, faça o fork de outro. Um worker travado em uma query lenta? Mate-o, faça o fork de outro. É por isso que Shopify, GitHub e basicamente todo monólito Rails de grande porte rodaram no Unicorn por uma década.
O problema é a memória. fork
deveria ser barato graças ao copy-on-write – pai e filho compartilham páginas de memória física até que um deles escreva, então N workers deveriam custar muito menos que N processos independentes. Na prática, a memória compartilhada se esvai sob tráfego real. Páginas são tocadas, e cada worker deriva para ter sua própria cópia completa do heap. Este é exatamente o problema que o Pitchfork foi construído para resolver, e a solução é incomumente inteligente. O truque é chamado de refork.
Em vez de fazer o fork de cada novo worker a partir do master frio, o Pitchfork deixa um worker aquecer – atender tráfego real, preencher seus caches, estabilizar no formato que manterá – e então promove esse worker quente a um "molde." O próximo worker é feito via fork do molde, não do master. Ele nasce pré-aquecido, e a maior parte de sua memória ainda é genuinamente compartilhada com o molde de onde veio.

Na escala da Shopify, isso é ouro da engenharia. Cada página de memória suja em milhares de servidores Rails eventualmente se torna um número em uma fatura. O refork reduz a frota a ponto de o truque valer a pena ser construído do zero – um monólito Rails que costumava precisar de um pequeno exército de máquinas agora cabe em uma fração da mesma frota. (A Shopify escreveu sobre o impacto em seu monólito.)
Você paga pela inteligência na complexidade. O master coordena o ciclo de vida de um molde. A dança do refork tem seus próprios modos de falha. Há decisões a tomar sobre quando um worker deve se tornar o molde e com que frequência rotacioná-los. Mas a estrutura subjacente – um master, N workers, cada um sendo seu próprio Ruby isolado – ainda é o modelo de processo simples e previsível que funciona desde a década de 1970.
Puma – thread the world
A única unidade de concorrência do Pitchfork é o processo – um worker, uma requisição por vez. A segunda resposta mantém a mesma estrutura de workers forked (um processo por núcleo, a recomendação padrão de Nate Berkopec), mas empilha threads dentro de cada worker: agora, um processo Ruby pode lidar com muitas requisições em curso ao mesmo tempo. Este é o modelo que a maioria dos apps Rails realmente executa.
Dentro de cada worker, o Puma é o servidor de thread-pool canônico:
- Uma acceptor thread faz um loop em
server.accepte envia cada conexão para uma fila. - Um pool de worker threads faz um loop em
queue.pop, processa a requisição Rack e envia a resposta de volta. - A fila é um
SizedQueue, para que o pool possa aplicar backpressure quando estiver sobrecarregado.
QUEUE = SizedQueue.new(64)
POOL_SIZE = 16
# Acceptor
Thread.new do
loop { QUEUE << server.accept }
end
# Workers
POOL_SIZE.times do
Thread.new do
loop do
conn = QUEUE.pop
handle_request(conn, app)
conn.close
end
end
end
Leves. Baratas para criar. Dentro de um worker, todas as threads compartilham o app carregado – sem pegada de memória do Rails por thread. 16 threads custam aproximadamente o que 1 thread custa, em RAM.
O que faz as threads funcionarem para requisições web é simples: a maior parte de uma requisição é I/O. O Rails passa a maior parte do tempo esperando pelo PostgreSQL, Redis ou alguma chamada HTTP externa – e o GVL é liberado em cada uma dessas esperas. Enquanto um worker do Pitchfork fica bloqueado em uma única consulta lenta, um worker do Puma com 16 threads pode ter 16 requisições diferentes estacionadas em I/O ao mesmo tempo. O pool de threads está fazendo exatamente a carga de trabalho para a qual foi projetado.
O modelo de pool de threads para de valer a pena quando cada conexão precisa de uma thread dedicada por um longo período. WebSockets. Long-polling. Server-sent events. Cada um deles retém uma thread por minutos ou horas, não fazendo quase nada – a conexão permanece ativa, mesmo que não haja trabalho real acontecendo nela. O pool de threads de cada worker enche com conexões ociosas e, assim que cada worker × cada thread estiverem ocupados, o próximo cliente espera.
Você pode aumentar a contagem de threads – mas threads não são gratuitas. Cada uma tem sua própria pilha (~1 MB por padrão), e cada uma compete pelo GVL mesmo quando está apenas acordando para verificar um socket. Existe um limite, e ele é menor do que você gostaria para um servidor de chat.
Falcon – fiber the world
Threads funcionam muito bem até que suas conexões comecem a durar minutos ou horas em vez de milissegundos. Cada cliente WebSocket além do limite do pool de threads é um cliente esperando na fila – é aí que a estrutura do Puma chega ao fim. A resposta do Falcon é descartar as threads inteiramente.
O Falcon, assim como o Puma, inicia por padrão uma frota de workers forkados – um por núcleo de CPU. A parte interessante é o que acontece dentro de um worker: uma thread de SO, um loop de eventos, e cada conexão aceita torna-se uma fiber.
A gem async de Samuel Williams implementa o scheduler que faz isso funcionar. O Falcon é construído sobre ela. A estrutura dentro de um worker:
require 'async'
Async do |task|
server = TCPServer.new('0.0.0.0', 4000)
loop do
conn = server.accept
task.async do |subtask|
handle_request(conn, app)
conn.close
end
end
end
Que task.async do cria uma fiber por conexão. Quando uma fiber atinge I/O, o escalonador a suspende e executa a próxima que estiver pronta. O epoll/kqueue do kernel faz a espera; o Ruby apenas percorre o conjunto de prontos.
Essa é a resposta para a carga de trabalho com a qual o Puma luta. Dez mil clientes WebSocket enviando um heartbeat a cada 30 segundos? O Falcon nem sente. Long-polling, SSE, qualquer coisa majoritariamente ociosa – fibers ociosas custam quase nada.
Onde o Falcon deixa de valer a pena é no trabalho limitado pela CPU. O forking oferece paralelismo entre núcleos, mas dentro de um worker, apenas uma fiber executa Ruby por vez – uma única requisição pesada não pode se distribuir. O pool de threads do Puma consegue fazer round-robin de requisições mistas de CPU; o reactor do Falcon não consegue.
A outra coisa que o Falcon exige de você é consciência. Uma gem bloqueante cuja extensão em C não se conecta ao escalonador de fibers congela todo o worker – todas as outras fibers param com ela. mysql2 costumava causar problemas aqui antes de desenvolver patches compatíveis com o escalonador. O ecossistema está majoritariamente corrigido agora, mas o modo de falha é real.
Três formatos, lado a lado
Esse foi o tour. Três servidores, três primitivas, três formatos completamente diferentes:
| — | ||||||||
|---|---|---|---|---|---|---|---|---|
| Pitchfork | Processo do SO | O kernel executa N cópias do Ruby em N núcleos. Cada requisição vive em sua própria VM isolada com seu próprio GVL. | Paralelismo de CPU com isolamento rígido entre requisições | |||||
| Puma | Thread do SO | Um pool de threads dentro de um processo. O GVL serializa a execução do Ruby, mas é liberado em I/O, então as threads podem aguardar bancos de dados e sockets em paralelo. | Requisições web limitadas por I/O – threads se acumulam em esperas de DB, Redis e HTTP enquanto o GVL é liberado | |||||
| Falcon | Fiber + event loop | Milhares de fibers agendadas cooperativamente por um único event loop em uma única thread do SO. O kernel faz a espera via epoll/kqueue; o Ruby apenas percorre o conjunto de prontos. | Milhares de conexões de longa duração majoritariamente inativas – tempo real, chat, streaming |
O GVL desempenha um papel completamente diferente em cada um. O Pitchfork o evita – cada worker é seu próprio GVL. O Puma trabalha ao redor dele ao se apoiar no fato de que o I/O libera o lock. O Falcon o torna quase irrelevante por ter apenas uma execução de Ruby executável por vez, de qualquer modo.
Mesmo Ruby. Mesmo Rack. Três maneiras completamente diferentes de atender a mais de uma requisição.
Por que Ractors não estão nesta lista
A quarta primitiva de concorrência do Ruby são os Ractors – a única maneira de executar Ruby verdadeiramente paralelo em um único processo. Múltiplos Ractors, múltiplos núcleos, sem GVL compartilhado. Eles estão na linguagem desde a versão 3.0, e o Ruby 4.0 finalmente os torna eficientes e ergonômicos o suficiente para serem considerados seriamente.
No papel, Ractors deveriam simplificar muita coisa. Todo o motivo de existência do Pitchfork é a engenharia necessária para manter workers forkados compartilhando memória – a promoção de warm-mold, a dança do refork, tudo isso. Ractors dariam a você o mesmo paralelismo no mesmo processo nativamente, sem a necessidade de acrobacias de refork. Então, por que ninguém construiu um servidor web baseado em Ractor?
Duas razões.
O que Ractors exigem: isolamento rigoroso. Nada mutável pode ser compartilhado entre Ractors – tudo o que for passado deve ser imutável ou copiado. Essa e a garantia de segurança que permite que o GVL desapareça.
Um app Rails é cheio de estado compartilhado mutável: pools de conexão, configurações de nível de classe, caches, singletons internos de gems. Tornar qualquer um deles seguro para Ractors é difícil. Tornar toda a stack segura é reescrever o app inteiro. (byroot escreveu a melhor análise profunda sobre isso; rails/rails#51543 acompanha o lado prático.)
O que Ractors realmente entregariam: paralelismo no mesmo processo. N cores executando Ruby em paralelo, sem fazer fork de N cópias do app – paralelismo de CPU e economia de memória, no mesmo pacote. Ambas as vitórias são reais. Nenhuma delas compensa para um servidor web.
A economia de memória falha nos preços da nuvem. Memória e CPU vêm agrupados em proporções fixas, tipicamente de 2 a 4 GB por core. Uma frota típica de workers Rails usa muito menos memória do que o provedor de nuvem vende junto com a CPU; a maior parte da RAM na sua fatura está apenas lá parada. Economias de memória só importam se permitirem que você compre uma máquina menor, e apps Rails não estão nem perto desse limite.
Do lado da CPU, o Pitchfork já oferece N núcleos paralelos via fork. Ractors permitiriam que você pulasse as acrobacias de refork, claro – mas eles as substituiriam por uma reescrita completa do app para a segurança do Ractor. Você estaria trocando uma complexidade de engenharia por outra muito pior.
Você estaria reescrevendo metade do seu app para reivindicar economias que não pode gastar, em um problema (paralelismo no processo) que o Pitchfork já resolve.
Isso não significa que os Ractors estão mortos. Jobs de background, trabalho de lote isolado, partes de um app que você pode separar e controlar – eles se encaixam bem aí. É isso que explorarei na RubyConf em novembro: o que muda quando o Ruby 4.0 tornar os Ractors baratos o suficiente para serem usados a sério, e onde eles começam a fazer sentido mesmo que ninguém construa um servidor baseado em Ractor sobre eles.
Enquanto isso, se você tem pensado na concorrência do Ruby como "threads vs processos vs fibers", tente pensar nela como Pitchfork vs Puma vs Falcon. As primitivas são a linguagem. Os servidores são as escolhas que as pessoas realmente fizeram. É aí que estão as lições.
We want to work with you. Check out our Services page!

