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.