🤯 Como fiquei confuso — e aprendi a gostar do modelo de ownership
Quando comecei a estudar Rust, eu já era familiarizado com os dois extremos do espectro de gerenciamento de memória.
De um lado, o modelo de alocação e liberação manual do C, onde cada malloc
exige um free
correspondente.
Do outro, linguagens com garbage collection, como Java ou TypeScript, nas quais o runtime gerencia a memória sem precisar da minha intervenção direta.
Rust, porém, não se encaixava em nenhum desses dois mundos. Seu sistema de ownership e borrowing parecia um terceiro modelo — algo que, ao mesmo tempo, era automático e completamente determinístico.
Essas diferenças despertaram algumas perguntas:
- O
drop()
de Rust seria uma forma de ARC? - O ARC seria apenas um GC simplificado?
- E se o modelo de Rust é tão eficiente, por que nem todas as linguagens modernas o adotam?
Tal curiosidade me levou a explorar mais profundamente o tema, incluindo a leitura do artigo A Unified Theory of Garbage Collection, de David F. Bacon, Perry Cheng e V.T. Rajan — que argumenta que ARC e GC são, na verdade, duas formas de garbage collection, unificadas sob a mesma teoria.
Este post é um resumo dessa jornada: uma tentativa de entender as três principais abordagens de gerenciamento automático de memória.
🧮 Reference Counting (ARC)
O ARC (Automatic Reference Counting) é utilizado em Objective-C, Swift e também aparece em linguagens como Rust (com Rc
e Arc
) e C++ (com shared_ptr
).
Cada objeto tem um contador de referências associado, que é incrementado quando novas referências são criadas e decrementado quando essas referências saem de escopo.
Nos bastidores, isso é feito automaticamente por chamadas de retain
e release
, inseridas pelo compilador em pontos estratégicos do código. Essas operações aumentam ou diminuem o contador de referências sem que o desenvolvedor precise chamá-las manualmente.
Com o programa em execução, quando o contador chega a zero, o objeto é desalocado imediatamente.
Características
- Determinístico: a memória é liberada exatamente no momento em que não há mais referências.
- Sem pausas globais: não há varredura periódica da heap.
- Latência previsível: ideal para aplicativos de interface ou tempo real.
Limitação — referências cíclicas
O ARC não tem uma visão global da heap. Se dois objetos se referenciam mutuamente, seus contadores nunca chegam a zero — eles “mantêm um ao outro vivos”:
1class Node {
2 var next: Node?
3}
4
5let a = Node()
6let b = Node()
7a.next = b
8b.next = a // ciclo de referência
Para evitar esse tipo de retenção circular, o ARC oferece referências fracas (weak
) e não proprietárias (unowned
), que não incrementam o contador.
Essas estratégias reduzem os ciclos de referência, mas não os eliminam completamente — o programador ainda precisa projetar cuidadosamente as relações entre objetos e capturas em closures para evitar vazamentos ou ponteiros inválidos.
♻️ Tracing Garbage Collector (Java, C#, Go)
O tracing garbage collector usado em Java, C# e Go funciona de maneira diferente. Ele não conta referências. Em vez disso, periodicamente varre todos os objetos alcançáveis, partindo de um conjunto de raízes (variáveis locais, globais e registradores). Tudo o que for inalcançável é considerado lixo e liberado em lote.
Características
- Totalmente automático: sem
retain
ourelease
. - Imune a ciclos: detecta objetos inalcançáveis, mesmo com referências circulares.
- Bom desempenho agregado: ideal para ambientes com muitas alocações de curta duração.
Desvantagens
- Não determinístico: não é possível prever quando a limpeza ocorrerá.
- Pausas ocasionais: o coletor precisa suspender as threads para inspecionar a memória.
- Maior uso de memória: a heap tende a crescer até o próximo ciclo de coleta.
Coletoras modernas, como G1GC, ZGC e o GC concorrente do Go, reduzem essas pausas e melhoram a previsibilidade ao rodar de forma incremental e paralela.
⚗️ Abordagens híbridas (Python, JavaScript, etc.)
Algumas linguagens combinam técnicas de contagem de referências e tracing:
- Python usa reference counting para a maioria dos objetos, com um GC que roda ocasionalmente para eliminar loops inalcançáveis.
- JavaScript (V8, SpiderMonkey) e Ruby adotam variantes híbridas ou incrementais do tracing GC, otimizadas para responsividade.
- Mesmo o Swift complementa o ARC com heurísticas internas para liberar objetos ociosos mais cedo quando possível.
Esses modelos híbridos buscam equilibrar determinismo (como o ARC) e simplicidade (como o GC), aceitando pequenos compromissos de ambos os lados.
🦀 Ownership e Borrow Checker (Rust)
O Rust segue um caminho completamente diferente. Ele garante segurança de memória em tempo de compilação, sem contadores nem threads de coleta.
Cada valor tem exatamente um dono, e o compilador insere chamadas de drop()
quando esse dono sai de escopo:
1fn main() {
2 let s = String::from("hello");
3 println!("{}", s);
4} // drop(s) é inserido automaticamente aqui
Tudo isso é feito por meio do sistema de ownership e das regras de empréstimo (borrowing), verificadas pelo compilador — não pelo runtime.
Características
- Zero custo em tempo de execução: não há contadores nem varredura da heap.
- Determinístico: a liberação ocorre no fim do escopo.
- Seguro por construção: evita leaks, use-after-free e double free.
- Desempenho previsível: ideal para jogos, sistemas embarcados e servidores de alta performance.
Como resolver quando é necessário compartilhar a posse de um valor
O modelo padrão de Rust permite apenas um dono por valor. Quando é preciso que que várias partes do código compartilhem a posse do mesmo valor, Rust fornece tipos explícitos com contagem de referências, de uso optativo:
1use std::rc::Rc;
2let a = Rc::new(String::from("hello"));
3let b = Rc::clone(&a); // contador +1
Esse tipo de compartilhamento de ownership é sempre explícito — o custo de contagem de referências só existe quando você opta por ele.
⚖️ Comparando os três modelos
Aspecto | ARC (Reference Counting) | GC (Tracing) | Rust (Ownership) |
---|---|---|---|
Liberação de memória | Imediata (contador = 0) | Periódica, em lotes | No fim do escopo |
Determinismo | ✅ Sim | ❌ Não | ✅ Sim |
Custo em runtime | Constante e previsível | Variável e amortizado | Zero |
Pausas globais | Nenhuma | Possíveis | Nenhuma |
Referências cíclicas | Vazamento se não houver weak |
Detectados automaticamente | Impossíveis por design |
Ideal para | Apps móveis, UI, tempo real | Servidores, linguagens dinâmicas | Sistemas, código de baixo nível |
Exemplos | Swift, Obj-C, Rust (Rc /Arc ) |
Java, C#, Go | Rust (modelo padrão) |
Por que nem todas as linguagens usam o modelo de Rust
À primeira vista, o modelo de Rust parece ter apenas vantagens — sem GC, sem pausas, sem vazamentos. Mas ele traz custos reais, principalmente na experiência do desenvolvedor e na flexibilidade do ecossistema:
- Curva de aprendizado íngreme: o sistema de ownership e borrowing é rígido, e erros do borrow checker são comuns no início.
- Menor flexibilidade: estruturas complexas (como grafos ou caches compartilhados) exigem soluções explícitas com
Rc
,Arc
ou mutabilidade interna. - Dificuldade em linguagens dinâmicas: linguagens com reflexão, tipagem dinâmica ou carregamento de código em tempo de execução (como Python ou JavaScript) não podem aplicar as garantias estáticas de Rust.
- Custo de compilação: o compilador realiza análises pesadas para provar segurança de memória, o que aumenta o tempo de build.
O modelo de Rust troca conveniência por controle. É perfeito para código de baixo nível e sistemas críticos, mas não para todos os contextos.
💡 Conclusão: uma visão unificada
Como apontam Bacon et al., o ARC e o tracing GC são duas formas de garbage collection dentro do mesmo arcabouço teórico:
“Reference counting is a form of garbage collection which maintains liveness information incrementally.”
Em resumo, o artigo descreve o ARC como uma abordagem local e incremental, e o tracing GC como uma abordagem global e periódica. A esse quadro, podemos adicionar o modelo de ownership do Rust como uma terceira abordagem estática, que desloca a segurança de memória completamente para o tempo de compilação.
Os três modelos buscam o mesmo objetivo: reaproveitar automaticamente a memória não utilizada, equilibrando de formas diferentes controle, simplicidade e custo em tempo de execução. Se incluirmos também o modelo manual, temos:
Manual (C) → Ownership (Rust) → Reference Counting (Swift) → Tracing GC (Java)
- Manual (C) oferece controle total, ao custo da segurança.
- ARC privilegia previsibilidade.
- Tracing GC privilegia simplicidade e produtividade.
- Rust privilegia segurança e custo zero em runtime — ao preço de regras mais rígidas e compilações mais pesadas.
Cada linguagem adota o modelo que mais reflete sua filosofia.
O essencial é compreender os trade-offs por trás de cada abordagem e reconhecer que Rust, Swift e Java representam três respostas distintas (e elegantes) para a mesma pergunta fundamental: como liberar memória de forma segura?