Pular para o conteúdo

Racional · ensaio 02

Memória

rationale.md · 104 linhas · 4 min de leitura

A redefinição: a memória é colorless, e o gerenciador viaja com o objeto, não com o contexto. E não há borrow checker.

Toda linguagem de sistemas precisa responder duas perguntas: de onde vêm os bytes e quem os libera. Rust responde com ownership + lifetimes + borrow checker (e &T/&mut T/*const T/*mut T como tipos distintos). Go responde com um GC e ponto. Zig responde com allocators passados como parâmetro. Cada resposta colore o código de um jeito.

Cada valor alocado no heap carrega um ponteiro para o seu próprio MemoryManager (@mm). Quando o objeto cresce ou realoca, ele usa o @mm dele, não o da função que o está manipulando. A função muta um Buffer sem saber nem se importar se ele é arena-backed, GC ou manual:

@mm(gc)
fn append_log(buf: mut Buffer, line: string) {
buf.push(line) // se push realocar, usa o @mm do buf (a arena), NÃO o @mm(gc) da função
}
var buf: Buffer @mm(arena) = Buffer.new()
append_log(mut buf, "erro") // muta in-place na arena; o GC nem encosta

Isto é o “colorless” em ação: o @mm(gc) de uma função governa só as alocações novas que ela origina, nunca a estratégia dos objetos que chegam até ela. A estratégia viaja com o dado.

Por baixo, duas camadas ortogonais: MemoryManager (a estratégia, gc/arena/none/c, “como gerencio”) sobre MemorySource (de onde vêm as páginas brutas, mmap/VirtualAlloc/ memory.grow do WASM/buffer fixo bare-metal, “de onde vem a RAM”). Qualquer combinação serve: GC-sobre-WASM, arena-sobre-nativo. É o mesmo desacoplamento do colorless, descido um nível.

Por que dá para abandonar o borrow checker? Porque o motivo dele desaparece dentro de um processo. O borrow checker existe para impedir aliasing mutável concorrente, dois caminhos mutando o mesmo dado ao mesmo tempo. Mas em Makoto o processo é a unidade de concorrência, com uma única linha de execução; o scheduler só o suspende em pontos explícitos (IO, send/recv), e nenhum outro processo toca o heap dele (shared-nothing, cap. 04). Sem aliasing concorrente, a justificativa principal do borrow checker some. Restam só dois perigos, e cada um tem uma defesa local:

  • Aliasing bug (mutar uma struct enquanto se segura outra referência a ela): resolvido por semântica de valor por default + mut para mutação. mut é modo de passagem, não tipo (como o inout do Swift): exclusivo, write-back, não escapa (morre no fim da chamada). A checagem é puramente local (“este lugar já está mut aqui?”), sem lifetimes.
  • Use-after-free: só existe sob @mm(none)/arena, território explicitamente unsafe. Sob o GC default, um ponteiro *T vivo é uma referência que o trace enxerga, então o alvo fica vivo, seguro por construção.

A chave que fecha o modelo: ponteiro nasce de identidade persistente, seja uma alocação no heap (que já carrega seu @mm), um campo de estrutura ou FFI. Você não consegue pegar *T de um lvalue de stack local; para mutar um int na stack, só há mut. Logo, dangling de stack é impossível: o & de um local não te dá um ponteiro armazenável.

  • Lifetimes / borrow checker (Rust). O &mut T empacota três coisas ao mesmo tempo (pode aliasar, é exclusivo, tem lifetime), e é isso que obriga o borrow checker a existir. Makoto desmonta o pacote: mut é o “exclusivo + write-back” sem o “pode aliasar”; *T é o “pode aliasar” sem o resto. Separados, nenhum dos dois exige rastreamento global.
  • Ponteiros nullable por default (C/Go). Tornariam o Optional[*T] redundante e reabririam a ambiguidade do null. Em Makoto o ponteiro é sempre válido; ausência é Optional[*T], explícita.
  • Allocator como parâmetro (Zig). Quebra o colorless: a função passaria a saber qual memória usa, e passar um buffer de arena para uma função GC viraria um descompasso. Com o @mm viajando no objeto, isso é seguro e invisível.
  • Smart pointers / Cell / RefCell (Rust). Embarcariam no core um maquinário que contradiz “não embarcar o que não se usa”. As interfaces de @mm cobrem o mesmo terreno mais limpo.
  • Um marcador @copy explícito. Descartado: cópia simples é o default não-marcado, e marcar o caso comum violaria o opt-in (você pagaria sintaxe pelo que mais acontece). O que ganha marca é o contrário: mover (@transfer, invalida a origem) e copiar-para-outro-MM (@promote(mm), corta o cordão com a arena de origem).

Houve um medo real e nomeado: o de que mut morresse para o atalho do ponteiro (como o ? virou escape-hatch universal em Rust/Zig). A resposta de design não foi tornar mut mais atraente. Foi fazer ponteiro não ser substituto: ele resolve problema diferente (referência persistente), nasce só de heap-identity (não dá para “preferir” no caso comum), e cobra deref explícito (*p) em cada acesso, então quem pegou um ponteiro “para não digitar mut” digita * o dia inteiro.

O programador de Rust que escreve uma função de mutação simples e precisa anotar lifetimes que não têm nada a ver com o problema. O de Zig que precisa threadar allocator: *Allocator por dez camadas de chamada. O de Go que não tem escolha de estratégia quando o GC não serve. Cada dor é uma cor de memória que vazou para onde não devia.

O objeto carrega sua estratégia de memória. Você muta o valor; o runtime usa o @mm dele para qualquer crescimento. Sem cor, sem lifetime.

Sob @mm(none)/arena, use-after-free volta a ser sua responsabilidade, e o compilador não cobre (é o preço explícito de descer ao metal, e fica em ilha unsafe). Ponteiros não-anuláveis forçam Optional[*T] onde C teria um simples nullable. E o deref *p obrigatório é ruído em código legítimo de ponteiro, aceito de propósito, porque é justamente o que derrota a preguiça.

Próximo: 03 · Async e IO