Skip to main content

Diagnóstico y solución de fugas de memoria en Python

Escrito por
blog hero python code purple

7 de marzo de 2017

0 minutos de lectura

 

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.

Gráfico de líneas que muestra cómo el uso de memoria del reflector aumenta de aproximadamente un 8 % el día 1 a un 20 % el día 4.
Las métricas muestran el problema: porcentaje de la memoria total del sistema utilizada por el reflector, mediante la biblioteca requests.

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:

import tracemalloctracemalloc.start(10)
try:    
	run_reflector()
except:    
	snapshot = tracemalloc.take_snapshot()    
	top_n(25, snapshot, trace_type='filename')

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í:

[ Top 25 with filename tracebacks ]
197618 blocks 17.02311134338379 MB/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/collections/__init__.py:0: size=17.0 MiB,
 count=197618,
 average=90 B105364 blocks 11.34091567993164 MB frozen importlib._bootstrap:0: 
size=11.3 MiB, 
count=105364, 
average=113 B60339 blocks 9.233230590820312 MB/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/json/decoder.py:0:
size=9455 KiB, 
count=60339, 
average=160 B...

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:

def collect_stats(self):        
self.snapshots.append(tracemalloc.take_snapshot())        
if len(self.snapshots)  1: 

stats = self.snapshots[-1].filter_traces(filters).compare_to(self.snapshots[-2], 'filename')    

for stat in stats[:10]:                
print("{} new KiB {} total KiB {} new {} total memory blocks: ".format(stat.size_diff/1024, stat.size / 1024, stat.count_diff ,stat.count))                
for line in stat.traceback.format():                    
print(line)

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í:

[ Top 5 with filename tracebacks ]190.7421875 
new KiB 1356.5634765625 total KiB 1930 
new 13574 total memory blocks:      
(1)  File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/linecache.py", 

line 02.1328125 
new KiB 12.375 total KiB 32 
new 86 total memory blocks:             

(2)  File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/tracemalloc.py", 
line 01.859375 
new KiB 18.7001953125 total KiB 3 
new 53 total memory blocks:         

(3)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connection.py", 
line 0-1.71875 
new KiB 34.5224609375 total KiB -2 
new 91 total memory blocks:   File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connectionpool.py", 
line 01.66015625 new KiB 61.662109375 total KiB 18 new 260 total memory blocks:   
File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/urllib/parse.py", line 0

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:

from tracemalloc 
import Filter    
filters = [Filter(inclusive=True, filename_pattern="*requests*")]    
filtered_stats = snapshot.filter_traces(filters).compare_to(old_snapshot.filter_traces(filters), 'traceback')    
for stat in stats[:10]:        
	print("{} 
	new KiB {} 
	total KiB {} 
	new {} 
	total memory blocks: ".format(stat.size_diff/1024, stat.size / 1024, stat.count_diff ,stat.count))        

	for line in stat.traceback.format():            
	print(line)

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:

48.7890625 
new KiB 373.974609375 total KiB 4 
new 1440 total memory blocks:                 

(4)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/structures.py", 
line 01.46875 
new KiB 16.2939453125 total KiB 2 
new 49 total memory blocks:   

File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 0 -1.4453125

new KiB 34.2802734375 total KiB -2 
new 96 total memory blocks:                 

(5)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 0-0.859375 
new KiB 31.8505859375 total KiB -1 
new 85 total memory blocks:   

File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connectionpool.py", 
line 00.6484375 
new KiB 20.8330078125 total KiB 1 
new 56 total memory blocks:   
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connection.py", line 0

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:

   stats = snapshot.filter_traces(filters).compare_to(old_snapshot.filter_traces(filters), 'traceback')

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:

5 memory blocks: 4.4921875 KiB  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 585    
r = adapter.send(request, **kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 475    
resp = self.send(prep, **send_kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 46    
return session.request(method=method, url=url, **kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 60    
return request('post', url, data=data, json=json, **kwargs)

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.

Gráfico de líneas que muestra que el uso de memoria del reflector se mantiene mayormente estable, entre el 8,5 % y el 9,3 %, durante cuatro días
Las métricas indican que el problema está resuelto: porcentaje de la memoria total del sistema utilizado por el reflector, después de eliminar las solicitudes y cambiar a urllib.

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.

Publicado en: