Articoli approfonditi sulla tecnologia che plasma il futuro.

Goose: un linguaggio di sistema senza heap

Goose è un linguaggio di sistema memory-safe senza heap, GC né annotazioni di lifetime. Cosa insegna su Rust e sulle regioni.

Illustrazione astratta di un albero di piccoli nodi collegati, resi come punti luminosi uniti da brevi archi, tutti sospesi in un'unica lastra traslucida piatta
Un albero costruito con link auto-relativi significa gli stessi byte a qualsiasi indirizzo — la persistenza diventa memcpy.

Ecco un'affermazione che irriterà almeno due comunità di linguaggi: il borrow checker è stata una deviazione. Non un errore — il modello di ownership di Rust ha risolto problemi reali e ha trascinato avanti l'intera industria — ma una deviazione rispetto a un'idea più vecchia e più semplice che oggi stiamo riscoprendo. Quell'idea sono le regioni: memoria allocata in grandi arene simili a stack e liberata in blocco quando lo scope termina. Il linguaggio Goose, un nuovo linguaggio di sistema di cui si parla questa settimana, è la scommessa moderna più aggressiva su quell'idea che abbia visto, e il suo design costringe a una domanda scomoda: quanto di ciò che sopportiamo in C++ e Rust è davvero portante, e quanto è solo l'heap che parla?

Goose non ha un allocatore heap. Niente garbage collector, niente reference counting, niente distruttori, niente annotazioni di lifetime, niente unsafe. Ogni valore dinamico vive inline su uno di un piccolo numero di stack di dati che il compilatore assegna staticamente. La crescita sposta semplicemente un puntatore. Liberare una struttura annidata da un milione di elementi è una singola scrittura nella cima dello stack. L'autore riporta benchmark più veloci del C++ idiomatico (3,3x) e del Rust sicuro (1,12x) con meno memoria — e la reazione pubblica di John Carmack ha evidenziato esattamente la parte che trovo più interessante, i riferimenti relativi che rendono una struttura dati il proprio formato su file. Che Goose finisca o meno per essere usato in software di produzione, è un serio esperimento di pensiero su come appare un linguaggio di sistema quando si elimina l'heap e non ci si scusa per questo.

Il modello di memoria è l'intero linguaggio

La maggior parte delle funzionalità di un linguaggio è negoziabile. Sintassi, sistemi di moduli, persino la gestione degli errori — puoi immaginare due linguaggi ragionevoli che fanno scelte opposte. I modelli di memoria non funzionano così. Ne scegli uno e tutto il resto ne consegue: le strutture dati, la gestione della concorrenza, la FFI, i messaggi di errore. Goose lo capisce e si spinge fino in fondo.

La regola è semplice da enunciare: un programma ha lo stack di chiamata nativo, i dati statici e N stack di dati, dove N lo calcola il compilatore. Per ogni stack c'è al massimo un valore ridimensionabile vivo, ed è sempre in cima. Questo singolo invariante garantisce una quantità sorprendente di vantaggi. Poiché il valore che cresce non si sposta mai — non c'è nulla sopra che debba essere rilocato — i riferimenti a un array in crescita restano validi per sempre. In C++, un std::vector<T> che rialloca invalida ogni puntatore e iteratore verso di esso, ed è per questo che generazioni di programmatori C++ sono inciampate tenendo un riferimento attraverso un push_back. Il Rust sicuro aggira il problema rendendolo inesprimibile; useresti invece indici u32, pagando in controlli dei limiti e in ergonomia. Goose semplicemente... ti lascia tenere il riferimento. Le regole su tipi e lifetime sono verificate in fase di compilazione, con il compilatore che deduce a cosa ogni riferimento è «radicato» e rifiuta quelli che potrebbero sopravvivere ai loro proprietari — ma senza annotazioni, senza regole di aliasing, senza esclusività.

struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later

Se hai passato del tempo con il borrow checker di Rust, questo sembra un gioco di prestigio. Dove sono finiti i lifetime? La risposta onesta: in una restrizione. Il sistema di ownership di Rust può dimostrare la sicurezza per schemi di condivisione arbitrari — grafi, cicli, strutture mutabili condivise tra scope. Goose dimostra la sicurezza per un insieme molto più ristretto di schemi e semplicemente rifiuta il resto. Questo compromesso — espressività in cambio di semplicità — è il più antico nel design dei linguaggi, ed è il filo che continuerò a tirare qui. Se hai letto il nostro articolo su cosa significa davvero soundness nel borrow checker di Rust, Goose è il rovescio della medaglia: la soundness ottenuta non con un'analisi più potente, ma con un linguaggio più piccolo.

Tutto inline: il layout dei dati come funzionalità di primo livello

La seconda grande idea è che non esiste un unico tipo array. Goose ha una famiglia di array che differiscono solo per ciò che accade alla dimensione: fissa in fase di compilazione, congelata dopo la costruzione, crescente entro una capacità inline, solo in crescita, in crescita e in riduzione. Tutti hanno la forma [metadata][elements...] — contigui, compatti, mai un puntatore a un blocco di elementi. Le stringhe sono semplicemente array di u8, quindi una struct può contenere parti di dimensione variabile inline nell'ordine di dichiarazione. Un record con un campo stringa di lunghezza variabile e una lista di sotto-record di lunghezza variabile è una sequenza continua di byte senza alcuna indirezione.

I numeri dichiarati sono impressionanti: un record d'ordine che in Goose occupa 29 byte e zero allocazioni, contro 160 byte e un'allocazione con std::string + std::vector<Item> in C++, o 153 byte e quattro allocazioni in Rust. Ora, una correzione onesta è emersa quasi subito nella discussione: l'esempio del tutorial di Goose su «un vettore di stringhe» ignora la small-string optimization, che ogni STL mainstream implementa. In C++ le stringhe corte non allocano. Questo non demolisce l'argomentazione — la SSO aiuta le stringhe sotto ~15–22 byte e non fa nulla per il caso generale dei dati annidati di dimensione variabile — ma ricorda che le tabelle di benchmark nella homepage di un linguaggio sono marketing, anche quando il linguaggio è valido. Anche le sconfitte ci sono, a merito dell'autore, cosa più di quanto la maggior parte dei progetti pubblichi.

C'è un costo reale, e Goose non lo nasconde: un array di elementi di dimensione variabile è sequenziale. Puoi iterarlo, ma non indicizzarlo — l'elemento k si trova dove cadono i byte. Se vuoi accesso casuale, mantieni un secondo array di riferimenti come indice. È il tipo di compromesso che le persone che fanno data-oriented design hanno fatto a mano per decenni (è fondamentalmente il modo in cui funzionano i formati colonnari e i layout in stile flatbuffers), ma qui è il comportamento predefinito, e uscirne richiede lavoro extra. Se sia una funzionalità o una camicia di forza dipende interamente dai tuoi pattern di accesso.

Riferimenti relativi: la tua struttura dati è il suo formato su file

L'idea che preferisco in Goose è il riferimento relativo, ed è anche quella su cui si è concentrato Carmack. Un riferimento normale è un indirizzo macchina: otto byte, assoluto, privo di significato fuori dal processo. Un riferimento relativo memorizza lo stesso collegamento come offset di 1, 2 o 4 byte — misurato o dal campo stesso o dalla base di un pool con nome. Le strutture costruite con collegamenti auto-relativi sono indipendenti dalla posizione. Significano la stessa cosa ovunque si trovino in memoria. Il che vuol dire che salvare una struttura dati è una write(), e caricarla è una read() più una passata di verifica.

Quest'ultima clausola conta più di quanto sembri. La versione ingenua del trucco — mappare in memoria un file pieno di puntatori — è un classico disastro di sicurezza. La versione di Goose valida: from_bytes controlla il framing, ogni tag, ogni lunghezza, ogni collegamento prima che i byte diventino un valore, quindi un file corrotto o ostile ti restituisce false, non un puntatore impazzito. Ottieni la velocità delle strutture mmap con qualcosa di simile alla sicurezza di un parser.

L'impostazione di Carmack era esattamente giusta: «Il "parsing" di dati da file e rete è spesso 100 volte più lento di quanto dovrebbe». Chiunque abbia profilato un percorso critico di deserializzazione lo sa. La risposta del settore finora sono stati i formati zero-copy guidati da uno schema — Cap'n Proto, FlatBuffers — che funzionano ma aggiungono al progetto un compilatore di schemi e una libreria runtime. La risposta di Goose è che il layout in memoria e il layout su filo sono la stessa cosa per costruzione, perché il linguaggio non ha mai avuto puntatori ad indirizzi assoluti. L'esempio dell'albero binario di ricerca è quasi provocatorio: nodi da 12 byte, collegamenti inclusi, l'inserimento è un push e una scrittura, e l'intero albero si persiste scrivendo i suoi byte. Chi conosce Lisp riconoscerà la mossa filosofica — collassare una distinzione che tutti davano per fondamentale — che fa parte del motivo per cui Lisp conta ancora come punto di riferimento di design a sessant'anni di distanza.

Ci siamo già passati: regioni, arene e ML Kit

La risposta storicamente informata a Goose è arrivata puntuale nei commenti: «Mi sembra di ricordare che i ragazzi di ML avevano seguito questa strada. Credo la chiamassero regioni». Esatto. La gestione della memoria basata su regioni risale al lavoro su ML Kit degli anni '90 — la region inference di Tofte e Talpin permetteva a un compilatore ML di sostituire il garbage collection con regioni simili a stack determinate staticamente, allocando e liberando intere regioni senza alcuna contabilità per oggetto. Il verdetto storico onesto è che fu considerata un vicolo cieco: la region inference funzionava, ma i programmi reali mostravano una crescita patologica della memoria — regioni che tenevano in vita dati morti per troppo tempo — e la comunità di ricerca si è in gran parte spostata altrove. I garbage collector hanno mantenuto la corona.

Ma l'idea non è mai morta davvero; ha solo cambiato mestiere. L'allocazione ad arena è pratica standard nei motori di gioco, nei compilatori e nei server ad alte prestazioni. I memory pool di Apache, le arene per richiesta nei web server, lo schema «resetta il buffer di lavoro a ogni frame» nei game loop — sono regioni con un altro nome, gestite a mano perché il linguaggio non ti aiuta. Ciò che Goose fa diversamente da ML Kit vale la pena dichiararlo con precisione: invece di inferire regioni per un linguaggio funzionale generico (difficile, imprevedibile), espone direttamente il modello dello stack e rende l'intento del programmatore parte del tipo. T[>..] dice «questo cresce, i riferimenti restano validi». Il compito del compilatore è verificare una disciplina dichiarata, non scoprirne una. È un problema molto più semplice, ed è lo stesso motivo per cui Rust ha avuto successo dove i vecchi schemi di lifetime automatici faticavano: i programmatori tollerano un contratto che possono vedere.

C'è una lezione più ampia su come i linguaggi di produzione assorbono la ricerca. La region inference come sostituzione automatica completa del GC: fallita. Le regioni come schema manuale di sistema: onnipresenti. Il borrow checking: un enorme successo che a sua volta prende molto in prestito dalle idee sulle regioni e dai tipi lineari. I processi share-nothing in stile Erlang: rivalutati, e i thread di Goose sono letteralmente quello — worker compilati come programmi separati con memoria propria, con valori che attraversano code tipizzate come un memcpy, e con data race non rappresentabili. Nessuno azzecca il quadro completo al primo tentativo. Le idee circolano per decenni prima di approdare in una forma che funziona.

Dove il modello si rompe

Avevo detto che avrei aperto con un'affermazione controcorrente e poi avrei concesso dove cede. Ecco la concessione. Il borrow checker non è una deviazione se gli schemi di condivisione del tuo programma sono davvero arbitrari — e un'enorme classe di programmi ha esattamente quella forma. La domanda più acuta nella discussione su Goose era anche la più semplice: come costruisci una tabella hash che mappa chiavi a stringhe, dove il programma inserisce ed elimina stringhe casuali in momenti casuali? L'eliminazione è il punto critico. Uno stack solo in crescita non può recuperare un buco nel mezzo. Le soluzioni alternative proposte — uno stack per classe di dimensione, compattazione periodica, un pool con freelist dove «liberare» uno slot significa solo che può essere riutilizzato — funzionano tutte, e il design a pool è davvero ingegnoso (uno slot obsoleto legge un valore diverso del tipo corretto invece di corrompere la memoria). Ma ciascuna di esse è il programmatore che reimplementa a mano un pezzo di un gestore di memoria, per quella specifica struttura dati.

Lo stesso vale per l'uso di memoria. Senza GC, uno stack in crescita conserva tutto ciò che ha mai dovuto contenere fino alla fine dello scope. I programmi a lunga esecuzione con cicli di vita irregolari — la questione dei documenti JSON emersa nel thread, le cache, lo stato delle sessioni — o rientrano nello schema disciplinato per scope o non ci rientrano, e quando non ci rientrano nessuna eleganza ti salva. Il modo di fallire di ML Kit (dati morti bloccati in regioni vive) non scompare in Goose; diventa visibile e manuale, il che è discutibilmente meglio, ma è ancora lì. Un commentatore ha descritto la postura giusta: Goose è un linguaggio per uno scopo limitato, e va bene così. I carichi di lavoro orientati ai dati, i compilatori, i sistemi di entità nei giochi, tutto ciò che ha la forma di una pipeline — leggi, trasforma, emetti, resetta — ci sta benissimo. Un grafo di oggetti mutabili a lunga vita, no.

Ancora una precauzione sui numeri in evidenza. Le suite di benchmark autoprodotte sono un genere con modalità di fallimento note: confronti il tuo Goose migliore con il C++ «idiomatico» di qualcun altro, e le idiomaticità sono dove iniziano le liti. Lo scivolone sulla SSO mostra che il codice di confronto non era tarato in modo avversariale. La cifra dell'1,12x sul Rust sicuro è più interessante di quella del 3,3x proprio perché è piccola — dice che il modello di memoria vince su layout ed eliminazione delle allocazioni, non per magia, e una versione di Rust ottimizzata con arene e indici chiuderebbe gran parte del divario. Ed è questo il punto: puoi scrivere Rust basato su arene, ma il linguaggio lo rende scomodo, e la scomodità è ciò che determina quale codice viene effettivamente scritto.

Due traiettorie di volo disegnate come scie luminose in un cielo scuro, una lunga e sinuosa, l'altra più corta e diretta, che convergono in un punto
Percorsi diversi verso la stessa sicurezza: Rust vincola la condivisione, Goose vincola il modello di memoria stesso.

Cosa rubheranno davvero i linguaggi di produzione da Goose

Quasi nessuno scriverà Goose in produzione nei prossimi anni — i linguaggi realizzati da una sola persona che compilano attraverso un unico backend C non ci arrivano in fretta, e l'autore lo dice lui stesso riguardo al loro stato. Non è questa la domanda interessante. La domanda interessante è quali idee sfuggano verso i linguaggi che già usiamo, perché è così che funziona sempre. Le mie previsioni, in ordine di fiducia:

  1. Strutture indipendenti dalla posizione con caricamento verificato. La serializzazione zero-copy con una passata di sicurezza è troppo valida per restare di nicchia. Aspettatevi versioni integrate nei linguaggi di ciò che FlatBuffers fa in modo goffo, soprattutto man mano che i servizi ad alta intensità di dati continuano a scontrarsi con i costi di parsing.
  2. Array come famiglia, non come tipo. «Cosa succede alla dimensione?» è una domanda organizzativa migliore di «stack o heap?». Fissi, congelati, con capacità limitata, solo in crescita — queste distinzioni esistono in ogni codebase come convenzioni informali. Renderle tipi è economico e intercetta bug reali.
  3. Valori di funzione a tempo di compilazione. Goose tratta i valori di funzione come entità a tempo di compilazione, quindi xs.filter() { it > 0 } compila nel ciclo che sembra essere. I template del C++ e la monomorfizzazione di Rust fanno già questo; la differenza è che Goose lo rende l'unico modo, eliminando del tutto la trappola dei puntatori a funzione a runtime.
  4. Ritorni multi-frame verificati staticamente. return err from load ritorna da una funzione N frame più in alto, controllato in fase di compilazione, senza unwinder, senza piramidi di Result, senza ? su ogni chiamata. I sistemi di effetti stanno girando attorno alla stessa idea da un'altra direzione. Uno di essi approderà in un linguaggio mainstream in questo decennio.

Anche il modello di threading share-nothing merita una menzione, soprattutto perché è la parte meno originale e la più confermata: sono i processi di Erlang, sono gli isolate di Dart, è il modello degli attori senza l'ideologia. Che continui a essere reinventato dice che il consenso del settore sullo stato mutabile condiviso è stabilito in teoria e ignorato in pratica. Goose, almeno, rende impossibile ignorarlo.

L'argomento vero: semplicità attraverso la restrizione

Fatti un passo indietro e il design di Goose è una mossa sola, ripetuta: restringere l'insieme dei programmi rappresentabili finché dimostrare la sicurezza diventa economico. Niente heap significa niente puntatori pendenti nell'heap. Stack solo in crescita significa che i riferimenti non possono essere invalidati. Thread separati per i worker significa che le race non possono esistere. La costruzione senza copie significa niente temporanei accidentali. Ogni restrizione elimina un'intera categoria di bug e un'intera categoria di meccanismi del linguaggio — le regole di aliasing del borrow checker, il distruttore, l'unwinder — che esistevano per gestirli.

È l'argomento «less is exponentially more» di Rob Pike senza guanti, e tocca la stessa tensione in cui cadde il Go di Pike: le restrizioni sembrano liberazione finché il tuo programma è proprio quello che la restrizione esclude, e allora sembrano un muro. Go ha escluso i generici per un decennio e ce l'ha fatta; ha anche escluso molti programmi corretti. Goose ne esclude molti di più. La ragione per prestarvi attenzione comunque è che le vecchie regole di Pike sulla semplicità hanno retto bene proprio perché i budget di complessità sono reali — ogni garanzia che un linguaggio offre automaticamente è una garanzia che ogni programmatore che lo usa non deve tenere a mente.

Quindi: il borrow checker è stato una deviazione? Per la programmazione di sistema generalista, no — l'espressività di Rust è davvero necessaria per i casi di condivisione arbitraria, e il suo successo ha dato all'industria un vocabolario per parlare di ownership. Ma per la grande, silenziosa maggioranza del codice di sistema con forma di pipeline, di arena, di richiesta? Goose dimostra che potremmo avere sicurezza della memoria, velocità e binari piccoli con una frazione della cerimonia — se fossimo stati disposti a dire «no» a più programmi. Il design dei linguaggi è l'arte di scegliere quali programmi vietare. Goose ne vieta di diversi da quelli a cui siamo abituati, e il risultato è lo schizzo di linguaggio di sistema più interessante che abbia letto da anni. Non mi aspetto di usarlo in produzione. Mi aspetto di continuare a rubarne le idee.

La scommessa di Goose è che un linguaggio possa vietare più programmi di Rust e restare utile — i benchmark suggeriscono che vale la pena prenderlo sul serio, sconfitte comprese.