Stack, Heap e Garbage Collector: como a memória do seu processo funciona

No primeiro post da série eu falei que um processo tem um espaço de endereçamento próprio, e citei stack e heap como parte disso. Nesse post vou aprofundar o que cada um é, como as threads consomem essa memória, e o que acontece quando ela não é liberada.

Este artigo usa o modelo conceitual tradicional de stack e heap para explicar os conceitos. A implementação interna do CPython possui detalhes adicionais que foram omitidos para simplificar a explicação.

Stack

Stack é uma região de memória que funciona como uma pilha (LIFO, last in first out). Toda vez que uma função é chamada, um bloco é empilhado no topo. Esse bloco (chamado de stack frame) contém as variáveis locais da função, os parâmetros que ela recebeu, e o endereço de retorno pra onde o programa volta quando a função terminar.

Quando a função retorna, o stack frame é desempilhado e a memória é liberada automaticamente. Não precisa de nenhuma ação do programador.

def soma(a, b):
    resultado = a + b  # conceitualmente, 'resultado' vive no stack frame dessa chamada
    return resultado

def main():
    x = soma(3, 4)  # 'x' vive no stack frame de main()
    print(x)

main()

Quando main() chama soma(3, 4), um novo stack frame é empilhado. Quando soma retorna, o frame é removido e resultado deixa de existir.

O stack tem tamanho fixo. Se você consumir tudo (recursão infinita, por exemplo), o programa crasharia com um stack overflow. O Python lança um RecursionError antes disso acontecer, como uma trava de segurança pra evitar o crash real do SO.

def recursion_infinita():
    return recursion_infinita()

recursion_infinita()  # RecursionError: maximum recursion depth exceeded

Nota: no CPython, os frames de funções historicamente vivem no heap como objetos, embora funcionem logicamente como stack. A partir do Python 3.11 houve otimizações significativas nisso.

Cada thread tem seu próprio stack. Isso é uma das coisas que falamos no primeiro post: threads compartilham heap, mas cada uma tem stack independente. Se você cria 1000 threads, são 1000 stacks separados na memória.

Heap

Heap é a região de memória usada pra alocação dinâmica. Tudo que tem tamanho desconhecido em tempo de compilação ou que precisa sobreviver além do escopo de uma função vai pro heap.

No Python, quase tudo vai pro heap. Quando você cria uma lista, um dicionário, um objeto, o interpretador aloca espaço no heap pra armazenar esses dados. A variável no stack é só uma referência (um ponteiro) pro objeto real que está no heap.

def cria_lista():
    numeros = [1, 2, 3, 4, 5]  # conceitualmente, 'numeros' é uma referência no stack
    return numeros              # o objeto lista está no heap

resultado = cria_lista()
print(resultado)  # [1, 2, 3, 4, 5] - o objeto no heap sobreviveu

A função cria_lista retornou e seu stack frame foi destruído. Mas a lista continua existindo no heap porque resultado ainda aponta pra ela.

A diferença fundamental: a memória do stack é gerenciada automaticamente pela estrutura de chamadas de função. A memória do heap precisa ser liberada de alguma forma. Em C, o programador faz isso manualmente com free(). Em Python, quem faz isso é o garbage collector.

Como as threads consomem memória

Cada thread tem seu stack próprio, mas todas as threads de um processo compartilham o heap. Em CPython, a execução de bytecode também é coordenada pelo GIL (que falamos no post anterior). Isso é o que torna a comunicação entre threads fácil (qualquer thread pode acessar qualquer objeto no heap) e ao mesmo tempo perigosa (duas threads podem modificar o mesmo objeto ao mesmo tempo).

import threading

dados_compartilhados = []  # esse objeto está no heap

def adiciona(item):
    dados_compartilhados.append(item)  # todas as threads acessam o mesmo heap

t1 = threading.Thread(target=adiciona, args=("a",))
t2 = threading.Thread(target=adiciona, args=("b",))
t1.start(); t2.start()
t1.join(); t2.join()

print(dados_compartilhados)  # ["a", "b"] ou ["b", "a"]

A lista dados_compartilhados está no heap. As duas threads acessam ela diretamente. Cada thread tem seu próprio stack com o parâmetro item, mas o append opera no mesmo objeto.

Nota: o .append() em listas é uma operação atômica no CPython por causa do GIL. A lista não vai se corromper, mas a ordem dos elementos é imprevisível.

Memory leak

Um memory leak acontece quando um objeto no heap não é mais necessário mas a memória continua ocupada. O programa mantém memória alocada desnecessariamente, e o consumo cresce sem parar.

Em linguagens com gerenciamento manual (C, C++), isso acontece quando o programador esquece de chamar free(). Em Python, acontece quando existem referências que impedem o garbage collector de limpar o objeto.

import tracemalloc

tracemalloc.start()

cache = {}

def processa_request(request_id):
    # simula processamento que gera dados
    resultado = {"id": request_id, "dados": "x" * 10000}
    cache[request_id] = resultado  # armazena no cache e nunca remove

# simula 10000 requests
for i in range(10000):
    processa_request(i)

snapshot = tracemalloc.take_snapshot()
stats = snapshot.statistics("lineno")

print(f"Itens no cache: {len(cache)}")
for stat in stats[:3]:
    print(stat)
Itens no cache: 10000
memory_examples.py:37: size=97.7 MiB, count=29996, average=3414 B
memory_examples.py:40: size=457 KiB, count=9743, average=48 B
memory_examples.py:38: size=288 KiB, count=1, average=288 KiB

97.7 MiB alocados numa única linha. O dicionário cache cresce a cada request e nunca é limpo. Cada entrada contém uma string de 10000 caracteres. Depois de 10000 requests, o cache está ocupando memória que nunca será liberada enquanto o programa rodar. Isso é um memory leak.

Não é que o garbage collector falhou. As referências existem (o dicionário aponta pra cada objeto). O garbage collector só pode limpar objetos que ninguém mais referencia. Se a referência existe, o objeto fica.

Garbage Collector

O CPython usa duas estratégias pra gerenciar memória no heap.

A primeira é contagem de referência (reference counting). Cada objeto tem um contador que rastreia quantas variáveis apontam pra ele. Quando o contador chega a zero, o objeto é desalocado imediatamente.

import sys

a = [1, 2, 3]
print(sys.getrefcount(a))  # 2 (a variável 'a' + o argumento da função)

b = a
print(sys.getrefcount(a))  # 3 (a + b + argumento)

del b
print(sys.getrefcount(a))  # 2 de novo

A contagem de referência é simples e rápida. Mas tem um problema: referências circulares.

import gc

class Node:
    def __init__(self):
        self.ref = None

a = Node()
b = Node()
a.ref = b  # a aponta pra b
b.ref = a  # b aponta pra a

del a
del b
# os dois objetos ainda existem no heap
# cada um aponta pro outro, refcount nunca chega a zero

Depois do del a e del b, as variáveis não existem mais. Mas os objetos no heap ainda referenciam um ao outro. O refcount de cada um é 1. A contagem de referência sozinha nunca vai limpar isso.

A segunda estratégia é o garbage collector cíclico. Ele roda periodicamente e detecta grupos de objetos que se referenciam entre si mas não são acessíveis por nenhuma variável do programa. Quando encontra, desaloca todos.

gc.collect()  # força uma coleta

O CPython utiliza coleta geracional, priorizando objetos mais novos. A lógica é que a maioria dos objetos morre jovem (variáveis temporárias, resultados intermediários), então verificar os mais novos com mais frequência é mais eficiente.

Detectando memory leak com tracemalloc

import tracemalloc

tracemalloc.start()

# pega um snapshot antes
snapshot1 = tracemalloc.take_snapshot()

# código suspeito
dados = []
for i in range(100000):
    dados.append({"index": i, "payload": "x" * 1000})

# pega um snapshot depois
snapshot2 = tracemalloc.take_snapshot()

# compara os dois
stats = snapshot2.compare_to(snapshot1, "lineno")

print("Top 3 diferenças de alocação:")
for stat in stats[:3]:
    print(stat)
Top 3 diferenças de alocação:
memory_examples.py:62: size=18.3 MiB (+18.3 MiB), count=199999 (+199999), average=96 B
memory_examples.py:61: size=4675 KiB (+4675 KiB), count=99743 (+99743), average=48 B
memory_examples.py:58: size=768 B (+768 B), count=1 (+1), average=768 B

O tracemalloc compara dois momentos e mostra onde a memória cresceu. 18.3 MiB de diferença em uma única linha. Em um serviço rodando em produção, você pode tirar snapshots em intervalos e ver se alguma estrutura está crescendo sem parar.


Próximo post da série: escalonamento de CPU e como o SO decide qual processo roda.

Referências: ARPACI-DUSSEAU, R. e A. Operating Systems: Three Easy Pieces, Cap. 13. Documentação CPython: tracemalloc, gc, sys.