Diagnóstico y solución de fugas de memoria en Python
7 de marzo de 2017
0 minutos de lecturaNota del editor
Este blog se publicó originalmente en fugue.co. Fugue se unió a Snyk en 2022 y es un componente clave de Snyk IaC.
Fugue usa Python ampliamente en nuestro producto SaaS de seguridad en la nube y en nuestras herramientas de soporte, gracias a su facilidad de uso, la seguridad de Python, su extensa biblioteca de paquetes y sus potentes herramientas de lenguaje. Una de las cosas que aprendimos al crear software complejo para la nube es que un lenguaje depende tanto de sus herramientas de depuración y perfilado como de sus demás características. Los errores de lógica, los picos de CPU y las fugas de memoria son inevitables, pero un buen depurador y perfiladores de CPU y memoria pueden facilitar y acelerar considerablemente la detección de estos errores, para que nuestros desarrolladores puedan volver a crear el dinámico sistema de orquestación y aplicación de políticas en la nube de Fugue. Veamos un caso concreto.
En otoño, nuestras métricas indicaron que un componente de Python de Fugue llamado reflector experimentaba reinicios aleatorios e inestabilidad después de unos días en funcionamiento. Al revisar el uso de memoria, vimos que la huella de memoria del reflector aumentaba de forma constante y continua, lo que indicaba una fuga de memoria. tracemalloc, una potente herramienta de seguimiento de memoria incluida en la biblioteca estándar de Python, nos permitió diagnosticar y solucionar rápidamente la fuga. Descubrimos que la fuga estaba relacionada con el uso de requests, una popular biblioteca HTTP de terceros para Python. Reescribir el componente para usar urllib, de la biblioteca estándar de Python, eliminó la fuga de memoria. En este blog, exploraremos los detalles.

Asignación de memoria en Python
En la mayoría de los casos, no es necesario entender la administración de memoria en Python más allá de saber que el intérprete la gestiona por ti. Sin embargo, al escribir programas de Python grandes y complejos con altos requisitos de estabilidad, conviene mirar detrás de escena para entender cómo escribir código que interactúe bien con los algoritmos de administración de memoria de Python. En particular, Python usa el conteo de referencias y la recolección de basura para liberar bloques de memoria, y solo devuelve memoria al sistema cuando se cumplen ciertos requisitos internos. Un script escrito únicamente en Python nunca tendrá control directo sobre la asignación de memoria en el intérprete. Si se necesita control directo sobre la asignación de memoria, se puede eludir la asignación de memoria del intérprete escribiendo o usando una extensión. Por ejemplo, numpy administra la memoria de grandes arreglos de datos mediante su propio asignador de memoria.
En esencia, Python es un lenguaje con recolección de basura que usa el conteo de referencias. El intérprete asigna memoria automáticamente a medida que se crean los objetos y registra el número de referencias a esos objetos en una estructura de datos asociada con el propio objeto. Esta memoria se libera cuando el conteo de referencias de esos objetos llega a cero. Además, la recolección de basura detecta ciclos y elimina los objetos a los que solo se hace referencia dentro de ciclos. Estos dos mecanismos permiten liberar todos los bytes de memoria asignados dentro del intérprete de Python, pero no se puede afirmar lo mismo de la memoria asignada en extensiones.
Python administra su propio heap, independiente del heap del sistema. El intérprete de Python asigna memoria mediante distintos métodos, según el tipo de objeto que se va a crear. Los tipos escalares, como los enteros y los números de punto flotante, usan métodos de asignación de memoria distintos de los de los tipos compuestos, como las listas, las tuplas y los diccionarios. En general, la memoria se asigna en el heap de Python en bloques de tamaño fijo, según el tipo. Estos bloques se organizan en pools, que a su vez se agrupan en arenas. La memoria se preasigna mediante arenas, pools y bloques, que luego se usan para almacenar datos según sea necesario durante la ejecución del programa. Como estos bloques, pools y arenas se mantienen en el propio heap de Python, liberar un bloque de memoria solo lo marca como disponible para reutilizarlo en el intérprete. Liberar memoria en Python no libera de inmediato la memoria a nivel del sistema. Cuando una arena completa se marca como libre, el intérprete de Python libera su memoria y la devuelve al sistema. Sin embargo, esto puede ocurrir con poca frecuencia debido a la fragmentación de memoria.
Debido a estas abstracciones, el uso de memoria en Python suele mostrar un comportamiento de marca máxima: el uso máximo de memoria determina el uso de memoria durante el resto de la ejecución, independientemente de que esa memoria siga utilizándose activamente. Además, la relación entre la memoria que se «libera» en el código y la que se devuelve al sistema es imprecisa y difícil de predecir. Estos comportamientos hacen que comprender por completo el uso de memoria de programas complejos de Python sea notoriamente difícil.
Perfilado de memoria con tracemalloc
tracemalloc es un paquete incluido en la biblioteca estándar de Python (a partir de la versión 3.4). Proporciona seguimientos detallados de la asignación de memoria a nivel de bloque, incluida la traza completa hasta la línea donde se produjo la asignación, y estadísticas sobre el comportamiento general de la memoria de un programa. La documentación está disponible aquí y ofrece una buena introducción a sus capacidades. La propuesta original de mejora de Python (PEP) que lo introdujo también ofrece información sobre su diseño.
tracemalloc se puede usar de dos maneras para localizar las áreas del código con mayor uso de memoria:
revisar estadísticas acumuladas del uso de memoria para identificar qué asignaciones de objetos consumen más memoria, y
rastrear los marcos de ejecución para identificar dónde se asignan esos objetos en el código.
Uso de memoria a nivel de módulo
Empezamos por rastrear el uso de memoria de todo el programa, para identificar en términos generales qué objetos consumen más memoria. Esto nos permitirá, esperamos, obtener suficiente información para saber dónde y cómo investigar más a fondo. El siguiente contenedor inicia el seguimiento e imprime estadísticas al presionar Ctrl-C:
tracemalloc.start(10) inicia el seguimiento de memoria y guarda 10 marcos de la traza para cada entrada. El valor predeterminado es 1, pero guardar más marcos de la traza es útil si planeas usar las trazas para localizar fugas de memoria, como veremos más adelante. tracemalloc.take_snapshot() toma una instantánea de la memoria asignada actualmente en el heap de Python. Almacena el número de bloques asignados, su tamaño y las trazas que permiten identificar qué líneas de código asignaron cada bloque de memoria. Una vez creada una instantánea, podemos calcular estadísticas sobre el uso de memoria, comparar instantáneas o guardarlas para analizarlas más adelante. top_n es una función auxiliar que escribí para dar un formato más legible a los resultados de tracemalloc. Aquí, solicito las 25 asignaciones de memoria más grandes de la instantánea, agrupadas por nombre de archivo. Después de ejecutarse durante unos minutos, el resultado es así:
Esto muestra la cantidad acumulada de memoria asignada por el componente durante todo el tiempo de ejecución, agrupada por nombre de archivo. Con este nivel de detalle, es difícil interpretar los resultados. Por ejemplo, la primera línea indica que se crean 17 MB de objetos collections, pero esta vista no proporciona suficientes detalles para saber qué objetos son ni dónde se usan. Necesitamos otro enfoque para aislar el problema.
Cómo interpretar los resultados de tracemalloc
tracemalloc muestra el uso neto de memoria en el momento en que se toma una instantánea. Al comparar dos instantáneas, muestra el uso neto de memoria entre ambas. Si se asigna y libera memoria entre instantáneas, no aparecerá en los resultados. Por lo tanto, si se crean instantáneas en el mismo punto de un ciclo, las asignaciones de memoria que aparezcan en las diferencias entre dos instantáneas contribuyen a la cantidad total de memoria usada a largo plazo, en lugar de ser asignaciones temporales durante la ejecución.
En el caso de los ciclos de referencias que requieren recolección de basura, los ciclos no recolectados aparecen en los resultados, mientras que los que sí se recolectan no. Cualquier bloque que el recolector de basura libere durante el periodo cubierto por una instantánea se registrará como memoria liberada. Por lo tanto, forzar la recolección de basura con gc.collect() antes de tomar una instantánea reducirá el ruido en los resultados.
Uso de memoria en cada iteración
Como buscamos una fuga de memoria, conviene entender cómo cambia con el tiempo el uso de memoria de nuestro programa. Podemos instrumentar el ciclo principal del componente para ver cuánta memoria se asigna en cada iteración, llamando al siguiente método desde el ciclo principal:
Este código toma una instantánea de memoria y la guarda; luego usa snapshot.compare_to(other_snapshot, group_by='filename') para comparar la instantánea más reciente con la anterior y agrupar los resultados por nombre de archivo. Después de algunas iteraciones para estabilizar el uso de memoria, el resultado es así:
Las asignaciones de linecache (1) y tracemalloc (2) forman parte de la instrumentación, pero también podemos ver algunas asignaciones de memoria hechas por el paquete HTTP requests (3) que conviene investigar más. Recordemos que tracemalloc registra el uso neto de memoria, así que estas asignaciones se acumulan en cada iteración. Aunque cada asignación es pequeña y no parece problemática a primera vista, la fuga de memoria solo se vuelve evidente después de unos días, por lo que probablemente se trate de pequeñas pérdidas que se van acumulando.
Filtrar instantáneas
Ahora que tenemos una idea de dónde buscar, podemos usar las funciones de filtrado de tracemalloc para mostrar solo las asignaciones de memoria relacionadas con el paquete requests:
snapshot.filter_traces() recibe una lista de Filters para aplicar a la instantánea. Aquí creamos un Filter en modo inclusive, de modo que solo incluya las trazas que coincidan con filename_pattern. Cuando inclusive es False, el filtro excluye las trazas que coincidan con filename_pattern. filename_pattern usa comodines al estilo UNIX para buscar coincidencias en los nombres de archivo de la traza. En este ejemplo, los comodines de "requests" coinciden con las apariciones de "requests" en medio de una ruta, como "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py".
Luego usamos compare_to() para comparar los resultados con la instantánea anterior. A continuación, se muestran los resultados filtrados:
Con el Filter aplicado, podemos ver claramente cómo requests usa la memoria. La línea (4) muestra que en cada iteración del ciclo principal se pierden aproximadamente 50 KiB de memoria en requests. Observa que en estos resultados se ven asignaciones de memoria negativas, como la (5). Estas asignaciones liberan memoria asignada en iteraciones anteriores del ciclo.
Rastrear las asignaciones de memoria
Para determinar qué usos de requests provocan fugas de memoria, podemos examinar en detalle dónde ocurren las asignaciones problemáticas llamando a compare_to() con traceback en lugar de filename, y usando un Filter para limitar los resultados:
Esto imprime 10 marcos de la traza (porque iniciamos el seguimiento con tracemalloc.start(10)) para cada entrada de los resultados. A continuación, se muestra un ejemplo truncado:
La traza completa nos permite retroceder desde las asignaciones de memoria hasta las líneas del código de nuestro proyecto que las generan. En este componente, nuestro uso de requests provenía de una biblioteca interna de almacenamiento que usaba una API HTTP. Reescribir la biblioteca para usar urllib directamente eliminó la fuga de memoria.

Perfilado de memoria: ¿arte o ciencia?
tracemalloc es una herramienta poderosa para comprender el uso de memoria de los programas de Python. Nos ayudó a entender el uso de memoria a nivel de módulo, descubrir qué objetos se asignan con mayor frecuencia y ver cómo cambiaba el uso de memoria del reflector en cada iteración. Incluye herramientas útiles de filtrado y nos permite ver el seguimiento completo de la pila de llamadas de cualquier asignación de memoria. Sin embargo, a pesar de todas sus funciones, encontrar fugas de memoria en Python todavía puede parecer más un arte que una ciencia. Los perfiladores de memoria nos permiten ver cómo se usa la memoria, pero a menudo es difícil encontrar la asignación exacta que está causando problemas. Nos corresponde sintetizar la información que obtenemos de nuestras herramientas para llegar a una conclusión sobre el comportamiento de la memoria del programa y, luego, decidir qué medidas tomar.
Usamos prácticamente todas las herramientas disponibles para Python (marcos de prueba, cProfile, etc.) para que el sistema de Fugue sea confiable, eficiente y fácil de mantener. Tanto el broker como el reflector aprovechan la introspección de Python para tomar decisiones sobre las llamadas dinámicas a la API de AWS, lo que nos permite enfocarnos en la lógica en lugar de programar casos exhaustivos. Fugue aprovecha las fortalezas de Python donde tiene sentido dentro del sistema, lo que, en última instancia, se traduce en una mayor estabilidad y extensibilidad del producto para los usuarios finales.
Seguridad de IaC diseñada para desarrolladores
Snyk protege tu infraestructura como código desde el ciclo de vida del desarrollo de software hasta el runtime en la nube con un motor unificado de políticas como código, para que todos los equipos puedan desarrollar, implementar y operar de forma segura.
