Caçando problemas de memória em Ruby: um guia definitivo

Publicados: 2022-03-11

Tenho certeza de que existem alguns desenvolvedores Ruby sortudos por aí que nunca terão problemas com memória, mas para o resto de nós, é incrivelmente desafiador caçar onde o uso de memória está ficando fora de controle e corrigi-lo. Felizmente, se você estiver usando um Ruby moderno (2.1+), existem algumas ótimas ferramentas e técnicas disponíveis para lidar com problemas comuns. Também se pode dizer que a otimização de memória pode ser divertida e gratificante, embora eu possa estar sozinho nesse sentimento.

Caçando problemas de memória em Ruby

Se você achava que os bugs eram irritantes, espere até procurar problemas de memória.
Tweet

Tal como acontece com todas as formas de otimização, as probabilidades são de que isso adicionará complexidade ao código, portanto, não vale a pena fazer isso a menos que haja ganhos mensuráveis ​​e significativos.

Tudo descrito aqui é feito usando o MRI Ruby canônico, versão 2.2.4, embora outras versões 2.1+ devam se comportar de forma semelhante.

Não é um vazamento de memória!

Quando um problema de memória é descoberto, é fácil chegar à conclusão de que há um vazamento de memória. Por exemplo, em um aplicativo da Web, você pode ver que, depois de ativar seu servidor, chamadas repetidas para o mesmo endpoint continuam aumentando o uso de memória a cada solicitação. Certamente há casos em que vazamentos de memória legítimos acontecem, mas eu aposto que eles são amplamente superados em número por problemas de memória com essa mesma aparência que não são realmente vazamentos.

Como um exemplo (artificial), vamos ver um pouco de código Ruby que repetidamente constrói uma grande variedade de hashes e a descarta. Primeiro, aqui está algum código que será compartilhado ao longo dos exemplos deste post:

 # common.rb require "active_record" require "active_support/all" require "get_process_mem" require "sqlite3" ActiveRecord::Base.establish_connection( adapter: "sqlite3", database: "people.sqlite3" ) class Person < ActiveRecord::Base; end def print_usage(description) mb = GetProcessMem.new.mb puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }" end def print_usage_before_and_after print_usage("Before") yield print_usage("After") end def random_name (0...20).map { (97 + rand(26)).chr }.join end

E o construtor de arrays:

 # build_arrays.rb require_relative "./common" ARRAY_SIZE = 1_000_000 times = ARGV.first.to_i print_usage(0) (1..times).each do |n| foo = [] ARRAY_SIZE.times { foo << {some: "stuff"} } print_usage(n) end

A gem get_process_mem é apenas uma maneira conveniente de obter a memória que está sendo usada pelo processo Ruby atual. O que vemos é o mesmo comportamento descrito acima, um aumento contínuo no uso de memória.

 $ ruby build_arrays.rb 10 0 - MEMORY USAGE(MB): 17 1 - MEMORY USAGE(MB): 330 2 - MEMORY USAGE(MB): 481 3 - MEMORY USAGE(MB): 492 4 - MEMORY USAGE(MB): 559 5 - MEMORY USAGE(MB): 584 6 - MEMORY USAGE(MB): 588 7 - MEMORY USAGE(MB): 591 8 - MEMORY USAGE(MB): 603 9 - MEMORY USAGE(MB): 613 10 - MEMORY USAGE(MB): 621

No entanto, se executarmos mais iterações, eventualmente chegaremos ao platô.

 $ ruby build_arrays.rb 40 0 - MEMORY USAGE(MB): 9 1 - MEMORY USAGE(MB): 323 ... 32 - MEMORY USAGE(MB): 700 33 - MEMORY USAGE(MB): 699 34 - MEMORY USAGE(MB): 698 35 - MEMORY USAGE(MB): 698 36 - MEMORY USAGE(MB): 696 37 - MEMORY USAGE(MB): 696 38 - MEMORY USAGE(MB): 696 39 - MEMORY USAGE(MB): 701 40 - MEMORY USAGE(MB): 697

Atingir esse platô é a marca registrada de não ser um vazamento de memória real, ou que o vazamento de memória é tão pequeno que não é visível em comparação com o restante do uso de memória. O que pode não ser intuitivo é por que o uso de memória continua a crescer após a primeira iteração. Afinal, ela construiu uma grande matriz, mas logo a descartou e começou a construir uma nova do mesmo tamanho. Não pode simplesmente usar o espaço liberado pelo array anterior? A resposta, que explica o nosso problema, é não. Além de ajustar o coletor de lixo, você não tem controle sobre quando ele é executado, e o que estamos vendo no exemplo build_arrays.rb são novas alocações de memória sendo feitas antes da coleta de lixo de nossos objetos descartados antigos.

Não entre em pânico se perceber um aumento repentino no uso de memória do seu aplicativo. Os aplicativos podem ficar sem memória por vários motivos - não apenas vazamentos de memória.

Devo salientar que este não é algum tipo de problema horrível de gerenciamento de memória específico para Ruby, mas geralmente é aplicável a linguagens coletadas de lixo. Apenas para me assegurar disso, reproduzi essencialmente o mesmo exemplo com Go e vi resultados semelhantes. No entanto, existem bibliotecas Ruby que facilitam a criação desse tipo de problema de memória.

Dividir e conquistar

Então, se precisarmos trabalhar com grandes blocos de dados, estamos condenados a apenas jogar muita RAM em nosso problema? Felizmente, esse não é o caso. Se pegarmos o exemplo build_arrays.rb e diminuirmos o tamanho do array, veremos uma diminuição no ponto em que o uso de memória se estabiliza aproximadamente proporcional ao tamanho do array.

Isso significa que, se pudermos dividir nosso trabalho em partes menores para processar e evitar a existência de muitos objetos ao mesmo tempo, podemos reduzir drasticamente o consumo de memória. Infelizmente, isso geralmente significa pegar um código bom e limpo e transformá-lo em mais código que faz a mesma coisa, apenas de uma maneira mais eficiente em termos de memória.

Isolando hotspots de uso de memória

Em uma base de código real, a origem de um problema de memória provavelmente não será tão óbvia quanto no exemplo build_arrays.rb . Isolar um problema de memória antes de tentar realmente investigar e corrigi-lo é essencial porque é fácil fazer suposições incorretas sobre o que está causando o problema.

Geralmente, uso duas abordagens, geralmente combinadas, para rastrear problemas de memória: deixar o código intacto e envolver um criador de perfil em torno dele e monitorar o uso de memória do processo enquanto desabilito/habilita diferentes partes do código que suspeito que possam ser problemáticas. Estarei usando memory_profiler aqui para criação de perfil, mas ruby-prof é outra opção popular, e derailed_benchmarks tem alguns ótimos recursos específicos do Rails.

Aqui está um código que usará muita memória, onde pode não ser imediatamente claro qual etapa está aumentando mais o uso de memória:

 # people.rb require_relative "./common" def run(number) Person.delete_all names = number.times.map { random_name } names.each do |name| Person.create(name: name) end records = Person.all.to_a File.open("people.txt", "w") { |out| out << records.to_json } end

Usando get_process_mem, podemos verificar rapidamente se ele usa muita memória quando há muitos registros de Person sendo criados.

 # before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end

Resultado:

 $ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96

Examinando o código, há vários passos que parecem bons candidatos para usar muita memória: construir um grande array de strings, chamar #to_a em uma relação Active Record para fazer um grande array de objetos Active Record (não é uma boa idéia , mas feito para fins de demonstração) e serializando a matriz de objetos Active Record.

Podemos então criar o perfil desse código para ver onde as alocações de memória estão acontecendo:

 # profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")

Observe que o número que está sendo alimentado para ser run aqui é 1/10 do exemplo anterior, pois o próprio criador de perfil usa muita memória e pode, na verdade, levar ao esgotamento da memória ao criar perfil de código que já causa alto uso de memória.

O arquivo de resultados é bastante longo e inclui alocação e retenção de memória e objetos nos níveis de gem, arquivo e localização. Há uma riqueza de informações para explorar, mas aqui estão alguns trechos interessantes:

 allocated memory by gem ----------------------------------- 17520444 activerecord-4.2.6 7305511 activesupport-4.2.6 2551797 activemodel-4.2.6 2171660 arel-6.0.3 2002249 sqlite3-1.3.11 ... allocated memory by file ----------------------------------- 2840000 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/hash_with_indifferent_access.rb 2006169 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/type/time_value.rb 2001914 /Users/bruz/code/mem_test/people.rb 1655493 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/connection_adapters/sqlite3_adapter.rb 1628392 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/json/encoding.rb

Vemos a maioria das alocações acontecendo dentro do Active Record, o que parece apontar para instanciar todos os objetos na matriz de records ou serialização com #to_json . Em seguida, podemos testar nosso uso de memória sem o criador de perfil enquanto desabilitamos esses suspeitos. Não podemos desabilitar a recuperação de records e ainda poder fazer a etapa de serialização, então vamos tentar desabilitar a serialização primeiro.

 # File.open("people.txt", "w") { |out| out << records.to_json }

Resultado:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB

Isso realmente parece ser para onde a maior parte da memória está indo, com o delta da memória antes/depois caindo 81% ao ignorá-lo. Também podemos ver o que acontece se pararmos de forçar a criação do grande array de registros.

 # records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }

Resultado:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB

Isso também reduz o uso de memória, embora seja uma redução de ordem de magnitude menor do que desabilitar a serialização. Portanto, neste momento, conhecemos nossos maiores culpados e podemos tomar uma decisão sobre o que otimizar com base nesses dados.

Embora o exemplo aqui tenha sido inventado, as abordagens são geralmente aplicáveis. Os resultados do Profiler podem não apontar para o local exato em seu código onde está o problema e também podem ser mal interpretados, portanto, é uma boa ideia acompanhar o uso real da memória enquanto ativa e desativa seções de código. A seguir, veremos alguns casos comuns em que o uso de memória se torna um problema e como otimizá-los.

Desserialização

Uma fonte comum de problemas de memória é a desserialização de grandes quantidades de dados de XML, JSON ou algum outro formato de serialização de dados. Usar métodos como JSON.parse ou Hash.from_xml do Active Support é incrivelmente conveniente, mas quando os dados que você está carregando são grandes, a estrutura de dados resultante que é carregada na memória pode ser enorme.

Se você tiver controle sobre a origem dos dados, poderá fazer coisas para limitar a quantidade de dados que está recebendo, como adicionar filtragem ou suporte à paginação. Mas se for uma fonte externa ou que você não pode controlar, outra opção é usar um desserializador de streaming. Para XML, Ox é uma opção, e para JSON yajl-ruby parece operar de forma semelhante, embora eu não tenha muita experiência com isso.

Só porque você tem memória limitada não significa que você não pode analisar documentos XML ou JSON grandes com segurança. Os desserializadores de streaming permitem que você extraia incrementalmente o que você precisa desses documentos e ainda mantém o consumo de memória baixo.

Aqui está um exemplo de análise de um arquivo XML de 1,7 MB, usando Hash#from_xml .

 # parse_with_from_xml.rb require_relative "./common" print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) hash = Hash.from_xml(file)["mondial"]["continent"] puts hash.map { |c| c["name"] }.join(", ") end
 $ ruby parse_with_from_xml.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 164

111 MB para um arquivo de 1,7 MB! Isso claramente não vai escalar bem. Aqui está a versão do analisador de streaming.

 # parse_with_ox.rb require_relative "./common" require "ox" class Handler < ::Ox::Sax def initialize(&block) @yield_to = block end def start_element(name) case name when :continent @in_continent = true end end def end_element(name) case name when :continent @yield_to.call(@name) if @name @in_continent = false @name = nil end end def attr(name, value) case name when :name @name = value if @in_continent end end end print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) continents = [] handler = Handler.new do |continent| continents << continent end Ox.sax_parse(handler, file) puts continents.join(", ") end
 $ ruby parse_with_ox.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 37

Isso nos leva a um aumento insignificante de memória e deve ser capaz de lidar com arquivos muito maiores. No entanto, a desvantagem é que agora temos 28 linhas de código do manipulador que não precisávamos antes, o que parece ser propenso a erros e, para uso em produção, deve ter alguns testes em torno disso.

Serialização

Como vimos na seção sobre o isolamento de hotspots de uso de memória, a serialização pode ter altos custos de memória. Aqui está a parte principal do people.rb anterior.

 # to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end

Executando isso com 100.000 registros no banco de dados, obtemos:

 $ ruby to_json.rb Before: 36 MB After: 505 MB

O problema de chamar #to_json aqui é que ele instancia um objeto para cada registro e depois codifica para JSON. Gerar o JSON registro por registro para que apenas um objeto de registro precise existir por vez reduz significativamente o uso de memória. Nenhuma das bibliotecas Ruby JSON populares parece lidar com isso, mas uma abordagem comumente recomendada é construir a string JSON manualmente. Existe uma gem json-write-stream que fornece uma boa API para fazer isso, e converter nosso exemplo para isso se parece com:

 # json_stream.rb require_relative "./common" require "json-write-stream" print_usage_before_and_after do file = File.open("people.txt", "w") JsonWriteStream.from_stream(file) do |writer| writer.write_object do |obj_writer| obj_writer.write_array("people") do |arr_writer| Person.find_each do |people| arr_writer.write_element people.as_json end end end end end

Mais uma vez, vemos que a otimização nos deu mais código, mas o resultado parece valer a pena:

 $ ruby json_stream.rb Before: 36 MB After: 56 MB

Ser preguiçoso

Um ótimo recurso adicionado ao Ruby a partir do 2.0 é a capacidade de tornar os enumeradores preguiçosos. Isso é ótimo para melhorar o uso de memória ao encadear métodos em um enumerador. Vamos começar com algum código que não seja preguiçoso:

 # not_lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

Resultado:

 $ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB 

O que acontece aqui é que em cada etapa da cadeia, ele itera sobre cada elemento no enumerador, produzindo uma matriz que tem o método subsequente na cadeia invocado e assim por diante. Vamos ver o que acontece quando fazemos isso lazy, o que requer apenas adicionar uma chamada para lazy no enumerador que obtemos de times :

 # lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times.lazy .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

Resultado:

 $ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB

Finalmente, um exemplo que nos dá uma grande vitória no uso de memória, sem adicionar muito código extra! Observe que se não precisássemos acumular nenhum resultado no final, por exemplo, se cada item fosse salvo no banco de dados e pudesse ser esquecido, haveria ainda menos uso de memória. Para fazer uma avaliação enumerável preguiçosa no final da cadeia, basta adicionar uma chamada final para force .

Outra coisa a ser observada sobre o exemplo é que a cadeia começa com uma chamada para times antes de lazy , que usa muito pouca memória, pois apenas retorna um enumerador que gerará um inteiro toda vez que for invocado. Portanto, se um enumerável puder ser usado em vez de um grande array no início da cadeia, isso ajudará.

Manter tudo em grandes arrays e mapas é conveniente, mas em cenários do mundo real, você raramente precisa fazer isso.

Uma aplicação do mundo real de construir um enumerável para alimentar preguiçosamente em algum tipo de pipeline de processamento é o processamento de dados paginados. Então, em vez de solicitar todas as páginas e colocá-las em uma grande matriz, elas podem ser expostas por meio de um enumerador que oculta bem todos os detalhes da paginação. Isso pode ser algo como:

 def records Enumerator.new do |yielder| has_more = true page = 1 while has_more response = fetch(page) response.records.each { |record| yielder << record } page += 1 has_more = response.has_more end end end

Conclusão

Fizemos algumas caracterizações do uso de memória em Ruby e analisamos algumas ferramentas gerais para rastrear problemas de memória, bem como alguns casos comuns e maneiras de melhorá-los. Os casos comuns que exploramos não são abrangentes e são altamente tendenciosos pelo tipo de problemas que eu pessoalmente encontrei. No entanto, o maior ganho pode estar apenas na mentalidade de pensar em como o código afetará o uso da memória.

Relacionado: Ruby Simultaneidade e Paralelismo: Um Tutorial Prático