Goose: Systemcode ohne Heap, eine Lektion für Rust
Goose ist eine speichersichere Systemsprache ohne Heap, GC und Lifetime-Annotationen. Was ihr Stack-Design über Rust und Regionen verrät.

Hier kommt eine Behauptung, die mindestens zwei Sprachgemeinschaften ärgern wird: Der Borrow Checker war ein Umweg. Kein Fehler, denn Rusts Ownership-Modell hat echte Probleme gelöst und die ganze Branche vorangebracht, aber ein Umweg an einer älteren, einfacheren Idee vorbei, die wir gerade wiederentdecken. Diese Idee heißt Regionen: Speicher, der in großen, stapelartigen Arenen angelegt und beim Verlassen eines Scopes am Stück freigegeben wird. Die Programmiersprache Goose, eine neue Systemsprache, die diese Woche die Runde macht, ist die aggressivste moderne Wette auf diese Idee, die ich bisher gesehen habe. Ihr Design zwingt uns zu einer unbequemen Frage: Wie viel von dem, was wir in C++ und Rust hinnehmen, ist tatsächlich tragend, und wie viel ist einfach der Heap, der mitredet?
Goose hat keinen Heap-Allocator. Kein Garbage Collector, kein Reference Counting, keine Destruktoren, keine Lifetime-Annotationen, kein unsafe. Jeder dynamische Wert liegt direkt auf einem von wenigen Datenstacks, die der Compiler statisch zuweist. Wachstum bedeutet, einen Zeiger weiterzuschieben. Das Freigeben einer verschachtelten Struktur mit einer Million Elementen ist ein einziger Schreibzugriff auf den Stack-Top. Der Autor berichtet, dass Benchmarks schneller laufen als idiomatisches C++ (3,3-fach) und sicheres Rust (1,12-fach), und das bei weniger Speicherverbrauch. John Carmack hat in seiner öffentlichen Reaktion genau den Teil hervorgehoben, der mich am meisten interessiert: die relativen Referenzen, durch die eine Datenstruktur zu ihrem eigenen Dateiformat wird. Ob Goose jemals produktive Software antreibt oder nicht, es ist ein ernstzunehmendes Gedankenexperiment darüber, wie eine Systemsprache aussieht, wenn man den Heap streicht und sich dafür nicht entschuldigt.
Das Speichermodell ist die ganze Sprache
Die meisten Sprachfeatures sind verhandelbar. Syntax, Modulsysteme, sogar Fehlerbehandlung: Man kann sich zwei vernünftige Sprachen vorstellen, die gegensätzlich entscheiden. Speichermodelle sind nicht so. Wählt man eins, ist alles Nachgelagerte festgelegt: eure Datenstrukturen, eure Nebenläufigkeit, euer FFI, eure Fehlermeldungen. Goose hat das verstanden und geht konsequent in diese Richtung.
Die Regel ist einfach formuliert: Ein Programm besitzt den nativen Call-Stack, statische Daten und N Datenstacks, wobei der Compiler N ermittelt. Pro Stack ist höchstens ein größenveränderlicher Wert aktiv, und er liegt immer oben. Diese eine Invariante bringt erstaunlich viel. Weil der wachsende Wert nie wandert, denn darüber liegt nichts, das verschoben werden müsste, bleiben Referenzen auf ein wachsendes Array dauerhaft gültig. In C++ macht ein std::vector<T>, das neu alloziert, jeden Zeiger und Iterator darauf ungültig, und deshalb sind Generationen von C++-Entwicklern über eine Referenz gestolpert, die sie über ein push_back hinweg gehalten haben. Sicheres Rust umgeht das, indem es das schlicht nicht ausdrückbar macht; stattdessen nutzt man u32-Indizes und bezahlt dafür mit Bounds Checks und Ergonomie. Goose lässt dich die Referenz einfach behalten. Typ- und Lifetime-Regeln werden zur Compilezeit geprüft; der Compiler leitet ab, woran jede Referenz „verankert“ ist, und lehnt alles ab, was seinen Besitzer überleben könnte, aber ohne Annotationen, ohne Aliasing-Regeln, ohne Exklusivität.
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
Wer sich mit dem Borrow Checker von Rust beschäftigt hat, empfindet das als Zaubertrick. Wo sind die Lifetimes hin? Die ehrliche Antwort: in eine Einschränkung. Rusts Ownership-System kann Sicherheit für beliebige Sharing-Muster beweisen, etwa Graphen, Zyklen oder geteilte veränderliche Strukturen über Scopes hinweg. Goose beweist Sicherheit für eine deutlich engere Menge von Mustern und verweigert den Rest schlicht. Dieser Tausch, Ausdruckskraft gegen Einfachheit, ist der älteste im Sprachdesign, und an diesem Faden ziehe ich hier weiter. Falls ihr unseren Beitrag über was Soundness im Borrow Checker von Rust wirklich bedeutet gelesen habt: Goose ist die Kehrseite dieser Medaille. Soundness entsteht hier nicht durch eine mächtigere Analyse, sondern durch eine kleinere Sprache.
Alles inline: Datenlayout als Sprachfeature
Die zweite große Idee: Es gibt keinen einzelnen Array-Typ. Goose kennt eine Familie von Array-Arten, die sich nur darin unterscheiden, was mit der Größe passiert: zur Compilezeit fix, nach der Konstruktion eingefroren, innerhalb einer Inline-Kapazität wachsbar, nur wachsend oder wachsend und schrumpfend. Alle folgen dem Muster [Metadaten][Elemente...], also zusammenhängend, dicht gepackt, nie ein Zeiger auf einen separaten Elementblock. Strings sind einfach u8-Arrays, sodass eine Struktur variabel große Teile inline in Deklarationsreihenfolge enthalten kann. Ein Datensatz mit einem Stringfeld variabler Länge und einer Liste variabel vieler Unterdatensätze ist ein zusammenhängender Bytebereich ohne jede Indirektion.
Die angegebenen Zahlen sind auffällig: Ein Bestelldatensatz ist in Goose 29 Bytes groß und benötigt null Allokationen, in C++ mit std::string + std::vector<Item> sind es 160 Bytes und eine Allokation, in Rust 153 Bytes und vier Allokationen. Eine faire Korrektur kam in der Diskussion fast sofort: Das Tutorial-Beispiel „ein Vektor von Strings“ von Goose ignoriert die Small-String-Optimierung, die jede gängige STL-Implementierung beherrscht. Kurze Strings in C++ allozieren nicht. Das entkräftet das Argument nicht, denn SSO hilft bei Strings bis etwa 15–22 Bytes und nützt im allgemeinen Fall verschachtelter Daten variabler Größe nichts. Es erinnert aber daran, dass Benchmark-Tabellen auf der Startseite einer Sprache Marketing sind, selbst wenn die Sprache gut ist. Die Verluste stehen auch drin, was dem Autor zugutehält, und das ist mehr, als die meisten Projekte veröffentlichen.
Der Preis ist real, und Goose verschweigt ihn nicht: Ein Array aus Elementen variabler Größe ist sequenziell. Man kann es durchlaufen, aber nicht indizieren, denn wo Element k liegt, bestimmen erst die Bytes davor. Wer wahlfreien Zugriff braucht, hält ein zweites Array mit Referenzen als Index. Diesen Tausch haben Leute, die datenorientiertes Design betreiben, seit Jahrzehnten von Hand gemacht (so funktionieren im Grunde spaltenorientierte Formate und flatbuffers-artige Layouts). Hier ist er aber der Standard, und wer davon abweichen will, hat Mehraufwand. Ob das ein Feature oder eine Zwangsjacke ist, hängt ganz von euren Zugriffsmustern ab.
Relative Referenzen: Die Datenstruktur ist ihr Dateiformat
Meine Lieblingsidee in Goose ist die relative Referenz, und genau die hat auch Carmack ins Visier genommen. Eine normale Referenz ist eine Maschinenadresse: acht Bytes, absolut, außerhalb des Prozesses bedeutungslos. Eine relative Referenz speichert dieselbe Verknüpfung als Offset mit 1, 2 oder 4 Bytes, gemessen entweder vom Feld selbst oder von der Basis eines benannten Pools aus. Aus selbstrelativen Verknüpfungen aufgebaute Strukturen sind positionsunabhängig. Sie bedeuten dasselbe, egal wo sie im Speicher liegen. Das heißt: Eine Datenstruktur zu speichern ist ein write(), und sie zu laden ist ein read() plus ein Verifikationsdurchlauf.
Der letzte Teil wiegt schwerer, als er klingt. Die naive Variante dieses Tricks, eine Datei voller Zeiger per Memory-Map einzubinden, ist ein klassisches Sicherheitsdesaster. Gooses Variante validiert: from_bytes prüft Rahmen, jeden Tag, jede Länge und jede Verknüpfung, bevor die Bytes zu einem Wert werden. Eine korrupte oder feindliche Datei liefert also false statt eines wilden Zeigers. Man bekommt die Geschwindigkeit von mmap-Strukturen mit etwas, das der Sicherheit eines Parsers nahekommt.
Carmacks Einschätzung traf genau zu: „‚Parsing‘ von Datei- und Netzwerkdaten ist oft 100-mal langsamer, als es sein müsste.“ Wer schon einmal einen Deserialisierungs-Hotpath profiliert hat, weiß das. Die bisherige Antwort der Branche sind schemagetriebene Zero-Copy-Formate wie Cap'n Proto oder FlatBuffers. Die funktionieren, bringen aber einen Schema-Compiler und eine Laufzeitbibliothek ins Projekt. Gooses Antwort: In-Memory-Layout und Wire-Layout sind per Konstruktion dasselbe, weil die Sprache nie Zeiger auf absolute Adressen hatte. Das Beispiel des binären Suchbaums wirkt fast frech: 12-Byte-Knoten inklusive Verknüpfungen, Einfügen ist ein Push und ein Store, und der ganze Baum wird persistent, indem man seine Bytes schreibt. Lisp-Leute erkennen den philosophischen Zug, eine Unterscheidung zusammenfallen zu lassen, von der alle annahmen, sie sei grundlegend. Genau das gehört zu warum Lisp auch sechzig Jahre später noch wichtig ist als Referenzpunkt fürs Design.
Wir waren schon einmal hier: Regionen, Arenen und ML Kit
Die historisch bewanderte Reaktion auf Goose kam prompt in den Kommentaren: „Ich meine, die ML-Leute sind diesen Weg schon gegangen. Ich glaube, sie nannten es Regionen.“ Richtig. Regionsbasierte Speicherverwaltung geht auf die ML-Kit-Arbeiten der 1990er zurück. Tofte und Talpins Region-Inference erlaubte es einem ML-Compiler, Garbage Collection durch statisch bestimmte, stapelartige Regionen zu ersetzen, die als Ganzes alloziert und freigegeben werden, ohne Buchhaltung pro Objekt. Das historische Urteil lautet ehrlicherweise, dass es eher als Sackgasse galt: Region-Inference funktionierte, doch echte Programme zeigten genug pathologisches Speicherwachstum, weil Regionen tote Daten viel zu lange am Leben hielten. Deshalb zog die Forschung weitgehend weiter. Garbage Collectors behielten ihre Krone.
Die Idee ist aber nie gestorben, sie hat nur den Job gewechselt. Arena-Allokation ist Standard in Game-Engines, Compilern und performanten Servern. Die Memory-Pools von Apache, Arenen pro Request in Webservern und das Muster „Scratch-Buffer jeden Frame zurücksetzen“ in Game-Loops sind Regionen unter anderem Namen, von Hand verwaltet, weil die Sprache einem nicht hilft. Was Goose anders macht als ML Kit, lohnt sich genau zu benennen: Statt Regionen für eine funktionale Allzwecksprache zu inferieren (schwierig und unvorhersagbar), legt Goose das Stack-Modell offen und macht die Absicht des Programmierers Teil des Typs. T[>..] sagt: „Das wächst, Referenzen bleiben gültig.“ Die Aufgabe des Compilers ist es, eine deklarierte Disziplin zu prüfen, nicht eine zu entdecken. Das ist ein viel leichteres Problem, und aus demselben Grund hat Rust dort Erfolg gehabt, wo frühere Schemata für automatische Lifetimes scheiterten: Programmierer akzeptieren einen Vertrag, den sie sehen können.
Daraus folgt eine allgemeinere Lektion, wie Produktionssprachen Forschung aufnehmen. Regionen-Inference als vollautomatischer GC-Ersatz: gescheitert. Regionen als manuelles Systemmuster: allgegenwärtig. Borrow Checking: ein riesiger Erfolg, der selbst stark auf Regionenideen und lineare Typen zurückgreift. Erlang-artige Share-nothing-Prozesse: rehabilitiert, und Gooses Threads sind genau das. Sie sind Worker, die als separate Programme mit eigenem Speicher kompiliert werden, wobei Werte über typisierte Queues als memcpy wandern und Data Races nicht darstellbar sind. Niemand trifft das ganze Bild beim ersten Versuch. Ideen zirkulieren jahrzehntelang, bevor sie in einer funktionierenden Form landen.
Wo das Modell an seine Grenzen stößt
Ich hatte angekündigt, mit einer kontroversen These zu beginnen und dann zu zeigen, wo sie bricht. Hier ist das Zugeständnis. Der Borrow Checker ist kein Umweg, wenn die Sharing-Muster eures Programms wirklich beliebig sind, und eine enorme Klasse von Programmen hat genau diese Form. Die schärfste Frage in der Goose-Diskussion war auch die einfachste: Wie baut man eine Hash-Tabelle, die Schlüssel auf Strings abbildet, wenn das Programm zu beliebigen Zeiten beliebige Strings einfügt und löscht? Das Löschen ist der Killer. Ein nur wachsender Stack kann ein Loch in der Mitte nicht zurückgewinnen. Die vorgeschlagenen Workarounds funktionieren alle: ein Stack pro Größenklasse, periodische Kompaktierung, ein Pool mit Freelist, bei dem „Freigeben“ eines Slots nur bedeutet, dass er wiederverwendet werden darf. Das Pool-Design ist wirklich clever, denn ein veralteter Slot liefert einen anderen Wert des richtigen Typs, statt Speicher zu korrumpieren. Aber jeder dieser Ansätze bedeutet, dass der Programmierer ein Stück Speicherverwaltung von Hand nachbaut, und zwar genau für diese Datenstruktur.
Ähnlich beim Speicherverbrauch: Ohne GC hält ein wachsender Stack alles, wofür er je gewachsen ist, bis zum Ende des Scopes. Langlebige Programme mit unregelmäßigen Lebenszeiten, etwa die JSON-Dokument-Frage aus dem Thread, Caches oder Session-State, passen entweder zum scope-disziplinierten Muster oder nicht. Wenn nicht, rettet keine Eleganz der Welt die Lage. Das Fehlerbild von ML Kit, also tote Daten in lebenden Regionen, verschwindet in Goose nicht. Es wird sichtbar und manuell, was wohl besser ist, aber es ist weiterhin da. Ein Kommentator formulierte die richtige Haltung: Goose ist eine Sprache für einen eingeschränkten Zweck, und das ist in Ordnung. Datenorientierte Workloads, Compiler, Entity-Systeme in Spielen und alles Pipeline-Artige (lesen, transformieren, ausgeben, zurücksetzen) passt hervorragend. Ein langlebiger, veränderlicher Objektgraph passt nicht.
Noch ein Vorbehalt zu den Schlagzeilenzahlen. Selbst durchgeführte Benchmark-Suiten sind ein Genre mit bekannten Fehlerquellen: Man vergleicht sein bestes Goose mit jemandes „idiomatischem“ C++, und Idiome sind genau der Ort, an dem Streit beginnt. Der SSO-Patzer zeigt, dass der Vergleichscode nicht gezielt optimiert war. Die 1,12-fache Zahl gegenüber sicherem Rust ist interessanter als die 3,3-fache, gerade weil sie klein ist. Sie sagt, dass das Speichermodell bei Layout und der Vermeidung von Allokationen gewinnt, nicht durch Zauberei, und eine optimierte Rust-Version mit Arenen und Indizes würde einen Großteil des Abstands schließen. Das ist der eigentliche Punkt: Man kann arenabasiertes Rust schreiben, aber die Sprache macht es umständlich, und Umständlichkeit bestimmt, welcher Code tatsächlich geschrieben wird.

Was Produktionssprachen tatsächlich von Goose übernehmen werden
Kaum jemand wird in den nächsten Jahren produktiven Code in Goose schreiben. Einpersonen-Sprachen, die über ein einziges C-Backend kompilieren, kommen nicht schnell dorthin, und der Autor sagt das selbst zum Status. Das ist aber nicht die interessante Frage. Interessant ist, welche Ideen in die Sprachen gelangen, die wir ohnehin benutzen, denn so läuft das immer. Meine Vorhersagen, nach Sicherheit sortiert:
- Positionsunabhängige Strukturen mit verifiziertem Laden. Zero-Copy-Serialisierung mit einem Sicherheitsdurchlauf ist zu gut, um Nische zu bleiben. Erwartet mehr sprachintegrierte Varianten dessen, was FlatBuffers umständlich macht, besonders weil datenintensive Dienste weiter an Parsing-Kosten stoßen.
- Arrays als Familie, nicht als Typ. „Was passiert mit der Größe?“ ist eine bessere Ordnungsfrage als „Stack oder Heap?“ Fest, eingefroren, kapazitätsbegrenzt, nur wachsend: Diese Unterscheidungen gibt es in jeder Codebasis als informelle Konventionen. Sie zu Typen zu machen ist billig und fängt echte Bugs ab.
- Funktionswerte zur Compilezeit. Goose behandelt Funktionswerte als Compile-Time-Entitäten, sodass
xs.filter() { it > 0 }zu der Schleife kompiliert, wie sie aussieht. C++-Templates und Rusts Monomorphisierung tun das bereits. Der Unterschied ist, dass Goose es zum einzigen Weg macht und die Falle der Laufzeit-Funktionszeiger komplett entfernt. - Statisch geprüfte Rückgaben über mehrere Frames.
return err from loadkehrt aus einer Funktion N Frames weiter oben zurück, zur Compilezeit geprüft, ohne Unwinder, ohneResult-Pyramide und ohne?bei jedem Aufruf. Effektsysteme umkreisen dieselbe Idee von der anderen Seite. Eine davon wird in diesem Jahrzehnt in einer Mainstream-Sprache landen.
Das Share-nothing-Threading-Modell verdient auch eine Erwähnung, vor allem weil es am wenigsten neu und am stärksten bestätigt ist: Es sind Erlangs Prozesse, es sind Darts Isolates, es ist das Actor-Modell ohne die Ideologie. Dass es immer wieder neu erfunden wird, zeigt, dass der Branchenkonsens zu geteiltem veränderlichem Zustand in der Theorie geklärt, in der Praxis aber ignoriert ist. Goose macht es zumindest unmöglich, ihn zu ignorieren.
Das eigentliche Argument: Einfachheit durch Einschränkung
Tritt man einen Schritt zurück, ist Gooses Design ein einziger Zug, wiederholt: die Menge darstellbarer Programme so weit verkleinern, dass sich Sicherheit billig beweisen lässt. Kein Heap bedeutet keine baumelnden Heap-Zeiger. Nur wachsende Stacks bedeuten, dass Referenzen nicht ungültig werden können. Threads mit getrennten Workern bedeuten, dass es keine Races geben kann. Kopierfreie Konstruktion bedeutet keine versehentlichen Temporärobjekte. Jede Einschränkung streicht eine ganze Fehlerkategorie und eine ganze Kategorie an Sprachmechanik, etwa die Aliasing-Regeln des Borrow Checkers, den Destruktor oder den Unwinder, die nur existierte, um diese Fehler zu verwalten.
Das ist Rob Pikes Argument „Weniger ist exponentiell mehr“ ohne Samthandschuhe, und es trifft dieselbe Spannung, die auch Pikes Go hatte: Einschränkungen fühlen sich wie Befreiung an, bis das eigene Programm genau von der Einschränkung ausgeschlossen wird, und dann fühlen sie sich wie eine Wand an. Go hat Generics zehn Jahre lang ausgeschlossen und überlebt, hat aber auch viele korrekte Programme ausgeschlossen. Goose schließt weit mehr aus. Der Grund, trotzdem hinzuschauen: Pikes alte Regeln zur Einfachheit sind gut gealtert, gerade weil Komplexitätsbudgets real sind. Jede Garantie, die eine Sprache automatisch bietet, ist eine Garantie, die jeder Programmierer nicht selbst im Kopf halten muss.
Also: War der Borrow Checker ein Umweg? Für universelle Systemprogrammierung nein. Rusts Ausdruckskraft wird für die Fälle mit beliebigem Sharing wirklich gebraucht, und sein Erfolg hat der Branche überhaupt erst ein Vokabular für Ownership verschafft. Aber für die große, stille Mehrheit an Systemcode, der pipeline-, arena- oder requestförmig ist? Goose ist ein Beleg dafür, dass wir Speichersicherheit, Geschwindigkeit und kleine Binaries mit einem Bruchteil des Zeremoniells hätten haben können, wenn wir bereit gewesen wären, mehr Programmen ein „Nein“ zu erteilen. Sprachdesign ist die Kunst, zu wählen, welche Programme man verbietet. Goose verbietet andere, als wir gewohnt sind, und das Ergebnis ist die interessanteste Skizze einer Systemsprache, die ich seit Jahren gelesen habe. Ich erwarte nicht, dass ich damit ausliefere. Ich erwarte aber, weiter von ihr zu lernen.
Gooses Wette lautet: Eine Sprache kann mehr Programme verbieten als Rust und trotzdem nützlich sein. Die Benchmarks legen nahe, dass sich das ernsthaft lohnt, Verluste inklusive.


