Gestión de memoria en Python: La Guía Esencial

Arwin Lashawn el 04 de diciembre de 2020

Antecedentes

Python no es conocido por ser un lenguaje de programación «rápido». Sin embargo, según los resultados de la Encuesta de Desarrolladores de Stack Overflow de 2020, Python es el 2º lenguaje de programación más popular por detrás de JavaScript (como habrás adivinado). Esto se debe en gran medida a su sintaxis súper amigable y su aplicabilidad para casi cualquier propósito. Aunque Python no es el lenguaje más rápido que existe, su gran legibilidad, junto con el apoyo de la comunidad y la disponibilidad de bibliotecas sin igual, lo han hecho extremadamente atractivo para hacer cosas con el código.

La gestión de la memoria de Python también juega un papel en su popularidad. ¿Cómo es eso? La gestión de la memoria de Python está implementada de forma que nos facilita la vida. ¿Has oído hablar del gestor de memoria de Python? Es el gestor que mantiene la memoria de Python bajo control, permitiéndote así centrarte en tu código en lugar de tener que preocuparte por la gestión de la memoria. Debido a su simplicidad, sin embargo, Python no te proporciona mucha libertad en la gestión del uso de la memoria, a diferencia de lenguajes como C++ donde puedes asignar y liberar memoria manualmente.

Sin embargo, tener una buena comprensión de la gestión de la memoria de Python es un gran comienzo que te permitirá escribir un código más eficiente. En última instancia, puedes imponerlo como un hábito que potencialmente puede ser adoptado en otros lenguajes de programación que conozcas.

Entonces, ¿qué obtenemos al escribir código eficiente en memoria?

  • Lleva a un procesamiento más rápido y a una menor necesidad de recursos, concretamente al uso de memoria de acceso aleatorio (RAM). Más memoria RAM disponible significaría generalmente más espacio para la caché, lo que ayudará a acelerar el acceso al disco. Lo bueno de escribir un código que sea eficiente en cuanto a la memoria es que no necesariamente requiere que se escriban más líneas de código.
  • Otro beneficio es que previene las fugas de memoria, un problema que hace que el uso de la RAM aumente continuamente incluso cuando los procesos son eliminados, lo que eventualmente conduce a la ralentización o al deterioro del rendimiento del dispositivo. Esto es causado por la incapacidad de liberar la memoria utilizada después de que los procesos terminen.

En el mundo de la tecnología, es posible que haya escuchado que «lo hecho es mejor que lo perfecto». Sin embargo, digamos que tienes dos desarrolladores que han utilizado Python en el desarrollo de la misma aplicación y la han completado en el mismo tiempo. Uno de ellos ha escrito un código más eficiente en cuanto a la memoria, lo que resulta en una aplicación más rápida. ¿Preferirías elegir la aplicación que se ejecuta sin problemas o la que se ejecuta notablemente más lenta? Este es un buen ejemplo en el que dos individuos pasarían la misma cantidad de tiempo de codificación y, sin embargo, tienen rendimientos de código notablemente diferentes.

Esto es lo que aprenderás en la guía:

  • ¿Cómo se gestiona la memoria en Python?
  • Colección de basura en Python
  • Monitoreo de la memoria en Python
  • Mejores prácticas para mejorar el rendimiento del código en Python

¿Cómo se gestiona la memoria en Python?

De acuerdo con la documentación de Python (3.9.0) para la gestión de la memoria, la gestión de la memoria de Python implica un montón privado que se utiliza para almacenar los objetos y estructuras de datos de su programa. Además, recuerda que es el gestor de memoria de Python el que se encarga de la mayor parte del trabajo sucio relacionado con la gestión de la memoria para que tú sólo te centres en tu código.

Asignación de memoria en Python

Todo en Python es un objeto. Para que estos objetos sean útiles, necesitan ser almacenados en la memoria para ser accedidos. Antes de que puedan ser almacenados en la memoria, un trozo de memoria debe ser primero asignado o asignado para cada uno de ellos.

En el nivel más bajo, el asignador de memoria bruta de Python primero se asegurará de que hay espacio disponible en el heap privado para almacenar estos objetos. Esto lo hace interactuando con el gestor de memoria de tu sistema operativo. Míralo como si tu programa Python pidiera al sistema operativo un trozo de memoria para trabajar.

En el siguiente nivel, varios asignadores específicos de objetos operan en el mismo heap e implementan distintas políticas de gestión dependiendo del tipo de objeto. Como ya sabrás, algunos ejemplos de tipos de objetos son las cadenas y los enteros. Aunque las cadenas y los enteros pueden no ser tan diferentes si tenemos en cuenta el tiempo que tardamos en reconocerlos y memorizarlos, los ordenadores los tratan de forma muy diferente. Esto se debe a que los ordenadores necesitan diferentes requisitos de almacenamiento y compensaciones de velocidad para los enteros en comparación con las cadenas.

Una última cosa que debes saber sobre cómo se gestiona el montón de Python es que no tienes ningún control sobre él. Ahora te estarás preguntando, ¿cómo escribimos entonces código eficiente en memoria si tenemos tan poco control sobre la gestión de la memoria de Python? Antes de entrar en eso, tenemos que entender mejor algunos términos importantes relativos a la gestión de la memoria.

Asignación de memoria estática vs. dinámica

Ahora que entiendes lo que es la asignación de memoria, es el momento de familiarizarte con los dos tipos de asignación de memoria, a saber, estática y dinámica, y distinguir entre los dos.

Asignación de memoria estática:

  • Como la palabra «estática» sugiere, las variables asignadas estáticamente son permanentes, lo que significa que necesitan ser asignadas de antemano y duran mientras el programa se ejecuta.
  • La memoria se asigna durante el tiempo de compilación, o antes de la ejecución del programa.
  • Se implementa utilizando la estructura de datos de la pila, lo que significa que las variables se almacenan en la memoria de la pila.
  • La memoria que ha sido asignada no puede ser reutilizada, por lo que no hay reutilización de memoria.

Asignación de memoria dinámica:

  • Como la palabra «dinámica» sugiere, las variables asignadas dinámicamente no son permanentes y pueden ser asignadas mientras un programa se está ejecutando.
  • La memoria se asigna en tiempo de ejecución o durante la ejecución del programa.
  • Se implementa utilizando la estructura de datos del heap, lo que significa que las variables se almacenan en la memoria del heap.
  • La memoria que ha sido asignada puede ser liberada y reutilizada.

Una ventaja de la asignación dinámica de memoria en Python es que no tenemos que preocuparnos por la cantidad de memoria que necesitamos para nuestro programa de antemano. Otra ventaja es que la manipulación de la estructura de datos se puede hacer libremente sin tener que preocuparse por la necesidad de una mayor asignación de memoria si la estructura de datos se expande.

Sin embargo, como la asignación de memoria dinámica se hace durante la ejecución del programa, consumirá más tiempo para su realización. Además, la memoria que ha sido asignada necesita ser liberada después de haber sido utilizada. De lo contrario, los problemas como las fugas de memoria pueden ocurrir potencialmente.

Nos encontramos con dos tipos de estructuras de memoria anteriormente – la memoria del montón y la memoria de la pila. Vamos a echar un vistazo más profundo a ellos.

Memoria de pila

Todos los métodos y sus variables se almacenan en la memoria de pila. Recuerda que la memoria de pila se asigna durante el tiempo de compilación? Esto significa efectivamente que el acceso a este tipo de memoria es muy rápido.

Cuando se llama a un método en Python, se asigna un marco de pila. Este marco de pila manejará todas las variables del método. Después de que el método es devuelto, el marco de la pila se destruye automáticamente.

Tenga en cuenta que el marco de la pila también es responsable de establecer el alcance de las variables de un método.

Memoria del montón

Todos los objetos y variables de instancia se almacenan en la memoria del montón. Cuando se crea una variable en Python, se almacena en un heap privado que luego permitirá su asignación y desasignación.

La memoria heap permite que estas variables sean accedidas globalmente por todos los métodos de tu programa. Una vez devuelta la variable, el recolector de basura de Python se pone a trabajar, cuyo funcionamiento veremos más adelante.

Ahora echemos un vistazo a la estructura de memoria de Python.

Python tiene tres niveles diferentes cuando se trata de su estructura de memoria:

  • Arenas
  • Pools
  • Bloques

Empezaremos por la más grande de todas: las arenas.

Arenas

Imagina un escritorio con 64 libros cubriendo toda su superficie. La parte superior del escritorio representa una arena que tiene un tamaño fijo de 256KiB que se asigna en el montón (Ten en cuenta que KiB es diferente de KB, pero puedes asumir que son lo mismo para esta explicación). Una arena representa el trozo de memoria más grande posible.

Más específicamente, las arenas son mapeos de memoria que son usados por el asignador de Python, pymalloc, que está optimizado para objetos pequeños (menores o iguales a 512 bytes). Las arenas se encargan de asignar memoria, y por lo tanto las estructuras posteriores no tienen que hacerlo más.

Esta arena se puede dividir a su vez en 64 pools, que es la siguiente estructura de memoria más grande.

Pools

Volviendo al ejemplo del escritorio, los libros representan todos los pools dentro de una arena.

Cada pool tendría típicamente un tamaño fijo de 4Kb y puede tener tres estados posibles:

  • Vacío: El pool está vacío y por tanto disponible para su asignación.
  • Usado: El pool contiene objetos que hacen que no esté ni vacío ni lleno.
  • Lleno: El pool está lleno y por lo tanto no está disponible para ninguna otra asignación.

Tenga en cuenta que el tamaño del pool debe corresponder al tamaño de página de memoria por defecto de su sistema operativo.

Un pool se divide entonces en muchos bloques, que son las estructuras de memoria más pequeñas.

Bloques

Volviendo al ejemplo del escritorio, las páginas dentro de cada libro representan todos los bloques dentro de un pool.

A diferencia de las arenas y los pools, el tamaño de un bloque no es fijo. El tamaño de un bloque va de 8 a 512 bytes y debe ser un múltiplo de ocho.

Cada bloque sólo puede almacenar un objeto Python de un tamaño determinado y tiene tres estados posibles:

  • Sin tocar: No ha sido asignado
  • Libre: Ha sido asignado pero fue liberado y puesto a disposición para su asignación
  • Asignado: Ha sido asignado

Nótese que los tres niveles diferentes de una estructura de memoria (arenas, pools y bloques) que discutimos anteriormente son específicamente para objetos Python más pequeños. Los objetos grandes se dirigen al asignador estándar de C dentro de Python, que sería una buena lectura para otro día.

Recogida de basura de Python

La recogida de basura es un proceso llevado a cabo por un programa para liberar la memoria previamente asignada para un objeto que ya no está en uso. Puedes pensar en la asignación de basura como un reciclaje o reutilización de la memoria.

Antes, los programadores tenían que asignar y desasignar memoria manualmente. Olvidar la desasignación de memoria podría llevar a una fuga de memoria, lo que llevaría a una caída en el rendimiento de la ejecución. Peor aún, la asignación y desasignación manual de memoria es incluso probable que conduzca a la sobreescritura accidental de la memoria, lo que puede hacer que el programa se bloquee por completo.

En Python, la recolección de basura se realiza de forma automática y por lo tanto le ahorra un montón de dolores de cabeza para gestionar manualmente la asignación y desasignación de memoria. En concreto, Python utiliza el conteo de referencias combinado con la recolección de basura generacional para liberar la memoria no utilizada. La razón por la que el conteo de referencias por sí solo no es suficiente para Python porque no limpia eficazmente las referencias cíclicas colgantes.

Un ciclo de recolección de basura generacional contiene los siguientes pasos –

  1. Python inicializa una «lista de descarte» para los objetos no utilizados.
  2. Se ejecuta un algoritmo para detectar ciclos de referencia.
  3. Si un objeto carece de referencias externas, se inserta en la lista de descartes.
  4. Se libera la asignación de memoria para los objetos de la lista de descartes.

Para aprender más sobre la recolección de basura en Python, puedes consultar nuestro post Python Garbage Collection: A Guide for Developers post.

Monitoreo de los problemas de memoria en Python

Aunque a todo el mundo le gusta Python, no se libra de tener problemas de memoria. Hay muchas razones posibles.

De acuerdo con la documentación de Python (3.9.0) para la gestión de la memoria, el gestor de memoria de Python no necesariamente libera la memoria de nuevo a su sistema operativo. Se indica en la documentación que «bajo ciertas circunstancias, el gestor de memoria de Python puede no desencadenar acciones apropiadas, como la recolección de basura, la compactación de memoria u otras medidas preventivas»

Como resultado, uno puede tener que liberar explícitamente la memoria en Python. Una forma de hacerlo es forzar al recolector de basura de Python a liberar la memoria no utilizada haciendo uso del módulo gc. Para ello, basta con ejecutar gc.collect(). Esto, sin embargo, sólo proporciona beneficios notables cuando se manipula un número muy grande de objetos.

Aparte de la naturaleza ocasionalmente errónea del recolector de basura de Python, especialmente cuando se trata de grandes conjuntos de datos, varias bibliotecas de Python también son conocidas por causar fugas de memoria. Pandas, por ejemplo, es una de estas herramientas en el radar. Considere la posibilidad de echar un vistazo a todos los problemas relacionados con la memoria en el repositorio oficial de GitHub de pandas!

Una razón obvia que puede pasar desapercibida incluso a los ojos agudos de los revisores de código es que hay objetos grandes persistentes dentro del código que no se liberan. En la misma nota, las estructuras de datos de crecimiento infinito es otra causa de preocupación. Por ejemplo, una estructura de datos de diccionario creciente sin un límite de tamaño fijo.

Una forma de resolver la estructura de datos creciente es convertir el diccionario en una lista si es posible y establecer un tamaño máximo para la lista. De lo contrario, simplemente establezca un límite para el tamaño del diccionario y límpielo cada vez que se alcance el límite.

Ahora se estará preguntando, ¿cómo detecto los problemas de memoria en primer lugar? Una opción es aprovechar una herramienta de supervisión del rendimiento de las aplicaciones (APM). Además, muchos módulos útiles de Python pueden ayudarle a rastrear y localizar problemas de memoria. Veamos nuestras opciones, empezando por las herramientas APM.

Herramientas de monitorización del rendimiento de las aplicaciones (APM)

Entonces, ¿qué es exactamente la monitorización del rendimiento de las aplicaciones y cómo ayuda a rastrear los problemas de memoria? Una herramienta APM le permite observar las métricas de rendimiento en tiempo real de un programa, lo que permite una optimización continua a medida que descubre los problemas que limitan el rendimiento.

A partir de los informes generados por las herramientas APM, podrá tener una idea general sobre el rendimiento de su programa. Dado que puede recibir y supervisar las métricas de rendimiento en tiempo real, puede tomar medidas inmediatas sobre cualquier problema observado. Una vez que haya reducido las posibles áreas de su programa que pueden ser los culpables de los problemas de memoria, entonces puede sumergirse en el código y discutirlo con los otros contribuyentes de código para determinar aún más las líneas específicas de código que necesitan ser arregladas.

Rastrear la raíz de los problemas de fugas de memoria en sí mismo puede ser una tarea desalentadora. Arreglarlo es otra pesadilla, ya que necesitas entender realmente tu código. Si alguna vez se encuentra en esa posición, no busque más porque ScoutAPM es una herramienta APM competente que puede analizar constructivamente y optimizar el rendimiento de su aplicación. ScoutAPM le da una visión en tiempo real para que pueda localizar rápidamente & resolver los problemas antes de que sus clientes puedan detectarlos.

Módulos de perfil

Hay muchos módulos de Python útiles que puede utilizar para resolver los problemas de memoria, ya sea una fuga de memoria o su programa se bloquea debido al uso excesivo de memoria. Dos de los recomendados son:

  1. tracemalloc
  2. memory-profiler

Tenga en cuenta que sólo el módulo tracemalloc está incorporado, así que asegúrese de instalar primero el otro módulo si desea utilizarlo.

tracemalloc

De acuerdo con la documentación de Python (3.9.0) para tracemalloc, el uso de este módulo puede proporcionarle la siguiente información:

  • Regreso donde se asignó un objeto.
  • Estadísticas sobre los bloques de memoria asignados por nombre de archivo y por número de línea: tamaño total, número y tamaño medio de los bloques de memoria asignados.
  • Calcular la diferencia entre dos instantáneas para detectar fugas de memoria.

Un primer paso recomendado que debe hacer para determinar el origen del problema de memoria es mostrar primero los archivos que asignan más memoria. Usted puede hacer esto fácilmente usando el primer ejemplo de código mostrado en la documentación.

Sin embargo, esto no significa que los archivos que asignan una pequeña cantidad de memoria no crecerán indefinidamente para causar fugas de memoria en el futuro.

memory_profiler

Este módulo es divertido. He trabajado con esto y es un favorito personal porque proporciona la opción de simplemente añadir el decorador @profile a cualquier función que desee investigar. La salida dada como resultado es también muy fácil de entender.

Otra razón que hace de este mi favorito personal es que este módulo le permite trazar un gráfico de uso de memoria basado en el tiempo. A veces, usted simplemente necesita una comprobación rápida para ver si el uso de la memoria sigue aumentando indefinidamente o no. Esta es la solución perfecta para eso ya que no necesita hacer un perfil de memoria línea por línea para confirmarlo. Simplemente puede observar el gráfico trazado después de dejar que el perfilador se ejecute durante un tiempo determinado. Aquí hay un ejemplo de la gráfica que sale –

De acuerdo con la descripción en la documentación del perfilador de memoria, este módulo de Python es para monitorear el consumo de memoria de un proceso, así como un análisis línea por línea de la misma para los programas de Python. Es un módulo puro de Python que depende de la librería psutil.

Recomiendo la lectura de este blog de Medium para profundizar en el uso de memory-profiler. Allí también aprenderás a utilizar otro módulo de Python, muppy (el más reciente es muppy3).

Mejores prácticas para mejorar el rendimiento del código de Python

Basta de todos los detalles sobre la gestión de la memoria. Ahora exploremos algunos de los buenos hábitos para escribir código Python eficiente en cuanto a memoria.

Aprovechar las bibliotecas de Python y las funciones incorporadas

Sí, este es un buen hábito que puede pasarse por alto con bastante frecuencia. Python tiene un apoyo de la comunidad sin igual y esto se refleja en las abundantes bibliotecas de Python disponibles para casi cualquier propósito, que van desde las llamadas a la API a la ciencia de datos.

Si hay una biblioteca de Python por ahí que le permite hacer lo mismo que lo que ya ha implementado, lo que puede hacer es comparar el rendimiento de su código cuando se utiliza la biblioteca en comparación con cuando se utiliza su código personalizado. Lo más probable es que las bibliotecas de Python (especialmente las más populares) sean más eficientes en cuanto a memoria que tu código porque se mejoran continuamente en base a los comentarios de la comunidad. ¿Preferirías confiar en un código creado de la noche a la mañana o en uno que ha sido rigurosamente mejorado durante un largo período?

Lo mejor de todo es que las bibliotecas de Python te ahorrarán muchas líneas de código, así que ¿por qué no?

No usar «+» para concatenar cadenas

En algún momento, todos hemos sido culpables de concatenar cadenas usando el operador «+» porque parece muy fácil.

Ten en cuenta que las cadenas son inmutables. Por lo tanto, cada vez que se añade un elemento a una cadena con el operador «+», Python tiene que crear una nueva cadena con una nueva asignación de memoria. Con cadenas más largas, la ineficiencia de la memoria del código será más pronunciada.

Usando itertools para bucles eficientes

Los bucles son una parte esencial de la automatización de cosas. A medida que continuamos usando bucles cada vez más, eventualmente nos encontraremos con que tenemos que usar bucles anidados, que son conocidos por ser ineficientes debido a su alta complejidad de ejecución.

Aquí es donde el módulo itertools viene al rescate. Según la documentación de itertools de Python, «El módulo estandariza un conjunto básico de herramientas rápidas y eficientes en memoria que son útiles por sí mismas o en combinación. En otras palabras, el módulo itertools permite hacer bucles eficientes en memoria deshaciéndose de los bucles innecesarios. Curiosamente, el módulo itertools se llama una joya, ya que permite componer soluciones elegantes para una miríada de problemas.

Estoy bastante seguro de que vas a trabajar con al menos un bucle en tu próxima pieza de código, así que trata de implementar itertools entonces!

Recapitulación y pensamientos finales

Aplicar buenos hábitos de gestión de memoria en Python no es para el programador casual. Si normalmente te apañas con scripts sencillos, no deberías encontrarte con problemas relacionados con la memoria en absoluto. Gracias al hardware y al software que siguen experimentando un rápido avance mientras lees esto, el modelo base de casi cualquier dispositivo que existe, independientemente de sus marcas, debería ejecutar los programas cotidianos sin problemas. La necesidad de un código eficiente en memoria sólo comienza a mostrarse cuando se empieza a trabajar en una gran base de código, especialmente para producción, donde el rendimiento es clave.

Sin embargo, esto no sugiere que la gestión de la memoria en Python sea un concepto difícil de entender, ni significa que no sea importante. Esto se debe a que el énfasis en el rendimiento de las aplicaciones está creciendo cada día. Un día, ya no será una mera cuestión de «hacer». En su lugar, los desarrolladores competirán por ofrecer una solución que no sólo sea capaz de resolver con éxito las necesidades de los clientes, sino que además lo haga con una velocidad vertiginosa y con un mínimo de recursos.