Quem Limpa a Memória?
Construindo um Garbage Collector do Zero em Rust
No artigo anterior, sobre como closures funcionam por dentro, ensinamos closures a guardar lembranças no heap. Funcionou — mas cada lembrança ficou ali para sempre. Neste capítulo final vamos construir o componente que descobre quais objetos ainda pertencem ao programa e devolve ao sistema a memória dos que já morreram.
VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
No artigo “Como closures funcionam por dentro: quando a stack não basta”, uma closure passou a ser o par { ponteiro_da_função, ponteiro_do_ambiente }. O ambiente foi criado com malloc porque precisava sobreviver ao retorno da função criadora. O problema é que nunca chamamos free.
fn cria_somador(n) {
retorne fn(x) { retorne x + n; };
}
var soma_dez = cria_somador(10); // ambiente { n: 10 } no heap
soma_dez = nulo; // quem libera aquele ambiente?
Se o programador não chama free, como o runtime prova que nenhum caminho do programa consegue mais alcançar um objeto?
O garbage collector não é um assunto solto: ele é a consequência direta de termos criado objetos com tempo de vida dinâmico.
O heap é uma região usada para dados cujo tempo de vida não acompanha uma única chamada. O alocador entrega um bloco; alguém precisa devolvê-lo. Esquecer de devolver causa vazamento. Devolver cedo demais cria um dangling pointer, uma referência para memória que já pode ter sido reutilizada. Devolver duas vezes corrompe o alocador.
| Falha | O que aconteceu | Efeito possível |
|---|---|---|
| Memory leak | objeto morto nunca foi liberado | uso de memória cresce |
| Use-after-free | objeto foi usado após free | dados corrompidos ou invasão |
| Double free | o mesmo bloco foi devolvido duas vezes | corrupção do heap |
“Objeto morto” não significa que seus bytes desapareceram. Significa apenas que o programa não possui mais um caminho válido para encontrá-lo. O GC transforma essa propriedade lógica em liberação física.
| Estratégia | Decisão | Ponto forte | Limite |
|---|---|---|---|
free manual | o programador escolhe | controle direto | erros de segurança |
| Reference counting | contador chega a zero | liberação previsível | ciclos sobrevivem |
| Ownership | o compilador prova o dono | sem pausa geral | linguagem e tipos mais complexos |
| Tracing GC | objeto não é alcançável | coleta ciclos naturalmente | runtime e pausas |
A Pulso ainda não possui um sistema de tipos capaz de expressar ownership. Contagem de referências pareceria simples, mas closures podem apontar umas para as outras e formar ciclos. Por isso construiremos um coletor traçador: ele parte de referências sabidamente vivas e percorre o grafo.
Uma closure referencia seu ambiente. O ambiente pode guardar outra closure, um par, uma string ou futuramente uma lista. Essas referências transformam o heap em um grafo dirigido. Não basta olhar um objeto isoladamente: ele pode estar vivo porque outro objeto vivo aponta para ele.
RAÍZES
stack ───────┐
globais ─────┼──► Closure A ───► Ambiente B ───► Objeto C
registradores┘ │
└────────► Objeto D
Objeto E ───► Objeto F // ilha sem caminho partindo das raízes
A, B, C e D permanecem. E e F são uma ilha inalcançável e podem ser liberados, mesmo que apontem um para o outro.
Uma raiz é uma referência considerada viva sem precisar ser encontrada dentro de outro objeto do heap. Na mini-VM da Pulso, as raízes naturais são os valores na stack, as variáveis globais e as closures dos frames ativos. Em um runtime nativo também podem existir referências em registradores e memória controlada pelo código gerado.
- Um GC preciso sabe exatamente quais palavras são referências.
- Um GC conservador trata qualquer número parecido com endereço como possível ponteiro.
- Como nossa
Valuepossui a varianteObject, a Pulso pode ser precisa: números nunca são confundidos com referências.
pub enum Value {
Number(f64),
Object(Handle), // o runtime sabe que esta variante aponta para o heap
Nil,
}
- Mark: comece nas raízes, marque cada objeto visitado e percorra suas referências.
- Sweep: percorra todos os slots; mantenha os marcados e libere os não marcados.
- Reset: apague as marcas dos sobreviventes para preparar a próxima coleta.
Nosso primeiro coletor será stop-the-world: a VM interrompe a execução enquanto coleta. Isso não quer dizer que todo GC funcione assim o tempo inteiro; significa apenas que escolhemos o contrato mais fácil de provar antes de introduzir concorrência.
pub struct Handle(pub usize);
struct Slot {
object: Object,
marked: bool,
}
pub struct Heap {
slots: Vec<Option<Slot>>,
free: Vec<usize>,
live: usize,
}
O Handle guarda o índice de um slot. Um slot vazio entra em free e pode ser reutilizado pela próxima alocação. Assim conseguimos enxergar alocação e coleta sem depender dos detalhes internos do alocador do sistema.
Mark-and-sweep não compactador mantém os sobreviventes no mesmo slot. Portanto os handles continuam válidos. Um GC compactador precisaria atualizar todas as referências ou usar uma camada de indireção.
pub fn allocate(&mut self, object: Object) -> Handle {
let slot = Slot { object, marked: false };
self.live += 1;
if let Some(index) = self.free.pop() {
self.slots[index] = Some(slot);
Handle(index)
} else {
self.slots.push(Some(slot));
Handle(self.slots.len() - 1)
}
}
A lista livre evita que o vetor cresça para sempre. Em um runtime real, o heap costuma trabalhar com blocos, classes de tamanho ou páginas; aqui cada slot guarda um objeto lógico para deixar o algoritmo observável.
fn mark_value(&mut self, value: Value) {
let Value::Object(handle) = value else { return };
let Some(slot) = self.slots[handle.0].as_mut() else { return };
if slot.marked { return; } // já visitado: encerra ciclos
slot.marked = true;
let children = match &slot.object {
Object::Pair(a, b) => vec![*a, *b],
Object::Closure { captures, .. } => captures.clone(),
};
for child in children { self.mark_value(child); }
}
Marcar antes de visitar os filhos é decisivo. Se A aponta para B e B aponta para A, a segunda chegada a A encontra marked = true e para. Conceitualmente, estamos executando uma busca em profundidade no grafo.
Recursão profunda pode estourar a stack nativa. Coletores reais costumam usar uma gray stack, uma pilha explícita de objetos descobertos e ainda não examinados.
for (index, entry) in self.slots.iter_mut().enumerate() {
let Some(slot) = entry else { continue };
if slot.marked {
slot.marked = false; // sobrevive e prepara o próximo ciclo
} else {
*entry = None; // executa Drop em Object
self.free.push(index); // slot poderá ser reutilizado
self.live -= 1;
}
}
Em nosso projeto, substituir o slot por None faz o Rust executar Drop sobre o objeto. Em uma VM baseada em blocos brutos, a varredura devolveria o bloco a uma free list ou ao alocador.
pub struct Vm {
pub heap: Heap,
pub stack: Vec<Value>,
pub globals: HashMap<String, Value>,
}
impl Vm {
pub fn roots(&self) -> Vec<Value> {
self.stack.iter().copied()
.chain(self.globals.values().copied())
.collect()
}
pub fn collect_garbage(&mut self) -> GcReport {
let roots = self.roots();
self.heap.collect(roots)
}
}
Copiamos a lista de raízes antes de emprestar o heap mutavelmente. Isso satisfaz o borrow checker e, mais importante, fotografa o conjunto inicial da coleta. Se omitíssemos globais ou um frame ativo, o GC poderia liberar um objeto ainda usado: um bug muito pior que vazamento.
Rodar depois de cada alocação é correto, porém caro. Nunca rodar volta ao vazamento. Uma política inicial pode disparar quando o número de objetos alcançar o dobro do tamanho observado após a última coleta.
if heap.live_objects() >= next_gc {
let report = vm.collect_garbage();
next_gc = (report.after * 2).max(64);
}
| Política | Memória | CPU | Uso típico |
|---|---|---|---|
| a cada alocação | mínima | muito alta | teste de correção |
| limiar crescente | controlada | moderada | runtime didático |
| geracional | eficiente | coletas curtas frequentes | VMs maduras |
let lembranca = heap.allocate(Pair(10, nil)); // slot 0
let somador = heap.allocate(Closure([lembranca])); // slot 1
let temporario = heap.allocate(Pair(99, nil)); // slot 2
globals["soma_dez"] = somador; // raiz → slot 1 → slot 0
collect_garbage(); // slot 2 não é alcançável
// antes: 3 objetos
// depois: 2 objetos; 1 liberado
- objeto diretamente presente na stack sobrevive;
- filhos alcançados por closure também sobrevivem;
- ilha sem raiz é completamente recuperada;
- ciclo sem raiz é coletado;
- ciclo com raiz não provoca recursão infinita;
- slot liberado é reutilizado;
- duas coletas seguidas não preservam uma marca antiga.
Um teste que apenas observa “liberou alguma coisa” é insuficiente. Precisamos também verificar handles sobreviventes e caminhos indiretos. O erro mais perigoso em um GC é um falso morto: o coletor não enxergou uma raiz legítima.
| Recurso | Por que existe | Próxima evolução |
|---|---|---|
| Compactação | reduz fragmentação | mover objetos e corrigir referências |
| GC geracional | objetos jovens morrem mais | nursery e remembered set |
| Barreira de escrita | registra referências modificadas | instrumentar stores |
| Incremental/concurrente | reduz pausas longas | intercalar marcação e programa |
| Finalizers | libera recursos externos | fila e semântica cuidadosa |
Também não estamos coletando o heap do executável LLVM do artigo “Como closures funcionam por dentro: quando a stack não basta”. Para isso, o código nativo precisaria registrar roots e alocações no runtime da Pulso. O projeto deste capítulo implementa primeiro o contrato completo dentro de uma VM pequena, onde cada referência é observável.
O projeto para download contém uma biblioteca, um executável demonstrativo e testes. Rode primeiro sem alterar nada; depois faça cada experimento isoladamente.
cargo run
cargo test
# resultado esperado do exemplo
antes: 3 objetos
depois: 2 objetos; 1 liberado
- Remova a global
soma_deze confirme que três objetos são coletados. - Coloque o temporário na stack e veja que ele sobrevive.
- Crie A apontando para B e B apontando para A; depois remova a raiz.
- Imprima cada handle visitado durante
mark. - Substitua a recursão por uma gray stack iterativa.
- Adicione
next_gce dispare coleta automaticamente.
Começamos com texto, criamos tokens, AST, bytecode e uma VM. Descemos até CPU e LLVM, acrescentamos chamadas e closures e, por fim, demos ao runtime a capacidade de encerrar o tempo de vida dos objetos. Novos temas — structs, arrays, módulos, otimizações e JIT — podem nascer daqui, mas este arco está completo.
Garbage collector significa que vazamentos são impossíveis?
Não. Um programa pode manter referências para objetos que nunca mais usará. Para o GC eles continuam alcançáveis, portanto vivos. O coletor elimina memória inalcançável; ele não adivinha intenção.
O Rust já não gerencia memória sozinho?
Rust gerencia os objetos que implementam nosso runtime. Mas estamos construindo a semântica de outra linguagem. Um Handle da Pulso pode continuar armazenado em estruturas Rust mesmo quando deixou de ser alcançável pelo programa Pulso. O GC decide a vida lógica dos objetos da linguagem convidada.
Por que mark-and-sweep consegue coletar ciclos?
Porque ele não pergunta quantas referências chegam ao objeto. Pergunta se existe um caminho partindo das raízes. Um ciclo isolado possui referências internas, mas nenhum caminho a partir do programa ativo.
Stop-the-world sempre causa travamentos perceptíveis?
Depende do tamanho do heap, da frequência e do algoritmo. Em heaps pequenos, a pausa pode ser irrelevante. Runtimes maduros usam gerações, coleta incremental ou concorrente para limitar latência.
Por que usamos handles em vez de ponteiros?
Handles tornam os exemplos seguros e observáveis: um índice identifica um slot e não exige unsafe. Ponteiros são possíveis, mas misturariam o algoritmo do GC com detalhes de alinhamento e alocação nativa.
Qual é a próxima evolução recomendada?
Para aprofundar memória, implemente uma gray stack iterativa e depois um GC geracional. Para ampliar a linguagem, structs e arrays são o próximo ciclo natural, agora já apoiados por um runtime que sabe rastrear objetos.
PROJETO COMPLETO · CÓDIGO DO CAPÍTULO
Continue exatamente de onde paramos.
O pacote contém o heap da Pulso, o coletor mark-and-sweep, um exemplo executável, testes de alcançabilidade e um roteiro de experimentos. Ele continua diretamente o ambiente de closures criado em “Como closures funcionam por dentro: quando a stack não basta”.