Memória, GC e o Modelo de Inchaço vs. Vazamento
Objective
O modelo de memória do Ruby tem uma propriedade que explica quase toda pergunta de "por que meu processo está usando tanta RAM": o heap do MRI nunca encolhe de volta depois de um pico. Um processo que precisa brevemente de 350MB para renderizar uma página grande permanece aproximadamente nesse tamanho depois, mesmo que quase nada dessa memória ainda esteja em uso. Diferenciar isso de um vazamento de memória genuíno (e saber o que de fato está disponível para corrigir cada um) é a habilidade central que este conceito cobre.
Use Cases
- Decidir se um processo que cresceu de 100MB para 350MB depois de uma requisição grande é um problema (vazamento) ou comportamento esperado (inchaço) antes de chamar alguém sobre isso.
- Diagnosticar uma subida lenta e constante de RSS que nunca reinicia: é um
vazamento de objeto Ruby, ou um vazamento de extensão C/VM invisível para
GC.stat? - Decidir se um processo Ruby multithreaded e pesado em I/O (Puma, Sidekiq) se beneficiaria de jemalloc, e por que essa combinação específica importa.
- Saber quando (raramente) vale de fato a pena ajustar as variáveis de
ambiente
RUBY_GC_HEAP_*em vez de deixar os padrões do GC em paz.
Deep Dive
Inchaço vs. vazamento: o modelo de diagnóstico
plaintext# A single big allocation permanently grows the process, even after GC: arr = Array.new(1_000_000) { "string" } arr = nil GC.start # RSS recovers only a small fraction of what it grew by
O heap do Ruby é feito de páginas de tamanho fixo; uma página com até um
único objeto vivo nela ("eden") não pode ser devolvida ao sistema
operacional, só uma página completamente vazia ("tomb") pode. Uma grande
alocação transitória deixa para trás muitas páginas eden meio vazias; o
processo parece permanentemente maior mesmo que GC.start tenha de fato
coletado o lixo. Isso é inchaço: rápido, grande, único, e ele se
estabiliza dentro de algumas horas de tráfego normal.
Um vazamento parece diferente: crescimento lento e linear que nunca se estabiliza, mesmo depois de 24+ horas com reinícios de worker desabilitados. A divisão diagnóstica:
plaintext# Watched over time in a long-running process: GC.stat[:heap_live_slots]
heap_live_slotscrescendo sem limite: um vazamento real de objeto Ruby (algo mantém uma referência viva que não deveria, um cache crescente sem remoção, um array que recebe itens mas nunca é limpo).heap_live_slotsfica estável mas o RSS continua subindo: o vazamento está em uma extensão C ou na própria VM, completamente invisível para as estatísticas de GC em nível Ruby. Isso é mais raro, mas muito mais difícil de rastrear; costuma exigir o próprio profiler de vazamento dojemalloc(MALLOC_CONF=prof_leak:true,...) ou um dump de heap completo, e pode levar tempo real de investigação.
Fragmentação por arena de thread: o caso avançado
plaintextMALLOC_ARENA_MAX=2 ruby app.rb
O malloc do glibc (o alocador padrão do Ruby no Linux) dá a cada thread sua
própria arena de memória para reduzir disputa de lock, normalmente até 8 × número de núcleos. Arenas não conseguem compartilhar memória livre entre si,
o que fragmenta gravemente em uma combinação específica: um processo que é
tanto multithreaded quanto pesado em I/O (Puma, Sidekiq), porque a GVL
só permite disputa real entre threads enquanto uma delas está bloqueada em
I/O, que é exatamente quando essa fragmentação aparece. Números reportados
no mundo real: RSS de 2 a 4 vezes maior nessa configuração específica,
corrigível com MALLOC_ARENA_MAX=2 (redução de memória de ~40%, custo de
CPU de ~13%) ou, de forma mais limpa, trocando o alocador inteiramente.
jemalloc como uma correção estrutural
plaintext# Linux, no Ruby recompile needed: LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ruby app.rb
O design de arenas do jemalloc evita o problema de fragmentação do glibc por
construção, não só limitando-o. É uma mudança quase sem risco para testar:
no pior caso é neutro; no melhor caso (especificamente Puma/Sidekiq) é um
ganho de memória significativo e estrutural, sem o trade-off de ~13% de CPU
que o MALLOC_ARENA_MAX carrega.
Trade-offs
- Ajustar as variáveis de ambiente
RUBY_GC_HEAP_*raramente vale a pena: a maioria das aplicações gasta menos de 1% do tempo de CPU em GC. Só compensa em dois casos estreitos: picos momentâneos de pressão de memória deixando centenas de milhares de slots permanentemente livres (diminua as taxas de crescimento, fixeINIT_SLOTSno estado estável observado), ou uma aplicação provadamente limitada por CPU no GC (faça o oposto: troque memória por coletas menos frequentes). Sempre confirme comGC.statantes de mexer em qualquer uma dessas; adivinhar desperdiça o esforço de ajuste. MALLOC_ARENA_MAX=1parece a correção mais agressiva possível, mas não vale a pena: compra só de 1 a 2% a mais de economia de memória sobre=2, por um custo de CPU desproporcional.2a4é a faixa prática.- jemalloc/tcmalloc mostram um ganho real de ~15% em benchmarks sintéticos de estresse de GC, mas um ganho pequeno e inconsistente sob tráfego real de requisições: trate como um experimento seguro e barato que vale a pena rodar em qualquer aplicação de produção multithreaded, não como uma porcentagem fixa garantida.