Nosotros en
Phusion tenemos un simple proxy HTTP multiproceso en Ruby (distribuye paquetes DEB y RPM). Vi en él un consumo de memoria de 1.3 GB. Pero esto es una locura para un proceso sin estado ...
Pregunta: ¿Qué es? Respuesta: ¡Ruby usa la memoria con el tiempo!Resulta que no estoy solo en este problema. Las aplicaciones de Ruby pueden usar mucha memoria. Pero por que? Según
Heroku y
Nate Burkopek , la
hinchazón se debe principalmente a la fragmentación de la memoria y la distribución excesiva del
montón .
Berkopek concluyó que hay dos soluciones:
- Utilice un asignador de memoria completamente diferente al glibc, generalmente jemalloc , o:
- Establezca la variable de entorno mágico
MALLOC_ARENA_MAX=2
.
Me preocupa la descripción del problema y las soluciones propuestas. Hay algo mal aquí ... No estoy seguro de que el problema esté completamente descrito correctamente o de que estas sean las únicas soluciones disponibles. También me molesta que muchos se refieran a jemalloc como una piscina mágica de plata.
La magia es solo una ciencia que aún no entendemos . Así que hice un viaje de investigación para descubrir toda la verdad. Este artículo cubrirá los siguientes temas:
- Cómo funciona la asignación de memoria.
- ¿Qué es esta "fragmentación" y "distribución excesiva" de memoria de la que todos están hablando?
- ¿Qué causa un gran consumo de memoria? ¿La situación es consistente con lo que dice la gente o hay algo más? (spoiler: sí, hay algo más).
- ¿Hay alguna solución alternativa? (spoiler: encontré uno).
Nota: este artículo es relevante solo para Linux, y solo para aplicaciones de Ruby multiproceso.Contenido
Asignación de memoria rubí: una introducción
Ruby asigna memoria a tres niveles, de arriba a abajo:
- Intérprete de Ruby que gestiona objetos Ruby.
- La biblioteca del asignador de memoria del sistema operativo.
- El núcleo
Veamos cada nivel.
Rubí
Por su parte, Ruby organiza objetos en áreas de memoria llamadas
páginas de montón Ruby . Tal página de montón se divide en ranuras del mismo tamaño, donde un objeto ocupa una ranura. Ya sea una cadena, una tabla hash, una matriz, una clase u otra cosa, ocupa una ranura.
Los espacios en la página del montón pueden estar ocupados o libres. Cuando Ruby selecciona un nuevo objeto, inmediatamente trata de ocupar un espacio libre. Si no hay espacios libres, se resaltará una nueva página de montón.
La ranura es pequeña, de unos 40 bytes. Obviamente, algunos objetos no caben en él, por ejemplo, líneas de 1 MB. Luego, Ruby almacena la información en otro lugar fuera de la página del montón y coloca un puntero a esta área de memoria externa en la ranura.
Los datos que no caben en la ranura se almacenan fuera de la página del montón. Ruby coloca un puntero a estos datos externos en la ranuraTanto las páginas de almacenamiento dinámico de Ruby como cualquier área de memoria externa se asignan utilizando el asignador de memoria del sistema.
Asignador de memoria del sistema
El asignador de memoria del sistema operativo es parte de glibc (tiempo de ejecución C). Es utilizado por casi todas las aplicaciones, no solo Ruby. Tiene una API simple:
- La memoria se asigna llamando a
malloc(size)
. Le da el número de bytes que desea asignar y devuelve la dirección de asignación o un error. - La memoria asignada se libera llamando
free(address)
.
A diferencia de Ruby, donde se asignan ranuras del mismo tamaño, el asignador de memoria se ocupa de las solicitudes de asignación de memoria de cualquier tamaño. Como aprenderá más adelante, este hecho conduce a algunas complicaciones.
A su vez, el asignador de memoria accede a la API del núcleo. El núcleo requiere fragmentos de memoria mucho más grandes que los que solicitan sus propios suscriptores, ya que la llamada del núcleo es costosa y la API del núcleo tiene una limitación: solo puede asignar memoria en múltiplos de 4 KB.
El asignador de memoria asigna fragmentos grandes (se denominan montones del sistema) y comparte su contenido para satisfacer las solicitudes de las aplicaciones.El área de memoria que el asignador de memoria asigna desde el núcleo se denomina montón. Tenga en cuenta que no tiene nada que ver con las páginas del montón Ruby, por lo que, para mayor claridad, usaremos el término
montón del sistema .
El asignador de memoria luego asigna partes de los montones del sistema a sus llamantes hasta que haya espacio libre. En este caso, el asignador de memoria asigna un nuevo montón de sistema desde el núcleo. Esto es similar a cómo Ruby selecciona objetos de las páginas de un montón de Ruby.
Ruby asigna memoria del asignador de memoria, que a su vez asigna memoria del núcleoEl núcleo
El núcleo solo puede asignar memoria en unidades de 4 KB. Uno de esos bloques 4K se llama página. Para evitar confusiones con las páginas del montón Ruby, para mayor claridad, utilizaremos el término
página del sistema (
página del sistema operativo).
La razón es difícil de explicar, pero así es como funcionan todos los núcleos modernos.
La asignación de memoria a través del núcleo tiene un impacto significativo en el rendimiento, razón por la cual los asignadores de memoria intentan minimizar la cantidad de llamadas al núcleo.
Definición de uso de memoria
Por lo tanto, la memoria se asigna a varios niveles, y cada nivel asigna más memoria de la que realmente necesita. Las páginas de montón de Ruby pueden tener espacios libres, así como montones de sistema. Por lo tanto, la respuesta a la pregunta "¿Cuánta memoria se usa?" ¡depende completamente del nivel que pidas!
Herramientas como
top
o
ps
muestran el uso de memoria desde la perspectiva del
kernel . Esto significa que los niveles superiores deben funcionar en concierto para liberar memoria desde el punto de vista del núcleo. Como aprenderá más tarde, esto es más difícil de lo que parece.
¿Qué es la fragmentación?
La fragmentación de la memoria significa que las asignaciones de memoria se dispersan aleatoriamente. Esto puede causar problemas interesantes.
Fragmentación de nivel rubí
Considere la recolección de basura de Ruby. La recolección de basura para un objeto significa marcar el espacio de la página del montón Ruby como libre, lo que permite su reutilización. Si toda la página del montón de Ruby consta solo de ranuras libres, entonces toda su página se puede volver a liberar al asignador de memoria (y, posiblemente, de vuelta al núcleo).
Pero, ¿qué sucede si no todas las máquinas tragamonedas son gratuitas? ¿Qué pasa si tenemos muchas páginas del montón de Ruby, y el recolector de basura libera objetos en diferentes lugares, de modo que al final hay muchos espacios libres, pero en diferentes páginas? En esta situación, Ruby tiene ranuras libres para colocar objetos, ¡pero el asignador de memoria y el núcleo continuarán asignando memoria!
Fragmentación de asignación de memoria
El asignador de memoria tiene un problema similar pero completamente diferente. No necesita borrar inmediatamente los montones de todo el sistema. Teóricamente, puede liberar cualquier página del sistema. Pero dado que el asignador de memoria trata con asignaciones de memoria de tamaño arbitrario, puede haber varias asignaciones en la página del sistema. No puede liberar la página del sistema hasta que se liberen todas las selecciones.
Piense en lo que sucede si tenemos una asignación de 3 KB, así como una asignación de 2 KB, dividida en dos páginas del sistema. Si libera los primeros 3 KB, ambas páginas del sistema permanecerán parcialmente ocupadas y no podrán liberarse.
Por lo tanto, si las circunstancias fallan, habrá mucho espacio libre en las páginas del sistema, pero no se liberarán por completo.
Peor aún: ¿qué pasa si hay muchos lugares libres, pero ninguno de ellos es lo suficientemente grande como para satisfacer una nueva solicitud de asignación? El asignador de memoria tendrá que asignar un montón de sistema completamente nuevo.
¿La fragmentación de la página del montón de Ruby está causando una hinchazón de memoria?
Es probable que la fragmentación esté causando un uso excesivo de la memoria en Ruby. Si es así, ¿cuál de las dos fragmentaciones es más dañina? Esto es ...
- ¿Fragmentación de la página del montón de rubíes? O
- ¿Fragmentación del asignador de memoria?
La primera opción es bastante simple de verificar. Ruby proporciona dos API:
ObjectSpace.memsize_of_all
y
GC.stat
. Gracias a esta información, puede calcular toda la memoria que recibió Ruby del asignador.
ObjectSpace.memsize_of_all
devuelve la memoria ocupada por todos los objetos Ruby activos. Es decir, todo el espacio en sus ranuras y cualquier dato externo. En el diagrama anterior, este es el tamaño de todos los objetos azules y naranjas.
GC.stat
permite averiguar el tamaño de todas las ranuras libres, es decir, el área gris completa en la ilustración de arriba. Aquí está el algoritmo:
GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]
Para resumirlos, este es todo el recuerdo que conoce Ruby, e implica fragmentar las páginas del montón de Ruby. Si, desde el punto de vista del kernel, el uso de memoria es mayor, entonces la memoria restante va a algún lugar fuera del control de Ruby, por ejemplo, a bibliotecas o fragmentación de terceros.
Escribí un programa de prueba simple que crea un montón de hilos, cada uno de los cuales selecciona líneas en un bucle. Aquí está el resultado después de un tiempo:
es ... solo ... loco!
El resultado muestra que Ruby tiene un efecto tan débil en la cantidad total de memoria utilizada, no importa si las páginas del montón de Ruby están fragmentadas o no.
Hay que buscar al culpable en otra parte. Al menos ahora sabemos que Ruby no tiene la culpa.
Estudio de fragmentación de asignación de memoria
Otro sospechoso probable es un asignador de memoria. Al final, Nate Berkopek y Heroku notaron que preocuparse por el asignador de memoria (ya sea un reemplazo completo para jemalloc o establecer la variable de entorno mágico
MALLOC_ARENA_MAX=2
) reduce drásticamente el uso de memoria.
Primero veamos qué hace
MALLOC_ARENA_MAX=2
y por qué ayuda. Luego examinamos la fragmentación a nivel del distribuidor.
Asignación de memoria excesiva y glibc
La razón por la que
MALLOC_ARENA_MAX=2
ayuda es
MALLOC_ARENA_MAX=2
subprocesamiento
MALLOC_ARENA_MAX=2
. Cuando varios subprocesos intentan simultáneamente asignar memoria desde el mismo montón de sistema, luchan por el acceso. Solo un subproceso a la vez puede recibir memoria, lo que reduce el rendimiento de la asignación de memoria de subprocesos múltiples.
Solo un subproceso a la vez puede funcionar con el montón del sistema. En tareas de subprocesos múltiples, surge un conflicto y, en consecuencia, el rendimiento disminuyeEn el asignador de memoria para tal caso hay optimización. Intenta crear varios montones de sistema y asignarlos a diferentes subprocesos. La mayoría de las veces un subproceso solo funciona con su propio montón, evitando conflictos con otros subprocesos.
De hecho, el número máximo de montones de sistemas asignados de esta manera es por defecto igual al número de procesadores virtuales multiplicado por 8. Es decir, en un sistema de doble núcleo con dos hiperprocesos, ¡cada uno produce
2 * 2 * 8 = 32
montones de sistemas! Esto es lo que yo llamo
distribución excesiva .
¿Por qué es tan grande el multiplicador predeterminado? Porque el desarrollador líder del asignador de memoria es Red Hat. Sus clientes son grandes empresas con servidores potentes y una tonelada de RAM. La optimización anterior le permite aumentar el rendimiento promedio de subprocesos múltiples en un 10% debido a un aumento significativo en el uso de la memoria. Para los clientes de Red Hat, este es un buen compromiso. Para la mayoría del resto, apenas.
Nate en su blog y en el artículo de Heroku afirman que aumentar la cantidad de montones de sistemas aumenta la fragmentación y citan documentación oficial. La variable
MALLOC_ARENA_MAX
reduce el número máximo de montones de sistema asignados para subprocesos múltiples. Por esta lógica, reduce la fragmentación.
Visualización de montones de sistema.
¿Es verdad la afirmación de Nate y Heroku de que aumentar la cantidad de montones de sistemas aumenta la fragmentación? De hecho, ¿hay algún problema con la fragmentación en el nivel del asignador de memoria? No quería dar por sentado ninguno de estos supuestos, así que comencé el estudio.
Desafortunadamente, no hay herramientas para visualizar los montones de sistemas, por lo
que escribí tal visualizador yo mismo .
Primero, debe preservar de alguna manera el esquema de distribución de los montones del sistema. Estudié la
fuente del asignador de memoria y miré cómo representa internamente la memoria. Luego escribió una biblioteca que itera sobre estas estructuras de datos y escribe el esquema en un archivo. Finalmente, escribió una herramienta que toma un archivo como entrada y compila la visualización como imágenes HTML y PNG (
código fuente ).
Aquí hay un ejemplo de visualización de un montón de sistema específico (hay muchos más). Pequeños bloques en esta visualización representan páginas del sistema.
- Las áreas rojas se utilizan celdas de memoria.
- Los grises son áreas libres que no se devuelven al núcleo.
- Las áreas blancas se liberan para el núcleo.
Las siguientes conclusiones se pueden extraer de la visualización:
- Hay cierta fragmentación. Las manchas rojas están dispersas de la memoria, y algunas páginas del sistema son solo medio rojas.
- Para mi sorpresa, la mayoría de los montones del sistema contienen una cantidad significativa de páginas del sistema completamente gratis (gris).
Y luego me di cuenta:
Aunque la fragmentación sigue siendo un problema, ¡no es el punto!Más bien, el problema es muy gris: ¡este asignador de memoria
no envía memoria al núcleo !
Después de volver a estudiar el código fuente del asignador de memoria, resultó que, de forma predeterminada, solo envía páginas del sistema al núcleo al final del montón del sistema, e incluso
rara vez lo hace. Probablemente, dicho algoritmo se implementa por razones de rendimiento.
Truco de magia: circuncisión
Afortunadamente, encontré un truco. Hay una interfaz de programación que obligará al asignador de memoria a liberar el núcleo, no solo la última, sino
todas las páginas relevantes del sistema. Se llama
malloc_trim .
Conocía esta función, pero no pensé que fuera útil, porque el manual dice lo siguiente:
La función malloc_trim () intenta liberar memoria libre en la parte superior del montón.
¡El manual está mal! El análisis del código fuente dice que el programa libera todas las páginas relevantes del sistema, no solo la parte superior.
¿Qué sucede si se llama a esta función durante la recolección de basura?
malloc_trim()
código fuente de Ruby 2.6 para llamar a
malloc_trim()
en la función gc_start desde gc.c, por ejemplo:
gc_prof_timer_start(objspace); { gc_marks(objspace, do_full_mark);
Y aquí están los resultados de la prueba:
¡Qué gran diferencia! Un simple parche redujo el consumo de memoria a casi
MALLOC_ARENA_MAX=2
.
Así es como se ve en la visualización:
Vemos muchas áreas blancas que corresponden a las páginas del sistema liberadas nuevamente al núcleo.
Conclusión
Resultó que la fragmentación, básicamente, no tenía nada que ver con eso. La desfragmentación sigue siendo útil, pero el problema principal es que al asignador de memoria no le gusta liberar memoria de vuelta al núcleo.
Afortunadamente, la solución resultó ser muy simple. Lo principal era encontrar la causa raíz.
Código fuente del visualizador
Código fuente¿Qué pasa con el rendimiento?
El rendimiento siguió siendo una de las principales preocupaciones. Llamar a
malloc_trim()
no se puede
malloc_trim()
de forma gratuita, pero de acuerdo con el código, el algoritmo funciona en tiempo lineal. Entonces recurrí a
Noah Gibbs , quien lanzó el banco de pruebas Rails Ruby Bench. Para mi sorpresa, el parche causó un ligero
aumento en el rendimiento.
Me voló la cabeza. El efecto es incomprensible, pero la noticia es buena.
Necesito más pruebas
Como parte de este estudio, solo se ha verificado un número limitado de casos. No se sabe cuál es el impacto en otras cargas de trabajo. Si desea ayudar con las pruebas,
contácteme .