Depurando vazamentos de memória oculta no Ruby


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 #{Process.pid} -o rss -h`.strip.to_i * 1024 puts "RSS: #{rss / 1024} ObjectSpace size #{ObjectSpace.memsize_of_all / 1024}" end def leak_memory pointers = [] 100_000.times do i = Fiddle.malloc(1024) pointers << i end 50_000.times do Fiddle.free(pointers.pop) end end usage # RSS: 16044 ObjectSpace size 2817 leak_memory usage # RSS: 118296 ObjectSpace size 3374 

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 # Don't track allocations for this block Mwrap.quiet do report_leaks end 

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}" # RSS: 130804 ObjectSpace size 3032 # Tracked size: 91691 

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 # Don't track allocations for this block Mwrap.quiet do report_leaks end 

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! # Now, validate that the source we got back from the template # handler is valid in the default_internal. This is for handlers # that handle encoding but screw up unless source.valid_encoding? raise WrongEncodingError.new(source, Encoding.default_internal) end begin mod.module_eval(source, identifier, 0) rescue SyntaxError # Account for when code in the template is not syntactically valid; eg if we're using # ERB and the user writes <%= foo( %>, attempting to call a helper `foo` and interpolate # the result into the template, but missing an end parenthesis. raise SyntaxErrorInTemplate.new(self, original_source) end end def handle_render_error(view, e) if e.is_a?(Template::Error) 

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 # RSS: 123096 ObjectSpace size 5583 

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}" # 98747 ObjectSpace.memsize_of_all_iseq 

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.

Source: https://habr.com/ru/post/pt473408/


All Articles