Buscando problemas de memoria en Ruby: una guía definitiva

Publicado: 2022-03-11

Estoy seguro de que hay algunos desarrolladores de Ruby afortunados que nunca tendrán problemas con la memoria, pero para el resto de nosotros, es increíblemente desafiante encontrar dónde el uso de la memoria se está saliendo de control y arreglarlo. Afortunadamente, si está utilizando un Ruby moderno (2.1+), existen excelentes herramientas y técnicas disponibles para tratar problemas comunes. También se podría decir que la optimización de la memoria puede ser divertida y gratificante, aunque puede que yo sea el único que tenga ese sentimiento.

Buscando problemas de memoria en Ruby

Si pensaba que los errores eran molestos, espere hasta buscar problemas de memoria.
Pío

Al igual que con todas las formas de optimización, lo más probable es que agregue complejidad al código, por lo que no vale la pena hacerlo a menos que haya ganancias medibles y significativas.

Todo lo descrito aquí se hace usando el MRI canónico Ruby, versión 2.2.4, aunque otras versiones 2.1+ deberían comportarse de manera similar.

¡No es una fuga de memoria!

Cuando se descubre un problema de memoria, es fácil llegar a la conclusión de que hay una pérdida de memoria. Por ejemplo, en una aplicación web, puede ver que después de activar su servidor, las llamadas repetidas al mismo punto final siguen aumentando el uso de la memoria con cada solicitud. Ciertamente, hay casos en los que ocurren fugas de memoria legítimas, pero apuesto a que son superados en número por problemas de memoria con la misma apariencia que en realidad no son fugas.

Como ejemplo (ideal), veamos un poco de código Ruby que genera repetidamente una gran variedad de hashes y los descarta. Primero, aquí hay un código que se compartirá a lo largo de los ejemplos en esta publicación:

 # 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

Y el generador de matrices:

 # 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

La gema get_process_mem es solo una forma conveniente de hacer que el proceso actual de Ruby utilice la memoria. Lo que vemos es el mismo comportamiento que se describió anteriormente, un aumento continuo en el uso de la memoria.

 $ 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

Sin embargo, si ejecutamos más iteraciones, eventualmente nos estancaremos.

 $ 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

Llegar a esta meseta es el sello distintivo de que no se trata de una fuga de memoria real, o de que la fuga de memoria es tan pequeña que no es visible en comparación con el resto del uso de la memoria. Lo que puede no ser intuitivo es por qué el uso de la memoria continúa creciendo después de la primera iteración. Después de todo, construyó una gran matriz, pero luego la descartó rápidamente y comenzó a construir una nueva del mismo tamaño. ¿No puede simplemente usar el espacio liberado por la matriz anterior? La respuesta, que explica nuestro problema, es no. Además de ajustar el recolector de basura, no tiene control sobre cuándo se ejecuta, y lo que estamos viendo en el ejemplo de build_arrays.rb es que se realizan nuevas asignaciones de memoria antes de la recolección de basura de nuestros objetos viejos y descartados.

No se asuste si ve un aumento repentino en el uso de memoria de su aplicación. Las aplicaciones pueden quedarse sin memoria por todo tipo de razones, no solo por pérdidas de memoria.

Debo señalar que este no es un tipo de problema horrible de administración de memoria específico de Ruby, sino que generalmente se aplica a los lenguajes recolectados en la basura. Solo para asegurarme de esto, reproduje esencialmente el mismo ejemplo con Go y vi resultados similares. Sin embargo, existen bibliotecas de Ruby que facilitan la creación de este tipo de problemas de memoria.

Divide y conquistaras

Entonces, si necesitamos trabajar con grandes cantidades de datos, ¿estamos condenados a arrojar mucha RAM a nuestro problema? Afortunadamente, ese no es el caso. Si tomamos el ejemplo de build_arrays.rb y reducimos el tamaño de la matriz, veremos una disminución en el punto en el que el uso de la memoria se estanca que es aproximadamente proporcional al tamaño de la matriz.

Esto significa que si podemos dividir nuestro trabajo en partes más pequeñas para procesar y evitar tener demasiados objetos existentes al mismo tiempo, podemos reducir drásticamente la huella de memoria. Desafortunadamente, eso a menudo significa tomar un código agradable y limpio y convertirlo en más código que haga lo mismo, solo que de una manera más eficiente en cuanto a la memoria.

Aislamiento de puntos de acceso de uso de memoria

En un código base real, la fuente de un problema de memoria probablemente no sea tan obvia como en el ejemplo de build_arrays.rb . Aislar un problema de memoria antes de tratar de profundizar y solucionarlo es esencial porque es fácil hacer suposiciones incorrectas sobre lo que está causando el problema.

Generalmente uso dos enfoques, a menudo en combinación, para rastrear problemas de memoria: dejar el código intacto y envolverlo con un perfilador, y monitorear el uso de la memoria del proceso mientras deshabilito/habilito diferentes partes del código que sospecho que podría ser problemático. Usaré memory_profiler aquí para la creación de perfiles, pero ruby-prof es otra opción popular, y derailed_benchmarks tiene excelentes capacidades específicas de Rails.

Aquí hay un código que usará un montón de memoria, donde puede que no quede claro de inmediato qué paso está aumentando más el uso de la memoria:

 # 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 Person , podemos verificar rápidamente que usa mucha memoria cuando se crean muchos registros de personas.

 # 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

Mirando a través del código, hay varios pasos que parecen buenos candidatos para usar mucha memoria: crear una gran variedad de cadenas, llamar a #to_a en una relación de Active Record para crear una gran variedad de objetos de Active Record (no es una gran idea , pero hecho con fines de demostración) y serializando la matriz de objetos Active Record.

Luego podemos perfilar este código para ver dónde están ocurriendo las asignaciones de memoria:

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

Tenga en cuenta que el número que se alimenta para run aquí es 1/10 del ejemplo anterior, ya que el generador de perfiles en sí usa mucha memoria y, de hecho, puede provocar el agotamiento de la memoria cuando se perfila el código que ya causa un uso elevado de la memoria.

El archivo de resultados es bastante largo e incluye memoria y asignación y retención de objetos en los niveles de gema, archivo y ubicación. Hay una gran cantidad de información para explorar, pero aquí hay un par de fragmentos interesantes:

 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 que la mayoría de las asignaciones ocurren dentro de Active Record, lo que parece apuntar a la creación de instancias de todos los objetos en la matriz de records o la serialización con #to_json . A continuación, podemos probar nuestro uso de memoria sin el generador de perfiles mientras deshabilitamos a estos sospechosos. No podemos deshabilitar la recuperación records y aún así poder realizar el paso de serialización, así que intentemos deshabilitar la serialización primero.

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

Resultado:

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

De hecho, parece ser a donde va la mayor parte de la memoria, con un delta de memoria anterior/después que cae un 81 % al omitirlo. También podemos ver qué sucede si dejamos de forzar la creación de la gran variedad 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

Esto también reduce el uso de la memoria, aunque es una reducción de un orden de magnitud menor que la desactivación de la serialización. Entonces, en este punto, conocemos a nuestros principales culpables y podemos tomar una decisión sobre qué optimizar en función de estos datos.

Aunque el ejemplo aquí fue inventado, los enfoques son generalmente aplicables. Es posible que los resultados del generador de perfiles no indiquen el lugar exacto del código donde se encuentra el problema, y ​​también pueden malinterpretarse, por lo que es una buena idea hacer un seguimiento observando el uso real de la memoria mientras activa y desactiva secciones del código. A continuación, veremos algunos casos comunes en los que el uso de la memoria se convierte en un problema y cómo optimizarlos.

deserialización

Una fuente común de problemas de memoria es deserializar grandes cantidades de datos de XML, JSON o algún otro formato de serialización de datos. Usar métodos como JSON.parse o Hash.from_xml de Active Support es increíblemente conveniente, pero cuando los datos que está cargando son grandes, la estructura de datos resultante que se carga en la memoria puede ser enorme.

Si tiene control sobre la fuente de los datos, puede hacer cosas para limitar la cantidad de datos que recibe, como agregar soporte de filtrado o paginación. Pero si es una fuente externa o una que no puedes controlar, otra opción es usar un deserializador de transmisión. Para XML, Ox es una opción, y para JSON yajl-ruby parece funcionar de manera similar, aunque no tengo mucha experiencia con él.

El hecho de que tenga una memoria limitada no significa que no pueda analizar documentos XML o JSON grandes de forma segura. Los deserializadores de transmisión le permiten extraer de forma incremental todo lo que necesite de estos documentos y aun así mantener bajo el consumo de memoria.

Este es un ejemplo de análisis de un archivo XML de 1,7 MB mediante 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 un archivo de 1,7 MB! Esto claramente no va a escalar bien. Aquí está la versión del analizador de transmisión.

 # 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

Esto nos lleva a un aumento de memoria insignificante y debería poder manejar archivos mucho más grandes. Sin embargo, la compensación es que ahora tenemos 28 líneas de código de controlador que no necesitábamos antes, lo que parece ser propenso a errores, y para uso en producción debería tener algunas pruebas a su alrededor.

Publicación por entregas

Como vimos en la sección sobre el aislamiento de puntos de acceso de uso de memoria, la serialización puede tener altos costos de memoria. Aquí está la parte clave de people.rb de antes.

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

Ejecutando esto con 100.000 registros en la base de datos, obtenemos:

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

El problema de llamar a #to_json aquí es que crea una instancia de un objeto para cada registro y luego lo codifica en JSON. Generar el JSON registro por registro para que solo un objeto de registro deba existir a la vez reduce significativamente el uso de la memoria. Ninguna de las bibliotecas populares de Ruby JSON parece manejar esto, pero un enfoque comúnmente recomendado es construir la cadena JSON manualmente. Hay una gema json-write-stream que proporciona una buena API para hacer esto, y convertir nuestro ejemplo a esto se ve así:

 # 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

Una vez más, vemos que la optimización nos ha dado más código, pero el resultado parece valer la pena:

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

Siendo flojo

Una gran característica agregada a Ruby a partir de 2.0 es la capacidad de hacer que los enumeradores sean perezosos. Esto es excelente para mejorar el uso de la memoria al encadenar métodos en un enumerador. Comencemos con un código que no sea perezoso:

 # 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 

Lo que sucede aquí es que en cada paso de la cadena, itera sobre cada elemento en el enumerador, produciendo una matriz que tiene el método subsiguiente en la cadena invocado, y así sucesivamente. Veamos qué sucede cuando hacemos esto perezoso, lo que solo requiere agregar una llamada a lazy en el enumerador que obtenemos 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, un ejemplo que nos brinda una gran ganancia en el uso de memoria, ¡sin agregar mucho código adicional! Tenga en cuenta que si no necesitáramos acumular ningún resultado al final, por ejemplo, si cada elemento se guardara en la base de datos y luego pudiera olvidarse, habría incluso menos uso de memoria. Para hacer una evaluación enumerable perezosa al final de la cadena, simplemente agregue una llamada final a force .

Otra cosa a tener en cuenta sobre el ejemplo es que la cadena comienza con una llamada a times anterior a lazy , que usa muy poca memoria ya que solo devuelve un enumerador que generará un número entero cada vez que se invoque. Entonces, si se puede usar un enumerable en lugar de una gran matriz al comienzo de la cadena, eso ayudará.

Es conveniente mantener todo en matrices y mapas enormes, pero en escenarios del mundo real, rara vez es necesario hacerlo.

Una aplicación del mundo real de construir un enumerable para alimentar perezosamente algún tipo de canalización de procesamiento es el procesamiento de datos paginados. Entonces, en lugar de solicitar todas las páginas y colocarlas en una gran matriz, podrían exponerse a través de un enumerador que oculta muy bien todos los detalles de paginación. Esto podría verse 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

Conclusión

Hicimos una caracterización del uso de la memoria en Ruby y observamos algunas herramientas generales para rastrear problemas de memoria, así como algunos casos comunes y formas de mejorarlos. Los casos comunes que exploramos no son completos y están muy sesgados por el tipo de problemas que personalmente he encontrado. Sin embargo, la mayor ganancia puede ser tener la mentalidad de pensar en cómo el código afectará el uso de la memoria.

Relacionado: Concurrencia y paralelismo de Ruby: un tutorial práctico