ADRIANOLAUREANO← Artigos
RUNTIMEMEMÓRIARUSTPULSO

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.

ARTIGO 15 · A VIDA E A MORTE DOS OBJETOS

2fases

3tipos de raiz

1heap gerenciado

MAPA DE ALCANÇABILIDADE · O COLETOR ENXERGA CAMINHOS
RAÍZESSTACKGLOBAISFRAMES
AclosureBambienteCobjetoDórfãoEciclo
alcançável · permaneceinalcançável · sweep

VOCABULÁRIO VISUAL

Antes de avançar, conheça as peças deste capítulo.

01Garbage collectorruntime que recupera objetos inalcançáveis
02Raizreferência viva onde a busca começa
03Grafoobjetos ligados por referências
04Markfase que marca tudo que ainda é alcançável
05Sweepfase que libera objetos não marcados
06Stop-the-worldpausa breve enquanto o heap é examinado
Você não precisa memorizar agora. Este mapa existe para consultar sempre que um termo reaparecer.
01 /
A dívida que trouxemos de “Como closures funcionam por dentro: quando a stack não basta”
A closure sobreviveu à stack; agora precisamos decidir quando ela deixa de existir.

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?
A pergunta deste capítulo

Se o programador não chama free, como o runtime prova que nenhum caminho do programa consegue mais alcançar um objeto?

O CICLO QUE ESTAMOS ENCERRANDO
10Bytecodea Pulso ganha uma VM
12NativoLLVM fala com a CPU
14Closuresdados escapam da stack
15GCo runtime recupera memória

O garbage collector não é um assunto solto: ele é a consequência direta de termos criado objetos com tempo de vida dinâmico.

02 /
Memória Não Some: Ela Apenas Deixa de Ter Dono
Vazamento, ponteiro pendente e dupla liberação são falhas diferentes.

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.

FalhaO que aconteceuEfeito possível
Memory leakobjeto morto nunca foi liberadouso de memória cresce
Use-after-freeobjeto foi usado após freedados corrompidos ou invasão
Double freeo mesmo bloco foi devolvido duas vezescorrupção do heap
Para quem está começando

“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.

03 /
Quatro Formas de Decidir Quando um Objeto Morre
Manual, contagem de referências, ownership e coleta traçadora resolvem contratos diferentes.
EstratégiaDecisãoPonto forteLimite
free manualo programador escolhecontrole diretoerros de segurança
Reference countingcontador chega a zeroliberação previsívelciclos sobrevivem
Ownershipo compilador prova o donosem pausa gerallinguagem e tipos mais complexos
Tracing GCobjeto não é alcançávelcoleta ciclos naturalmenteruntime 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.

CONTAGEM NÃO É ALCANÇABILIDADE
CONTAGEM DE REFERÊNCIASA ↔ Bcada objeto mantém o outro com contador 1o ciclo não é liberado
GC TRAÇADORraízes → nadaA e B não são alcançáveis pelo programao ciclo inteiro pode ser varrido
04 /
Pare de Imaginar uma Lista: o Heap é um Grafo
Objetos são nós; referências são arestas; variáveis ativas apontam para pontos de entrada.

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.

05 /
Raízes: Onde o Coletor Começa a Procurar
O GC precisa de uma fronteira confiável entre o programa ativo e o heap.

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.

Preciso ou conservador?
  • 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 Value possui a variante Object, 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,
}
06 /
Mark-and-Sweep: Marcar o Vivo, Varrer o Morto
Duas fases pequenas transformam alcance em memória reutilizável.
  1. Mark: comece nas raízes, marque cada objeto visitado e percorra suas referências.
  2. Sweep: percorra todos os slots; mantenha os marcados e libere os não marcados.
  3. Reset: apague as marcas dos sobreviventes para preparar a próxima coleta.
UMA COLETA COMPLETA
01Pararobter uma visão estável
02Marcarcaminhar desde as raízes
03Varrerrecuperar não marcados
04Continuarreusar slots livres

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.

07 /
A Representação: Handles, Slots e uma Marca por Objeto
Uma referência estável é mais segura para o laboratório do que expor ponteiros brutos.
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.

Por que não mover os objetos?

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.

08 /
Alocando e Reutilizando Espaços Vazios
Coletar só ajuda se a próxima alocação puder aproveitar a memória recuperada.
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.

09 /
A Fase Mark: Percorrendo sem Entrar em Loop
A própria marca funciona como memória da busca.
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.

Uma preocupação de produção

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.

10 /
A Fase Sweep: Transformando Ausência de Marca em Espaço Livre
Sobreviventes perdem a marca; mortos perdem o slot.
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.

11 /
Ligando o Coletor à VM da Pulso
O algoritmo só fica correto quando todas as raízes reais entram na coleta.
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.

12 /
Quando o GC Deve Rodar?
A política de disparo equilibra memória ocupada e tempo gasto coletando.

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íticaMemóriaCPUUso típico
a cada alocaçãomínimamuito altateste de correção
limiar crescentecontroladamoderadaruntime didático
geracionaleficientecoletas curtas frequentesVMs maduras
13 /
Experimento Completo: uma Closure Viva e um Objeto Morto
Vamos acompanhar o heap antes e depois da coleta.
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
ANTES E DEPOIS DO MARK-AND-SWEEP
ANTES3 slots ocupadosglobal → closure → lembrançatemporário isolado3 objetos vivos fisicamente
DEPOIS2 vivos + 1 livreclosure e lembrança marcadasslot temporário reutilizável1 objeto recuperado
14 /
Testar um GC é Provar que Ele Libera — e que Ele Não Libera Demais
Os dois lados da segurança precisam aparecer na suíte.
Matriz mínima de testes
  • 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.

15 /
O que Nosso Coletor Ainda Não Faz
Entender o limite do modelo evita confundir laboratório com runtime de produção.
RecursoPor que existePróxima evolução
Compactaçãoreduz fragmentaçãomover objetos e corrigir referências
GC geracionalobjetos jovens morrem maisnursery e remembered set
Barreira de escritaregistra referências modificadasinstrumentar stores
Incremental/concurrentereduz pausas longasintercalar marcação e programa
Finalizerslibera recursos externosfila 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.

16 /
Laboratório: Faça o Coletor Revelar Suas Decisões
Um GC deixa de parecer magia quando imprime o grafo que percorreu.

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
Experimentos em ordem
  1. Remova a global soma_dez e confirme que três objetos são coletados.
  2. Coloque o temporário na stack e veja que ele sobrevive.
  3. Crie A apontando para B e B apontando para A; depois remova a raiz.
  4. Imprima cada handle visitado durante mark.
  5. Substitua a recursão por uma gray stack iterativa.
  6. Adicione next_gc e dispare coleta automaticamente.
17 /
O Ciclo Está Fechado: do Texto à Vida e Morte dos Objetos
Agora a Pulso possui uma história contínua, não uma coleção de demonstrações.
O que construímos

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.

18 /
Perguntas Frequentes
O que costuma ficar depois da primeira coleta.
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”.

Baixar código final deste artigo (.zip)Rust · Pulso · heap · mark-and-sweep · testes

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.