Goose: язык системного кода без кучи
Goose — безопасный системный язык без кучи, сборщика мусора и аннотаций времени жизни. Что его дизайн на одном стеке говорит о Rust и регионах.

Вот утверждение, которое разозлит как минимум два языковых сообщества: проверяющий заимствования (borrow checker) был обходным путём. Не ошибкой — модель владения Rust решила реальные проблемы и протащила всю индустрию вперёд, — но обходным путём мимо более старой и простой идеи, которую мы сейчас заново открываем. Эта идея называется регионами: память выделяется в больших аренах, устроенных как стек, и освобождается целиком, когда заканчивается область видимости. Язык программирования Goose, новый системный язык, который на этой неделе всех обсуждает, — самая агрессивная современная ставка на эту идею из всех, что я видел, и его дизайн заставляет задать неудобный вопрос: сколько из того, что мы терпим в C++ и Rust, действительно несущие конструкции, а сколько — просто шум от кучи?
У Goose нет аллокатора кучи. Нет сборщика мусора, нет подсчёта ссылок, нет деструкторов, нет аннотаций времени жизни, нет unsafe. Каждое динамическое значение живёт прямо внутри одного из нескольких стеков данных, которые компилятор распределяет статически. Рост — это сдвиг указателя. Освобождение вложенной структуры из миллиона элементов — это одна запись в вершину стека. Автор сообщает, что бенчмарки работают быстрее идиоматичного C++ (в 3,3 раза) и безопасного Rust (в 1,12 раза) и при этом используют меньше памяти. Публичная реакция Джона Кармака выделила именно то, что интересует меня больше всего: относительные ссылки, которые превращают структуру данных в собственный файловый формат. Выпустит ли Goose когда-нибудь production-софт или нет, это серьёзный мысленный эксперимент о том, каким становится системный язык, если убрать кучу и не извиняться за это.
Модель памяти — это и есть весь язык
Большинство языковых возможностей можно обсуждать. Синтаксис, системы модулей, даже обработка ошибок — можно представить два разумных языка, которые выбирают противоположное. С моделями памяти так не получается. Выбрал одну — и всё, что идёт дальше, предопределено: твои структуры данных, модель конкурентности, FFI, сообщения об ошибках. Goose это понимает и полностью на этом строится.
Правило формулируется просто: у программы есть нативный стек вызовов, статические данные и N стеков данных, где N вычисляет компилятор. На каждом стеке одновременно живёт не более одного растущего значения, и оно всегда наверху. Этот единственный инвариант даёт поразительно много. Растущее значение никогда не перемещается — над ним нет ничего, что пришлось бы сдвигать, — поэтому ссылки на растущий массив остаются валидными навсегда. В C++ std::vector<T>, который переаллоцируется, инвалидирует все указатели и итераторы на себя, поэтому поколения C++-программистов обжигались, держа ссылку через push_back. Безопасный Rust обходит это, делая такую ситуацию невыразимой: вместо этого нужно использовать индексы u32, и цена — проверки границ и неудобство. Goose просто... позволяет сохранить ссылку. Правила типов и времени жизни проверяются на этапе компиляции: компилятор выводит, от чего «укоренена» каждая ссылка, и отвергает те, что могут пережить свой владелец, — но без аннотаций, без правил алиасинга и без эксклюзивности.
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
Если ты какое-то время работал с проверяющим заимствования в Rust, это выглядит как фокус. Куда делись времена жизни? Честный ответ: в ограничения. Система владения Rust может доказать безопасность для произвольных схем разделения — графов, циклов, разделяемых изменяемых структур между областями видимости. Goose доказывает безопасность для гораздо более узкого набора шаблонов и просто отказывается от всего остального. Этот компромисс — выразительность ради простоты — самый старый в дизайне языков, и именно за эту нить я здесь и буду тянуть. Если ты читал наш материал о том, что на самом деле означает soundness в проверяющем заимствования Rust, Goose — это обратная сторона той же монеты: безопасность достигается не более мощным анализом, а меньшим языком.
Всё внутри: раскладка данных как первоклассная возможность
Вторая большая идея в том, что единого типа массива нет. У Goose есть семейство видов массивов, которые различаются только тем, что происходит с размером: фиксированный на этапе компиляции, замороженный после построения, растущий в пределах встроенной ёмкости, только растущий, растущий и сжимающийся. Все они устроены как [метаданные][элементы...] — непрерывно, упаковано, никогда не через указатель на блок элементов. Строки — это просто массивы u8, так что структура может содержать части переменного размера, лежащие внутри в порядке объявления. Запись с полем строки переменной длины и списком вложенных записей переменной длины — это один отрезок байтов без единой косвенности.
Заявленные цифры впечатляют: запись заказа занимает 29 байт и не делает ни одной аллокации в Goose, против 160 байт и одной аллокации в C++ с std::string + std::vector<Item>, либо 153 байта и четыре аллокации в Rust. При этом в обсуждении почти сразу всплыла справедливая поправка: пример из туториала Goose про «вектор строк» не учитывает small-string optimization, которую реализует любая распространённая STL. Короткие строки в C++ не аллоцируют память. Это не убивает аргумент — SSO помогает строкам примерно до 15–22 байт и ничего не даёт в общем случае вложенных данных переменного размера, — но это напоминание, что таблицы бенчмарков на главной странице языка — это маркетинг, даже когда язык хороший. Проигрыши там тоже есть, и автору за это спасибо, а это больше, чем делают большинство проектов.
Цена настоящая, и Goose её не прячет: массив элементов переменного размера последователен. По нему можно итерироваться, но нельзя индексировать — элемент k находится там, куда попали байты. Если нужен произвольный доступ, держи второй массив ссылок как индекс. Такой компромисс люди из мира data-oriented design делали вручную десятилетиями (примерно так устроены колоночные форматы и раскладки в стиле flatbuffers), но здесь это по умолчанию, а отказ от этого требует дополнительной работы. Что это — фича или смирительная рубашка, зависит полностью от твоих паттернов доступа.
Относительные ссылки: твоя структура данных и есть её файловый формат
Моя любимая идея в Goose — относительная ссылка, и на неё тоже обратил внимание Кармак. Обычная ссылка — это адрес в памяти: восемь байт, абсолютный, бессмысленный вне процесса. Относительная ссылка хранит ту же связь как смещение в 1, 2 или 4 байта, отсчитанное либо от самого поля, либо от базы именованного пула. Структуры, построенные из самоотносительных ссылок, не зависят от позиции: они означают одно и то же, где бы ни располагались в памяти. А значит, сохранить структуру данных — это write(), а загрузить её — это read() и проход верификации.
Последняя часть важнее, чем кажется. Наивная версия этого трюка — отобразить в память файл, полный указателей, — классическая катастрофа с безопасностью. Версия Goose валидирует: from_bytes проверяет разметку, каждый тег, каждую длину, каждую связь, прежде чем байты станут значением, поэтому повреждённый или враждебный файл даёт false, а не дикий указатель. Ты получаешь скорость структур из mmap с чем-то вроде безопасности парсера.
Формулировка Кармака была точной: «„Парсинг“ файловых и сетевых данных часто в 100 раз медленнее, чем нужно». Любой, кто профилировал горячий путь десериализации, это знает. Ответ индустрии до сих пор — форматы zero-copy с описанием схемы, такие как Cap'n Proto и FlatBuffers, которые работают, но тянут в проект компилятор схем и библиотеку времени выполнения. Ответ Goose в том, что раскладка в памяти и раскладка на проводе — это одно и то же по построению, потому что у языка изначально не было указателей на абсолютные адреса. Пример бинарного дерева поиска почти дерзкий: узлы по 12 байт, включая связи, вставка — это один push и одна запись, а всё дерево сохраняется простой записью своих байтов. Лисперы узнают философский ход — схлопнуть различие, которое все считали фундаментальным, — и это часть того, почему Lisp до сих пор важен как ориентир в дизайне шестьдесят лет спустя.
Мы здесь уже бывали: регионы, арены и ML Kit
Исторически грамотная реакция на Goose появилась в комментариях вовремя: «Кажется, ребята из ML шли по этому пути. Насколько помню, они называли это регионами». Верно. Управление памятью на основе регионов уходит корнями в работу над ML Kit в 1990-х: регионный вывод Тофте и Талпина позволял компилятору ML заменить сборку мусора статически определёнными регионами в стиле стека, выделяя и освобождая целые регионы без какого-либо учёта по каждому объекту. Честный исторический вердикт таков: это признали тупиком. Регионный вывод работал, но реальные программы показали достаточно патологического роста памяти — регионы удерживали мёртвые данные живыми слишком долго, — и исследовательское сообщество в основном переключилось. Сборщики мусора сохранили корону.
Но сама идея никуда не делась — она просто сменила работу. Арены — стандартная практика в игровых движках, компиляторах и высокопроизводительных серверах. Пулы памяти Apache, арены на запрос в веб-серверах, паттерн «сбрасывай scratch-буфер каждый кадр» в игровых циклах — это регионы под другим именем, управляемые вручную, потому что язык тебе не поможет. Что именно Goose делает иначе, чем ML Kit, стоит назвать точно: вместо вывода регионов для языка общего назначения с функциональной основой (сложно и непредсказуемо) он напрямую открывает модель стека и делает намерение программиста частью типа. T[>..] означает «это растёт, ссылки остаются валидными». Задача компилятора — проверять заявленную дисциплину, а не открывать её самостоятельно. Это гораздо более простая задача, и по той же причине Rust преуспел там, где более ранние схемы автоматических времён жизни буксовали: программисты терпят контракт, который могут видеть.
Здесь есть более широкий урок о том, как продакшен-языки усваивают исследования. Вывод регионов как полностью автоматическая замена GC: провал. Регионы как ручной системный паттерн: повсеместны. Проверка заимствований: огромный успех, который сам много заимствует у идей регионов и линейных типов. Процессы Erlang без общего состояния: подтверждены, а потоки Goose — буквально они: воркеры, скомпилированные как отдельные программы со своей памятью, а значения передаются через типизированные очереди как memcpy, и гонки данных нельзя выразить. Никто не угадывает всю картину с первого раза. Идеи циркулируют десятилетиями, прежде чем обрести форму, которая работает.
Где модель ломается
Я обещал начать с контрарного тезиса, а затем признать, где он ломается. Вот это признание. Проверяющий заимствования не является обходным путём, если схемы разделения в твоей программе действительно произвольны, а огромный класс программ именно такой. Самый острый вопрос в обсуждении Goose был и самым простым: как построить хеш-таблицу, которая отображает ключи в строки, если программа вставляет и удаляет случайные строки в случайные моменты времени? Удаление — главная проблема. Стек, который только растёт, не может переиспользовать дырку в середине. Предложенные обходные пути — отдельный стек на каждый размерный класс, периодическое уплотнение, пул со свободным списком, где «освобождение» слота означает лишь, что его можно переиспользовать, — все работают, и пул-дизайн действительно умный (устаревший слот читает другое значение правильного типа, а не портит память). Но каждый из них — это программист вручную переизобретает кусок менеджера памяти для конкретной структуры данных.
Похожий момент — использование памяти. Без GC растущий стек держит всё, до чего вырос, до выхода из области видимости. Долгоживущие программы с неровными временами жизни — вопрос про JSON-документы из обсуждения, кеши, состояние сессий — либо укладываются в дисциплинированную по областям схему, либо нет, и когда не укладываются, никакая элегантность не спасёт. Провал ML Kit (мёртвые данные, закреплённые в живых регионах) в Goose никуда не исчезает: он становится видимым и ручным, что, пожалуй, лучше, но проблема остаётся. Один из комментаторов сформулировал правильную позицию: Goose — язык для ограниченной задачи, и это нормально. Рабочие нагрузки с упором на данные, компиляторы, системы игровых сущностей, всё, что имеет форму конвейера — прочитать, преобразовать, выдать, сбросить, — ложится на него отлично. Долгоживущий изменяемый граф объектов — нет.
И ещё одна оговорка к главным цифрам. Самостоятельно проведённые наборы бенчмарков — это жанр с известными провалами: ты сравниваешь свой лучший Goose с чьим-то «идиоматичным» C++, а именно на идиомах и начинаются споры. Ошибка с SSO показывает, что код сравнения не был адверсариально настроен. Цифра 1,12x над безопасным Rust интереснее, чем 3,3x, именно потому что она маленькая: она говорит, что модель памяти выигрывает за счёт раскладки и устранения аллокаций, а не магии, и настроенная версия на Rust с аренами и индексами закрыла бы значительную часть разрыва. И в этом весь смысл: ты можешь писать арены на Rust, но язык делает это неудобным, а неудобство и определяет, какой код реально пишут.

Что продакшен-языки на самом деле украдут у Goose
Почти никто не будет писать продакшен-код на Goose в ближайшие несколько лет: языки одного человека, компилирующиеся через единственный C-бэкенд, так быстро не дорастают, и автор сам говорит о статусе проекта. Но это не самый интересный вопрос. Интересно другое: какие идеи уйдут в языки, которыми мы уже пользуемся, потому что так это всегда и работает. Мои прогнозы, по убыванию уверенности:
- Структуры, не зависящие от позиции, с проверкой при загрузке. Zero-copy сериализация с проверкой безопасности слишком хороша, чтобы оставаться нишевой. Ждём более интегрированных в языки версий того, что FlatBuffers делает неуклюже, особенно по мере того как сервисы с большим объёмом данных продолжают упираться в стоимость парсинга.
- Массивы как семейство, а не как один тип. «Что происходит с размером?» — лучший организующий вопрос, чем «стек или куча?». Фиксированный, замороженный, с ограниченной ёмкостью, только растущий — эти различия существуют в каждой кодовой базе как неформальные соглашения. Превратить их в типы дёшево и ловит реальные баги.
- Функциональные значения на этапе компиляции. Goose трактует функциональные значения как сущности времени компиляции, поэтому
xs.filter() { it > 0 }компилируется в цикл, на который похож. Шаблоны C++ и мономорфизация Rust уже так делают; разница в том, что Goose делает это единственным способом, полностью убирая ловушку с указателями на функции времени выполнения. - Статически проверяемый возврат через несколько кадров.
return err from loadвозвращает из функции на N кадров выше, проверяется на этапе компиляции, без раскрутки стека, без пирамидыResultи без?на каждом вызове. Системы эффектов ходят вокруг этой же идеи с другой стороны. Одна из них приземлится в мейнстрим-языке в этом десятилетии.
Модели потоков без общего состояния тоже стоит упомянуть, в основном потому что это самая не новая и самая подтверждённая идея: это процессы Erlang, изоляты Dart, модель акторов без идеологии. Что её снова и снова переизобретают, говорит о том, что консенсус индустрии насчёт разделяемого изменяемого состояния в теории устоялся, а на практике игнорируется. Goose хотя бы делает игнорирование невозможным.
Главный аргумент: простота через ограничение
Если отойти назад, дизайн Goose — это один ход, повторённый много раз: сузить множество представимых программ, пока безопасность не станет дешёвой для доказательства. Нет кучи — нет висячих указателей на кучу. Растущие только стеки — ссылки не могут быть инвалидированы. Воркеры в отдельных потоках — гонок не бывает. Конструирование без копирования — никаких случайных временных объектов. Каждое ограничение убирает целую категорию багов и целую категорию языковых механизмов — правила алиасинга проверяющего заимствования, деструктор, раскрутку стека, — которые существовали, чтобы с этими багами справляться.
Это аргумент Роба Пайка «меньше — экспоненциально больше», снятый в перчатках, и он попадает в то же напряжение, в которое попал Go Пайка: ограничения ощущаются как освобождение, пока твоя программа не окажется той, которую ограничение исключает, и тогда они превращаются в стену. Go несколько лет обходился без дженериков и выжил; но он также исключил много корректных программ. Goose исключает гораздо больше. Причина всё равно обратить на него внимание в том, что старые правила Пайка о простоте хорошо состарились именно потому, что бюджет сложности реален: каждая гарантия, которую язык даёт автоматически, — это гарантия, которую каждый программист не должен держать в голове.
Итак: был ли проверяющий заимствования обходным путём? Для системного программирования общего назначения — нет: выразительность Rust действительно нужна для случаев произвольного разделения, а его успех дал индустрии словарь, чтобы вообще говорить о владении. Но что насчёт большого, тихого большинства системного кода, который имеет форму конвейера, арены или запроса? Goose — доказательство того, что мы могли бы иметь безопасность памяти, скорость и небольшие бинарники с долей церемоний, если бы были готовы сказать «нет» большему количеству программ. Дизайн языка — это искусство выбирать, какие программы запретить. Goose запрещает другие, нежели мы привыкли, и результат — самый интересный эскиз системного языка, который я читал за последние годы. Я не жду, что буду его выпускать. Но я жду, что буду у него continue воровать идеи.
Ставка Goose в том, что язык может запрещать больше программ, чем Rust, и всё равно оставаться полезным: бенчмарки говорят, что это стоит воспринимать всерьёз, вместе с проигрышами.


