Em 2015, escrevi sobre as ferramentas que Ruby fornece para
detectar vazamentos de memória gerenciada . Principalmente, o artigo falava sobre vazamentos facilmente gerenciáveis. Desta vez, falarei sobre ferramentas e truques que você pode usar para eliminar vazamentos que não são tão fáceis de analisar em Ruby. Em particular, vou falar sobre mwrap, heaptrack, iseq_collector e chap.
Vazamentos de memória não gerenciada
Este pequeno programa provoca um vazamento com uma ligação direta ao malloc. Começa com um consumo de 16 MB de RSS e termina com 118 MB. O código coloca na memória 100 mil blocos de 1024 bytes e exclui 50 mil deles.
require 'fiddle' require 'objspace' def usage rss = `ps -p
Embora o RSS tenha 118 MB, nosso objeto Ruby só reconhece três megabytes. Na análise, vemos apenas uma parte muito pequena desse vazamento de memória muito grande.
Um exemplo real de tal vazamento é
descrito por Oleg Dashevsky , eu recomendo a leitura deste maravilhoso artigo.
Aplicar Mwrap
Mwrap é um criador de perfil de memória para Ruby que monitora todas as alocações de dados na memória interceptando malloc e outras funções dessa família. Ele intercepta chamadas que colocam e
liberam memória usando
LD_PRELOAD . Ele usa
liburcu para contagem e pode rastrear contadores de alocação e exclusão para cada ponto de chamada, nos códigos C e Ruby. O Mwrap é pequeno, com o dobro do RSS de um programa com perfil e com o dobro da velocidade.
Difere de muitas outras bibliotecas em seu tamanho muito pequeno e em suporte a Ruby. Ele rastreia locais em arquivos Ruby e não se limita a backgracks de nível C valgrind + masif e perfis semelhantes. Isso simplifica bastante o isolamento das fontes de problemas.
Para usar o criador de perfil, você precisa executar o aplicativo através do shell Mwrap, ele implementará o ambiente LD_PRELOAD e executará o binário Ruby.
Vamos adicionar o Mwrap ao nosso script:
require 'mwrap' def report_leaks results = [] Mwrap.each do |location, total, allocations, frees, age_total, max_lifespan| results << [location, ((total / allocations.to_f) * (allocations - frees)), allocations, frees] end results.sort! do |(_, growth_a), (_, growth_b)| growth_b <=> growth_a end results[0..20].each do |location, growth, allocations, frees| next if growth == 0 puts "#{location} growth: #{growth.to_i} allocs/frees (#{allocations}/#{frees})" end end GC.start Mwrap.clear leak_memory GC.start
Agora execute o script com o wrapper Mwrap:
% gem install mwrap % mwrap ruby leak.rb leak.rb:12 growth: 51200000 allocs/frees (100000/50000) leak.rb:51 growth: 4008 allocs/frees (1/0)
Mwrap detectou corretamente um vazamento no script (50.000 * 1024). E não apenas determinado, mas também isolado uma linha específica (
i = Fiddle.malloc(1024)
), que levou a um vazamento. O criador de perfil vinculou-o corretamente às chamadas para
Fiddle.free
.
É importante notar que estamos lidando com uma avaliação. Mwrap monitora a memória compartilhada alocada pelo ponto de discagem e, em seguida, monitora a liberação de memória. Mas se você tiver um ponto de chamada que aloque blocos de memória de tamanhos diferentes, o resultado será impreciso. Temos acesso à avaliação:
((total / allocations) * (allocations - frees))
Além disso, para simplificar o rastreamento de vazamentos, o Mwrap rastreia
age_total
, que é a soma da vida útil de cada item liberado, e também
max_lifespan
, a vida útil do item mais antigo no ponto de chamada. Se
age_total / frees
grande, o consumo de memória estará aumentando, apesar das inúmeras coleções de lixo.
Mwrap tem vários ajudantes para reduzir o ruído.
Mwrap.clear
limpará todo o armazenamento interno.
Mwrap.quiet {}
forçará o Mwrap a rastrear o bloco de código.
Outro recurso distintivo do Mwrap é o rastreamento do número total de bytes alocados e liberados. Remova
clear
do script e execute-o:
usage puts "Tracked size: #{(Mwrap.total_bytes_allocated - Mwrap.total_bytes_freed) / 1024}"
O resultado é muito interessante, porque, apesar do tamanho RSS de 130 MB, o Mwrap vê apenas 91 MB. Isso sugere que inflamos nosso processo. A execução sem o Mwrap mostra que, em uma situação normal, o processo leva 118 MB e, neste caso simples, a diferença foi de 12 MB. O padrão de alocação / liberação levou à fragmentação. Esse conhecimento pode ser muito útil, em alguns casos, os processos glibc malloc não configurados fragmentam tanto que a quantidade muito grande de memória usada no RSS é realmente gratuita.
Mwrap pode isolar um velho vazamento de tapete vermelho?
Em
seu artigo, Oleg discute uma maneira muito completa de isolar um vazamento muito fino no tapete vermelho. Existem muitos detalhes. É muito importante fazer medições. Se você não está construindo uma linha do tempo para o processo RSS, é improvável que consiga se livrar de qualquer vazamento.
Vamos entrar em uma máquina do tempo e demonstrar como é mais fácil usar o Mwrap para esses vazamentos.
def red_carpet_leak 100_000.times do markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, extensions = {}) markdown.render("hi") end end GC.start Mwrap.clear red_carpet_leak GC.start
Redcarpet 3.3.2:
redcarpet.rb:51 growth: 22724224 allocs/frees (500048/400028) redcarpet.rb:62 growth: 4008 allocs/frees (1/0) redcarpet.rb:52 growth: 634 allocs/frees (600007/600000)
Redcarpet 3.5.0:
redcarpet.rb:51 growth: 4433 allocs/frees (600045/600022) redcarpet.rb:52 growth: 453 allocs/frees (600005/600000)
Se você puder executar o processo na metade da velocidade, basta reiniciá-lo no produto Mwrap com o registro do resultado em um arquivo, e poderá identificar uma ampla variedade de vazamentos de memória.
Vazamento misterioso
Recentemente, o Rails foi atualizado para a versão 6. Em geral, a experiência foi muito positiva, o desempenho permaneceu aproximadamente o mesmo. O Rails 6 possui alguns recursos muito bons que usaremos (por exemplo,
Zeitwerk ). O Rails mudou a maneira como os modelos foram renderizados, o que exigiu algumas alterações para compatibilidade. Alguns dias após a atualização, notamos um aumento no RSS do executante de tarefas do Sidekiq.
Mwrap relatou um aumento acentuado no consumo de memória devido à sua alocação (
link ):
source.encode!
No começo, ficamos muito intrigados. Estávamos tentando entender por que insatisfeitos com Mwrap? Talvez ele tenha quebrado? À medida que o consumo de memória aumentava, as pilhas de Ruby permaneciam inalteradas.

Dois milhões de slots no heap consumiram apenas 78 MB (40 bytes por slot). Linhas e matrizes podem ocupar mais espaço, mas ainda não explicou o consumo anormal de memória que observamos. Isso foi confirmado quando eu
rbtrace -p SIDEKIQ_PID -e ObjectSpace.memsize_of_all
.
Para onde foi a memória?
Heaptrack
Heaptrack é um perfilador de memória de pilha para Linux.
Milian Wolff
explicou perfeitamente como o criador de perfil funciona e falou sobre isso em vários discursos (
1 ,
2 ,
3 ). De fato, é um criador de perfil de heap nativo muito eficiente que, com a ajuda da
libunwind, coleta backtraces de aplicativos com perfil. Ele funciona visivelmente mais rápido que o
Valgrind / Massif e tem a capacidade de torná-lo muito mais conveniente para perfis temporários no produto. Pode ser anexado a um processo já em execução!
Como na maioria dos criadores de perfil de heap, ao chamar todas as funções da família malloc, o Heaptrack deve contar. Esse procedimento definitivamente atrasa um pouco o processo.
Na minha opinião, a arquitetura aqui é a melhor de todas as possíveis. A interceptação é realizada usando
LD_PRELOAD
ou
GDB para carregar o criador de perfil. Usando um
arquivo FIFO especial, ele transfere os dados do processo de criação de perfil o mais rápido possível. O wrapper
heaptrack é um script de shell simples que facilita a localização de um problema. O segundo processo lê as informações do FIFO e compacta os dados de rastreamento. Como o Heaptrack opera com "pedaços", você pode analisar o perfil alguns segundos após o início da criação de perfil, bem no meio da sessão. Basta copiar o arquivo de perfil para outro local e iniciar a GUI do Heaptrack.
Este
ticket do GitLab me contou sobre a possibilidade de lançar o Heaptrack. Se eles pudessem executá-lo, então eu posso.
Nosso aplicativo é executado em um contêiner, e eu preciso reiniciá-lo com
--cap-add=SYS_PTRACE
, isso permite que o GDB use o
ptrace , necessário para o Heaptrack se injetar. Também preciso de um
pequeno hack para o arquivo shell aplicar
root
ao perfil do processo não
root
(lançamos nosso aplicativo Discourse no contêiner com uma conta limitada).
Após tudo ter sido feito, resta apenas executar o
heaptrack -p PID
e aguardar os resultados aparecerem. O Heaptrack acabou sendo uma excelente ferramenta, era muito fácil rastrear tudo o que acontece com vazamentos de memória.

No gráfico, você vê dois saltos, um devido ao
cppjieba
e outro devido ao
objspace_xmalloc0
no Ruby.
Eu sabia sobre o
cppjieba . Segmentar o idioma chinês é caro, você precisa de dicionários grandes, portanto isso não é um vazamento. Mas e quanto à alocação de memória no Ruby, que ainda não me diz isso?

O ganho principal está relacionado ao
iseq_set_sequence
no
compile.c
. Acontece que o vazamento é devido a seqüências de instruções. Isso esclareceu o vazamento descoberto por Mwrap. Sua causa foi
mod.module_eval(source, identifier, 0)
, que criou sequências de instruções que não foram excluídas da memória.
Se, em uma análise retrospectiva, eu considerasse cuidadosamente um despejo de pilha do Ruby, eu teria notado todos esses IMEMOs, pois eles estão incluídos nesse despejo. Eles são simplesmente invisíveis durante o diagnóstico em processo.
A partir deste momento, a depuração foi bem simples. Rastreei todas as chamadas para o módulo eval e joguei fora o que ele avaliou. Descobri que estamos adicionando métodos a uma classe grande repetidamente. Aqui está uma visão simplificada do bug que encontramos:
require 'securerandom' module BigModule; end def leak_methods 10_000.times do method = "def _#{SecureRandom.hex}; #{"sleep;" * 100}; end" BigModule.module_eval(method) end end usage # RSS: 16164 ObjectSpace size 2869 leak_methods usage
Ruby tem uma classe para armazenar sequências de instruções
RubyVM::InstructionSequence
:
RubyVM::InstructionSequence
. No entanto, Ruby é muito preguiçoso para criar esses objetos de invólucro, porque armazená-los desnecessariamente é ineficiente. Koichi
Sasada criou a dependência
iseq_collector . Se adicionarmos esse código, podemos encontrar nossa memória oculta:
require 'iseq_collector' puts "#{ObjectSpace.memsize_of_all_iseq / 1024}"
materializa cada sequência de instruções, o que pode aumentar um pouco o consumo de memória do processo e dar um pouco mais de trabalho ao coletor de lixo.
Se, por exemplo, calcularmos o número de ISEQs antes e depois de iniciar o coletor, veremos que, após iniciar
ObjectSpace.memsize_of_all_iseq
nosso contador da classe
RubyVM::InstructionSequence
aumentará de 0 para 11128 (neste exemplo):
def count_iseqs ObjectSpace.each_object(RubyVM::InstructionSequence).count end
Esses invólucros permanecerão por toda a vida útil do método; eles precisarão ser visitados com uma execução completa do coletor de lixo. Nosso problema foi resolvido reutilizando a classe responsável pela renderização dos modelos de email (
hotfix 1 ,
hotfix 2 ).
chap
Durante a depuração, usei uma ferramenta muito interessante. Há alguns anos, Tim Boddy retirou uma ferramenta interna usada pelo VMWare para analisar vazamentos de memória e abriu seu código. Aqui está o único vídeo sobre isso que eu consegui encontrar:
https://www.youtube.com/watch?v=EZ2n3kGtVDk . Diferentemente da maioria das ferramentas similares, essa não tem efeito no processo executável. Ele pode ser simplesmente aplicado aos arquivos do dump principal, enquanto o glibc é usado como alocador (não há suporte para jemalloc / tcmalloc, etc.).
Com o camarada, é muito fácil detectar o vazamento que eu tive. Poucas distribuições têm um binário chap, mas você pode facilmente
compilá-lo a partir do código fonte . Ele é apoiado ativamente.
# 444098 is the `Process.pid` of the leaking process I had sudo gcore -p 444098 chap core.444098 chap> summarize leaked Unsigned allocations have 49974 instances taking 0x312f1b0(51,573,168) bytes. Unsigned allocations of size 0x408 have 49974 instances taking 0x312f1b0(51,573,168) bytes. 49974 allocations use 0x312f1b0 (51,573,168) bytes. chap> list leaked ... Used allocation at 562ca267cdb0 of size 408 Used allocation at 562ca267d1c0 of size 408 Used allocation at 562ca267d5d0 of size 408 ... chap> summarize anchored .... Signature 7fbe5caa0500 has 1 instances taking 0xc8(200) bytes. 23916 allocations use 0x2ad7500 (44,922,112) bytes.
O Chap pode usar assinaturas para procurar locais de memória diferente e complementar o GDB. Ao depurar no Ruby, pode ser de grande ajuda para determinar qual memória o processo está usando. Ele mostra a memória total usada, às vezes o glibc malloc pode fragmentar tanto que o volume usado pode ser muito diferente do RSS real. Você pode ler a discussão:
Recurso 14759: [PATCH] defina M_ARENA_MAX para glibc malloc - Ruby master - Sistema de rastreamento de problemas de Ruby . O Chap é capaz de contar corretamente toda a memória usada e fornecer uma análise aprofundada de sua alocação.
Além disso, o chap pode ser integrado aos fluxos de trabalho para detectar automaticamente vazamentos e sinalizar esses conjuntos.
Acompanhamento do trabalho
Esta rodada de depuração me fez levantar algumas questões relacionadas aos nossos kits de ferramentas auxiliares:
Sumário
O kit de ferramentas de hoje para depurar vazamentos de memória muito complexos é muito melhor do que era há 4 anos! Mwrap, Heaptrack e chap são ferramentas muito poderosas para resolver problemas de memória que surgem durante o desenvolvimento e operação.
Se você está procurando um simples vazamento de memória no Ruby, recomendo a leitura do
meu artigo de 2015 , na maioria das vezes é relevante.
Espero que você ache mais fácil da próxima vez que começar a depurar um vazamento de memória nativa complexo.