Gerenciamento de Memória

Diferentemente de linguagens de programação como C/C++, o MicroPython oculta os detalhes do gerenciamento de memória do desenvolvedor ao oferecer suporte ao gerenciamento automático de memória. O gerenciamento automático de memória é uma técnica usada por sistemas operacionais ou aplicações para gerenciar automaticamente a alocação e a desalocação de memória. Isso elimina desafios como esquecer de liberar a memória alocada a um objeto. O gerenciamento automático de memória também evita o problema crítico de usar memória que já foi liberada. O gerenciamento automático de memória assume muitas formas, sendo uma delas a coleta de lixo (GC, garbage collection).

O coletor de lixo geralmente tem duas responsabilidades;

  1. Alocar novos objetos na memória disponível.

  2. Liberar memória não utilizada.

Existem muitos algoritmos de GC, mas o MicroPython usa a política Mark and Sweep para gerenciar a memória. Esse algoritmo tem uma fase de marcação que percorre o heap marcando todos os objetos vivos, enquanto a fase de varredura percorre o heap recuperando todos os objetos não marcados.

A funcionalidade de coleta de lixo no MicroPython está disponível por meio do módulo embutido gc:

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

Mesmo quando gc.disable() é invocado, a coleta pode ser disparada com gc.collect().

Memória do MicroPython a partir de código C

É necessário ter consciência do coletor de lixo ao escrever código C que aloca memória do “heap do Python” (ou seja, funções m_malloc(), m_malloc0(), m_free(), etc).

A fase de marcação do coletor de lixo procura por ponteiros vivos para a memória do heap a partir das seguintes raízes:

  • A pilha do runtime principal do Python (ou do REPL).

  • As pilhas de cada “thread do Python”, para portas que implementam threads do Python sobre threads ou tarefas nativas do sistema operacional.

  • Os “ponteiros raiz” definidos em código C usando a macro MP_REGISTER_ROOT_POINTER. Esta é a maneira recomendada de ter ponteiros com escopo estático para o heap do Python.

  • Alocações rastreadas feitas com as funções m_tracked_calloc(), m_tracked_realloc e m_tracked_free(). Essas funções especiais permitem alocar um bloco de memória que é sempre considerado vivo pelo coletor de lixo. Semelhante à alocação de memória em C, essa memória só é liberada ao chamar m_tracked_free() ou por meio de soft reset. Há um pequeno custo de uso de memória e de tempo de execução para cada alocação rastreada. Esse recurso não é habilitado por padrão em todas as portas.

O coletor de lixo então varre e marca recursivamente toda a memória apontada pelos ponteiros raiz, até que todos os endereços sejam esgotados. Isso é suficiente para encontrar todos os objetos Python que ainda estão em uso pelo runtime do MicroPython.

No entanto, a seguinte memória não será varrida pelo coletor de lixo e poderia ser liberada prematuramente:

  • Variáveis C estáticas ou globais que contêm ponteiros para a memória do heap.

  • Ponteiros que não apontam para a “cabeça” de um buffer alocado (ou seja, para o endereço exato retornado por m_malloc()), mas em vez disso para um endereço dentro do buffer alocado (por exemplo, um ponteiro para uma struct aninhada). Por motivos de desempenho, o coletor de lixo não marca o buffer envolvente nesses casos.

  • A pilha de qualquer thread ou tarefa do RTOS que não esteja executando código Python ou registrada manualmente como uma “thread do Python” (para portas que oferecem suporte a threads ou tarefas nativas).

Maneiras de evitar use-after-free nesses cenários:

  • Usar a API de alocação rastreada m_tracked_calloc(), m_tracked_realloc() e m_tracked_free().

  • Registrar um ponteiro raiz (veja acima), em vez de armazenar um ponteiro em uma variável estática.

  • Reestruturar o código, por exemplo, tendo uma API onde o código Python inicializa um objeto Python singleton (implementado em C) que contém todos os ponteiros relevantes, em vez de mantê-los em variáveis estáticas.

Nota

Soft Reset sempre limpa o heap do Python e libera toda a memória. É importante não manter nenhum ponteiro para o heap após um soft reset, pois eles se tornarão ponteiros pendentes (dangling) para memória liberada.

Algumas portas também oferecem suporte a um “heap de C” (veja Alocação dinâmica de memória em C), caso em que você pode alocar memória que permanecerá válida ao longo de um soft reset chamando funções C padrão como malloc, etc.

O modelo de objetos

Todos os objetos do MicroPython são referenciados pelo tipo de dado mp_obj_t. Esse tipo geralmente tem o tamanho de uma palavra (word), ou seja, o mesmo tamanho de um ponteiro na arquitetura de destino, e normalmente pode ser de 32 bits (STM32, RP2, nRF, Unix x86) ou de 64 bits (Unix x64). Ele também pode ser maior que o tamanho de uma palavra para certas representações de objetos; por exemplo, OBJ_REPR_D tem um mp_obj_t de 64 bits em uma arquitetura de 32 bits.

Um mp_obj_t representa um objeto do MicroPython, por exemplo um inteiro, float, tipo, dict ou instância de classe. Alguns objetos, como booleanos e inteiros pequenos, têm seu valor armazenado diretamente no valor de mp_obj_t e não exigem memória adicional. Outros objetos têm seu valor armazenado em outro lugar na memória (por exemplo, no heap gerenciado pela coleta de lixo) e seu mp_obj_t contém um ponteiro para essa memória. Uma parte do mp_obj_t é a tag, que indica qual é o tipo do objeto.

Veja py/mpconfig.h para os detalhes específicos das representações disponíveis.

Tagging de ponteiros

Como os ponteiros estão alinhados a palavras (word-aligned), quando são armazenados em um mp_obj_t os bits inferiores desse identificador de objeto serão zero. Por exemplo, em uma arquitetura de 32 bits os 2 bits inferiores serão zero:

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

Esses bits são reservados para o propósito de armazenar uma tag. A tag armazena informações extras, em vez de introduzir um novo campo para armazenar essas informações no objeto, o que poderia ser ineficiente. No MicroPython a tag indica se estamos lidando com um inteiro pequeno, uma string internada (pequena) ou um objeto concreto, e semânticas diferentes se aplicam a cada um deles.

Para inteiros pequenos o mapeamento é o seguinte:

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

Onde os asteriscos contêm o valor inteiro propriamente dito. Para uma string internada ou um objeto imediato (por exemplo, True) o layout do valor de mp_obj_t é, respectivamente:

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

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

Já um objeto concreto que não é nenhum dos anteriores assume a forma:

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

Os asteriscos aqui correspondem ao endereço do objeto concreto na memória.

Alocação de objetos

O valor de um inteiro pequeno é armazenado diretamente no mp_obj_t e será alocado no próprio local (in-place), não no heap nem em outro lugar. Dessa forma, a criação de inteiros pequenos não afeta o heap. O mesmo vale para strings internadas que já têm seus dados textuais armazenados em outro lugar, e para valores imediatos como None, False e True.

Todo o resto que é um objeto concreto é alocado no heap, e sua estrutura de objeto é tal que um campo é reservado no cabeçalho do objeto para armazenar o tipo do objeto.

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

A menor unidade de alocação do heap é um bloco, que tem o tamanho de quatro palavras de máquina (16 bytes em uma máquina de 32 bits, 32 bytes em uma máquina de 64 bits). Outra estrutura também alocada no heap rastreia a alocação de objetos em cada bloco. Essa estrutura é chamada de bitmap.

../_images/bitmap.png

O bitmap rastreia se um bloco está “livre” ou “em uso” e usa dois bits para rastrear esse estado para cada bloco.

O coletor de lixo mark-sweep gerencia os objetos alocados no heap e também utiliza o bitmap para marcar objetos que ainda estão em uso. Veja py/gc.c para a implementação completa desses detalhes.

Alocação: layout do heap

O heap é organizado de modo que consiste em blocos dentro de pools. Um bloco pode ter diferentes propriedades:

  • ATB(allocation table byte): Se definido, então o bloco é um bloco normal

  • FREE: Bloco livre

  • HEAD: Cabeça de uma cadeia de blocos

  • TAIL: Na cauda de uma cadeia de blocos

  • MARK : Bloco de cabeça marcado

  • FTB(finaliser table byte): Se definido, então o bloco tem um finalizador