DEV Community

Cover image for Ruby, Rails, Multi-threading e Puma: Como tudo isso se conecta?
Erick Takeshi
Erick Takeshi

Posted on

Ruby, Rails, Multi-threading e Puma: Como tudo isso se conecta?

No desenvolvimento de aplicações Rails modernas, entender o funcionamento interno do Ruby, threads e servidores web tornou-se cada vez mais crucial. Seja você está construindo uma API de alto desempenho, processando jobs em background, ou otimizando a performance de uma aplicação existente, o conhecimento profundo desses conceitos pode fazer a diferença entre uma aplicação que escala bem e uma que falha sob carga.

Nos meus últimos 9 anos de carreira, raramente precisei mergulhar tão fundo nesses conceitos. No entanto, em meu trabalho atual, desenvolvemos soluções que demandam um entendimento mais profundo de como o Rails e o Puma funcionam. Este artigo é um compilado desses aprendizados, visando ajudar outros desenvolvedores que precisam otimizar suas aplicações Rails para melhor concorrência e performance.

TL;DR: Este artigo explora como o Ruby gerencia threads, como garantir thread safety em suas aplicações, e como configurar o Puma para máxima performance em diferentes cenários.

Disclaimer: O foco do post é sobre o interpretador MRI do Ruby (CRuby), que é o mais comumente utilizado em ambientes de produção.

Como o Ruby trabalha com threads?

Meu objetivo aqui não é ensinar a sintaxe para criar threads, sincroniza-las ou utilizar mutexes. Em vez disso, quero fornecer uma visão geral sobre como as threads funcionam no Ruby e como tirar o maior proveito delas.

Para entender threads no Ruby, o primeiro conceito essencial é o GVL (Global VM Lock) ou GIL (Global Interpreter Lock).

Global Vm Lock ou Global Interpreter Lock

┌────────────────────────────────────┐
│            Processo Ruby           │
│                                    │
│    ┌──────────┐     ┌──────────┐   │
│    │ Thread 1 │     │ Thread 2 │   │
│    └──────────┘     └──────────┘   │
│           │             │          │
│           └─────┬───────┘          │
│                 ▼                  │
│         ┌──────────────┐           │
│         │     GVL      │           │
│         └──────────────┘           │
│                 │                  │
│         ┌──────────────┐           │
│         │ Interpretador│           │
│         └──────────────┘           │
└────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

O GVL é um mecanismo presente nas versões MRI do Ruby que impede que duas threads rodem código Ruby ao mesmo tempo. Isso significa que não há paralelismo real quando se trata de execução de código Ruby puro. No entanto, o processo Ruby pode executar qualquer outra operação que não envolva código Ruby em paralelo, como operações de I/O.

Qual a vantagem de se utilizar threads?

Como descrito no parágrafo anterior, é possível executar I/O (leituras do disco, requests HTTP, acesso ao banco de dados) em paralelo a código Ruby, então, quando dividimos o programa em várias threads, enquanto uma thread espera o resultado de alguma chamada I/O, outra thread pode ir executando código em paralelo, oferecendo assim a possibilidade de tirarmos maior vantagem de processadores multi-cores.

Quando analisamos a distribuição de recursos em uma aplicação web (Olá Rails), podemos perceber que a maior parte do processamento é gasto com operações I/O, principalmente escrever / ler algo de algum banco de dados, então faz sentido que sua aplicação esteja preparada pra trabalhar em um contexto multi-threaded, e por sorte, neste contexto, o GIL não será um problema e aplicações Ruby podem se tornar muito eficientes!

Thread safety

A origem de todo mal quando se trata de ambientes multi-threaded é o estado compartilhado, ou seja, quando diferentes threads acessam a mesma região de memória simultaneamente. No contexto do Ruby e do sistema operacional, as threads compartilham o mesmo espaço de memória, o que significa que variáveis podem ser acessadas simultaneamente, levando a comportamentos imprevisíveis e difíceis de depurar—o que chamamos de problemas de concorrência.

Ilustrando problemas de concorrência

Embora problemas de concorrência sejam amplamente conhecidos e discutidos, muitas vezes são difíceis de visualizar sem um exemplo concreto.
PROBLEMAS DE CONCORRÊNCIA SÃO INERENTEMENTE DIFÍCEIS!

Imagine que temos a seguinte classe:

class S3Uploader
    def initialize(files)
        @files = files
    end

    def call
        threads = []

        @files.each do |file|
            threads << Thread.new {
                result = send_to_s3(file.name, file.blob)
                upload_results << result
            }
        end

        threads.each(&:join)
    end

    def upload_results
        @results ||= Queue.new
    end

    def send_to_s3(filename, blob)
        # IO
    end
end
Enter fullscreen mode Exit fullscreen mode

A lógica parece correta à primeira vista:

  1. Criamos uma thread para cada operação de I/O, aproveitando melhor o tempo de CPU.
  2. Os resultados do upload são armazenados na fila @results.

Porém, ao executar o código, percebemos que ele não é determinístico! Ou seja, rodando o mesmo código várias vezes, obtemos resultados diferentes. Em um cenário com dois arquivos, às vezes @results contém dois elementos (como esperado), mas em outras execuções contém apenas um.

Pior: esse único resultado pode pertencer a qualquer um dos arquivos!

E de onde vem esse problema?

O problema surge por conta do operador ||= , que em ruby é um check and assign, mas pra entendermos de fato precisamos nos aprofundar aqui.

Estamos acostumados a olhar pra execução de código de maneira procedural, ou seja, cada linha é executada de cima pra baixo, uma por vez, então temos uma certa linearidade na execução do programa. O problema surge quando criamos e utilizamos outras threads, nesse caso, múltiplas linhas do nosso programa podem ser executadas ao mesmo tempo (ou não) e em ordens totalmente aleatórias!
Vamos a um exemplo prático de execução deste programa:

  1. foi chamado o método call da classe
  2. é criado e inicializado o array vazio threads
  3. Iteração sobre a variável de instancia @files
  4. em cada iteração é criada uma thread nova para cada file, dando um append no array threads
    1. thread faz o upload para o S3 (chamada http, I/O)
    2. da um append na queue @results com o retorno da chamada http
  5. thread principal aguarda a finalização das outras threads instanciadas

Num contexto multi-threaded, os passos 4a e 4b podem ser executados tanto em paralelo (ao mesmo tempo) quanto em ordens totalmente aleatórias, isso é um comportamento inerente de como o sistema operacional distribui os recursos computacionais da sua máquina, caso queira saber mais sobre o assunto procure sobre task / thread scheduler.

Como não temos controle sobre essa distribuição, diversos cenários podem ser problemáticos nesse caso, gerando as famosas race conditions.

Execução passo a passo de uma race condition

Vamos a um exemplo peculiar onde uma race condition acontece e faz com que o valor da variável @results seja computada de maneira errada (OBS: lembrando que o valor esperado seria o @results ter 2 valores de respostas das chamadas ao S3):

  1. thread A é spawnada e começa a chamada http (I/O)
  2. GIL é liberado
  3. thread B é spawnada e começa a chamada http (I/O)
  4. thread B finaliza a chamada http e faz a verificação da variável @results (ao chamar o método upload_results
  5. thread B enxergar a variável como nil e instancia uma nova queue
  6. thread B para de executar por conta do thread scheduler
  7. thread A volta a executar
  8. thread A completa o request
  9. thread A enxergar a variável como nil e instancia uma nova queue (por conta do operador ||=
  10. thread A salva o resultado do request na variável @results
  11. thread B volta a executar e salva na variável results uma nova instância de Queue
  12. thread B salva o resultado do request na variável @results

Percebem o problema que surgiu no passo 11? A thread B, voltou a sua execução em um momento totalmente inoportuno, após fazer a checagem e antes de ter sido feito o assignment, dando chance para que a thread A fizesse o assignment novamente!

Todo o problema surge por conta do operador ||= , ele não é um operador atômico, na prática significa que ele não faz sua tarefa em uma única operação, no caso específico deste operador, é como se ele tivesse a seguinte implementação*:*

def ||=(variable, value)
    if variable.nil?
        variable = value
    else
        variable
    end
end
Enter fullscreen mode Exit fullscreen mode

Percebam que existem duas etapas nessa operação:

  1. checar se a variável está nil
  2. fazer o assignment ou devolver o valor

No contexto de múltiplas threads, essas etapas podem ser interrompidas no meio, resultando em um comportamento imprevisível, portante é recomendado evitá-lo num contexto multi-threaded.

Como garantir que meu código é thread safe?

Diante do problema apresentado, como garantir que nosso código não sofra com race conditions ou inconsistências? Basicamente, temos duas opções:

  1. Evitar estado compartilhado: Essa é a abordagem mais simples, pois elimina completamente os riscos de concorrência. No entanto, em aplicações reais, é praticamente impossível evitar estado compartilhado em 100% dos casos.
  2. Controlar o acesso ao estado compartilhado: Aqui entra o conceito de thread safety, que consiste em permitir acesso concorrente ao estado da aplicação sem comprometer sua integridade.

A opção 1 é a mais simples, porém, qualquer aplicação real que faça algo útil, vai depender de estado, em 99,9% dos casos. A opção 2 é onde o thread safety entra.

Thread safety é a garantia de que um código pode ser executado por múltiplas threads simultaneamente sem corromper dados, introduzir comportamentos imprevisíveis ou gerar condições de corrida. Para isso, existem diversas técnicas, como locks, mutexes, semáforos, filas concorrentes e outras estruturas de sincronização.

Este artigo não pretende cobrir todas essas técnicas, mas aqui estão algumas diretrizes gerais:

  • Verifique se as bibliotecas que você usa são thread-safe
    • no caso de gems, provavelmente no README vai ter algum lugar alegando que a gem é thread safe, caso contrário, busque nas issues do repositório do projeto
  • Use estruturas de dados thread-safe
    • existe uma gem chamada concurrent ruby (que por sinal já vem como dependência default do Rails), ela implementa diversos padrões e estruturas de dados thread safe, é sempre o primeiro lugar que eu olho quando preciso de algo, 99% dos casos já tem algo pronto ali que possa ser utilizado
  • Evite estado mutável e global sempre que possível
    • evite, quando possível, estado mutável e global, estou falando aqui de constantes, métodos e variáveis de classe, variáveis globais e a AST. O lugar de estado global compartilhado, normalmente, é algum banco de dados, como Postgres, Redis, etc.
  • Cuidado com metaprogramação e multi-threading
    • A AST é um caso excepcional, mas como Ruby é uma linguagem extremamente dinâmica (cheia de meta programming), imagine o risco de diferentes threads rodando com versões diferentes do código ao mesmo tempo! Se precisar usar metaprogramação, entenda os impactos na concorrência.

Rails, Puma e Multi-threading

Aqui é a parte que vamos conectar a teoria das seções anteriores com a prática.

O Puma é o servidor default do Rails (faz um tempinho já). O Puma é um servidor multi-threaded. Existe no próprio repositório do Puma um arquivo detalhando sua arquitetura e seu funcionamento, vale muito a leitura para qualquer dev Rails.

O Puma possui basicamente dois modos de trabalho, o cluster e o single. Em termos de multi-threading não faz muita diferença, pois em ambos os modos o Puma irá trabalhar com threads, porém no cluster mode, ele irá fazer um fork e criar outros processos, que servirão requests em paralelo também.

Como o Puma gerencia requests?

Agora chegamos na parte principal, como o Puma trabalha com threads.

De forma simplificada:

  1. Existe um thread pool (conjunto de threads previamente alocadas).
  2. Cada request entra em uma fila (todo set).
  3. À medida que threads ficam disponíveis, elas processam os requests na ordem de chegada.

O mais importante aqui é entender que cada request roda em uma thread separada. Isso significa que enquanto um request está bloqueado esperando uma operação de I/O (como uma consulta no banco), outras threads continuam processando outros requests.

O que isso significa para o Rails?

O Rails foi projetado para ser thread-safe. Todo o código dos controllers é instanciado e escopado por request, o que garante que o próprio Rails não introduz condições de corrida.

No entanto, essa segurança pode ser comprometida quando adicionamos nossas próprias abstrações dentro do controller. Para evitar problemas:

  • Sempre prefira instanciar objetos dentro do escopo do request, em vez de depender de métodos de classe ou variáveis globais.
  • Se precisar compartilhar estado entre requests, use um armazenamento externo como Redis ou um banco de dados relacional.

Se fugir dessas práticas for necessário, tenha certeza de que está garantindo sincronização adequada para evitar concorrência insegura.

O poder da concorrência e paralelismo

Agora que entendemos como o Ruby, Rails e o Puma funcionam, podemos enxergar melhor os benefícios do modelo concorrente.

Na maioria das aplicações web, o maior gargalo é I/O—seja acessando um banco de dados, chamando APIs externas ou lendo arquivos. Como o Puma é multi-threaded, ele permite que outras threads processem novos requests enquanto uma thread aguarda uma operação de I/O ser concluída.

Esse modelo é altamente eficiente, pois maximiza a utilização dos recursos computacionais, evitando que a aplicação fique ociosa.

O ponto de virada: otimizando o número de threads

Nem tudo são flores. Aumentar indiscriminadamente o número de threads não melhora indefinidamente o desempenho.

Cada aplicação tem um ponto ótimo de configuração, e aumentar demais o número de threads pode, na verdade, reduzir o throughput e aumentar a latência. Isso acontece porque:

  • Mais threads significa mais concorrência por CPU, memória e acesso a recursos.
  • Em cenários extremos, pode ocorrer starvation, onde algumas threads ficam bloqueadas por longos períodos esperando sua vez.

A configuração RAILS_MAX_THREADS no Puma define quantas threads podem ser usadas por processo. Para encontrar o melhor valor:

  • Teste diferentes configurações e use benchmarking para medir o impacto.
  • Lembre-se: o número ótimo depende da carga da sua aplicação e dos recursos do servidor, então simule em ambiente mais próximo possível a produção.

Existem diversos artigos detalhando sobre benchmarking de diferentes combinações de números de processos e threads no Puma, porém, lembre-se que isso é específico de cada aplicação, teste e descubra o número ótimo para o seu caso!

Concluindo

  • O Rails é thread-safe por padrão, mas seu código precisa seguir boas práticas para manter essa segurança.
  • O Puma permite concorrência eficiente, maximizando o uso dos recursos disponíveis.
  • Evitar estado compartilhado na memória da aplicação entre requests elimina grande parte dos riscos de concorrência.
  • Ajustar corretamente o número de threads é essencial para garantir um bom desempenho sem sobrecarregar o servidor.

Entender esses conceitos não apenas evita bugs difíceis de depurar, mas também permite extrair o máximo de desempenho da aplicação, garantindo escalabilidade e eficiência.

Top comments (2)

Collapse
 
lucas_arthur_94 profile image
Lucas Arthur Felgueiras

Artigo completíssimo, mandou bem demais, parabéns!!

Collapse
 
razielrodrigues profile image
Raziel Rodrigues

Massa demais meu parceiro!