Gestión de memoria

A diferencia de lenguajes de programación como C/C++, MicroPython oculta los detalles de la gestión de memoria al desarrollador mediante la gestión automática de memoria. La gestión automática de memoria es una técnica que utilizan los sistemas operativos o las aplicaciones para administrar automáticamente la asignación y liberación de memoria. Esto elimina problemas como olvidarse de liberar la memoria asignada a un objeto. La gestión automática de memoria también evita el problema crítico de usar memoria que ya ha sido liberada. La gestión automática de memoria adopta muchas formas, una de ellas es la recolección de basura (garbage collection, GC).

El recolector de basura suele tener dos responsabilidades;

  1. Asignar nuevos objetos en la memoria disponible.

  2. Liberar la memoria no utilizada.

Existen muchos algoritmos de GC, pero MicroPython utiliza la política Mark and Sweep para gestionar la memoria. Este algoritmo tiene una fase de marcado que recorre el montón (heap) marcando todos los objetos vivos, mientras que la fase de barrido recorre el montón recuperando todos los objetos no marcados.

La funcionalidad de recolección de basura en MicroPython está disponible a través del módulo integrado gc:

>>> x = 5
>>> x
5
>>> import gc
>>> gc.enable()
>>> gc.mem_alloc()
1312
>>> gc.mem_free()
2071392
>>> gc.collect()
19
>>> gc.disable()
>>>

Incluso cuando se invoca gc.disable(), la recolección puede activarse con gc.collect().

Memoria de MicroPython desde código C

Es necesario tener en cuenta el recolector de basura al escribir código C que asigna memoria del «montón de Python» (es decir, las funciones m_malloc(), m_malloc0(), m_free(), etc).

La fase de marcado del recolector de basura busca punteros vivos a la memoria del montón a partir de las siguientes raíces:

  • La pila del entorno de ejecución principal de Python (o REPL).

  • Las pilas de cada «hilo de Python», para los puertos que implementan los hilos de Python sobre los hilos o tareas nativas del sistema operativo.

  • Los «punteros raíz» definidos en el código C mediante la macro MP_REGISTER_ROOT_POINTER. Esta es la forma recomendada de tener punteros con ámbito estático hacia el montón de Python.

  • Las asignaciones rastreadas realizadas con las funciones m_tracked_calloc(), m_tracked_realloc y m_tracked_free(). Estas funciones especiales permiten asignar un bloque de memoria que el recolector de basura siempre considera vivo. De forma similar a la asignación de memoria en C, esta memoria solo se libera llamando a m_tracked_free() o mediante un reinicio en caliente. Cada asignación rastreada conlleva una pequeña sobrecarga de uso de memoria y de tiempo de ejecución. Esta característica no está habilitada de forma predeterminada en todos los puertos.

A continuación, el recolector de basura escanea y marca recursivamente toda la memoria a la que apuntan los punteros raíz, hasta que se agotan todas las direcciones. Esto es suficiente para encontrar todos los objetos de Python que el entorno de ejecución de MicroPython todavía está utilizando.

Sin embargo, la siguiente memoria no será escaneada por el recolector de basura y podría liberarse prematuramente:

  • Variables C estáticas o globales que contienen punteros a la memoria del montón.

  • Punteros que no apuntan al «inicio» de un búfer asignado (es decir, a la dirección exacta devuelta por m_malloc()), sino a una dirección dentro del búfer asignado (por ejemplo, un puntero a una estructura anidada). Por razones de rendimiento, el recolector de basura no marca el búfer contenedor en estos casos.

  • La pila de cualquier hilo o tarea RTOS que no esté ejecutando código de Python ni esté registrada manualmente como «hilo de Python» (para los puertos que admiten hilos o tareas nativas).

Formas de evitar el uso después de liberar (use-after-free) en estos escenarios:

  • Utilizar la API de asignación rastreada m_tracked_calloc(), m_tracked_realloc() y m_tracked_free().

  • Registrar un puntero raíz (véase más arriba), en lugar de almacenar un puntero en una variable estática.

  • Reestructurar el código, por ejemplo creando una API en la que el código de Python inicialice un objeto Python singleton (implementado en C) que contenga todos los punteros relevantes, en lugar de tenerlos en variables estáticas.

Nota

Reinicio en caliente siempre limpia el montón de Python y libera toda la memoria. Es importante no mantener ningún puntero al montón después de un reinicio en caliente, ya que se convertirán en punteros colgantes hacia memoria liberada.

Algunos puertos también admiten un «montón de C» (véase Asignación dinámica de memoria en C), en cuyo caso se puede asignar memoria que permanecerá válida tras un reinicio en caliente llamando a las funciones estándar de C malloc, etc.

El modelo de objetos

Todos los objetos de MicroPython se referencian mediante el tipo de dato mp_obj_t. Este suele tener el tamaño de una palabra (es decir, el mismo tamaño que un puntero en la arquitectura de destino), y normalmente puede ser de 32 bits (STM32, RP2, nRF, Unix x86) o de 64 bits (Unix x64). También puede ser mayor que el tamaño de una palabra para ciertas representaciones de objetos; por ejemplo, OBJ_REPR_D tiene un mp_obj_t de 64 bits en una arquitectura de 32 bits.

Un mp_obj_t representa un objeto de MicroPython, por ejemplo un entero, un float, un tipo, un dict o una instancia de clase. Algunos objetos, como los booleanos y los enteros pequeños, tienen su valor almacenado directamente en el valor mp_obj_t y no requieren memoria adicional. Otros objetos tienen su valor almacenado en otro lugar de la memoria (por ejemplo, en el montón gestionado por el recolector de basura) y su mp_obj_t contiene un puntero a esa memoria. Una parte de mp_obj_t es la etiqueta, que indica de qué tipo de objeto se trata.

Consulte py/mpconfig.h para conocer los detalles específicos de las representaciones disponibles.

Etiquetado de punteros

Dado que los punteros están alineados a palabra, cuando se almacenan en un mp_obj_t los bits inferiores de este identificador de objeto serán cero. Por ejemplo, en una arquitectura de 32 bits los 2 bits inferiores serán cero:

********|********|********|******00

Estos bits están reservados para almacenar una etiqueta. La etiqueta guarda información adicional en lugar de introducir un nuevo campo en el objeto para almacenar esa información, lo cual podría ser ineficiente. En MicroPython la etiqueta indica si estamos tratando con un entero pequeño, una cadena (pequeña) internada o un objeto concreto, y a cada uno de estos se le aplica una semántica diferente.

Para los enteros pequeños la correspondencia es la siguiente:

********|********|********|*******1

Donde los asteriscos contienen el valor entero real. Para una cadena internada o un objeto inmediato (por ejemplo, True) la disposición del valor mp_obj_t es, respectivamente:

********|********|********|*****010

********|********|********|*****110

Mientras que un objeto concreto que no es ninguno de los anteriores adopta la forma:

********|********|********|******00

Los asteriscos aquí corresponden a la dirección del objeto concreto en memoria.

Asignación de objetos

El valor de un entero pequeño se almacena directamente en el mp_obj_t y se asignará en el mismo lugar (in-place), no en el montón ni en otro sitio. De este modo, la creación de enteros pequeños no afecta al montón. De forma similar para las cadenas internadas que ya tienen sus datos textuales almacenados en otro lugar, y para los valores inmediatos como None, False y True.

Todo lo demás que sea un objeto concreto se asigna en el montón, y su estructura de objeto es tal que se reserva un campo en la cabecera del objeto para almacenar el tipo del objeto.

+++++++++++
+         +
+ type    + object header
+         +
+++++++++++
+         + object items
+         +
+         +
+++++++++++

La unidad de asignación más pequeña del montón es un bloque, que tiene un tamaño de cuatro palabras de máquina (16 bytes en una máquina de 32 bits, 32 bytes en una máquina de 64 bits). Otra estructura, también asignada en el montón, rastrea la asignación de objetos en cada bloque. Esta estructura se denomina mapa de bits (bitmap).

../_images/bitmap.png

El mapa de bits rastrea si un bloque está «libre» o «en uso» y utiliza dos bits para registrar este estado de cada bloque.

El recolector de basura mark-sweep gestiona los objetos asignados en el montón, y también utiliza el mapa de bits para marcar los objetos que todavía están en uso. Consulte py/gc.c para ver la implementación completa de estos detalles.

Asignación: disposición del montón

El montón está organizado de tal manera que consiste en bloques agrupados en pools. Un bloque puede tener diferentes propiedades:

  • ATB (allocation table byte, byte de la tabla de asignación): Si está activado, entonces el bloque es un bloque normal

  • FREE: Bloque libre

  • HEAD: Cabecera de una cadena de bloques

  • TAIL: En la cola de una cadena de bloques

  • MARK : Bloque de cabecera marcado

  • FTB (finaliser table byte, byte de la tabla de finalizadores): Si está activado, entonces el bloque tiene un finalizador