Содержание
Введение
Язык и компилятор Goose спроектировал Wouter van Oortmerssen. Согласно странице проекта, реализация компилятора, бенчмарки, примеры и документация созданы с помощью ИИ-модели Claude Fable (“This repo was produced almost entirely with AI, from a human language design”). В чём автор был замечен ранее: ЯП Lobster, разработка которого началась в 2010 году, и библиотека сериализации FlatBuffers, опубликованная Google в 2014-м.
Бэкграунд у автора вполне солидный, посмотрим же на “гуся” в деталях.
Основная фишка Goose связана с управлением памятью. К системе управления памятью можно предъявить три нефункциональных требования на букву “Б”:
Безопасно
Без затей: управление памятью не требует сложных действий типа явных аннотаций времени жизни
Быстро: выделение и освобождение памяти требуют минимальной и предсказуемой работы во время выполнения
В грубом приближении некоторые языки, с которыми я знаком, расставляют акценты так:
Язык | Безопасно | Без затей | Быстро |
|---|---|---|---|
Go | ◐ | ⬤ | ◔ |
JavaScript | ◐ | ⬤ | ◔ |
Dart | ⬤ | ⬤ | ◔ |
Rust | ⬤ | ◯ | ⬤ |
⬤ – да; ◐ – с оговорками; ◔ – лишь частично; ◯ – нет.
Пояснения к таблице:
Go и JavaScript: без затей и с базовыми гарантиями безопасности памяти. В обычном Go-коде без
unsafeи в управляемой памяти JavaScript исключены проблемы типа dangling pointers и use-after-free, но в обоих языках возможны гонки данных: в Go – между goroutines, работающими с общей памятью, в JavaScript – между agents (например, Web Workers), обращающимися к одномуSharedArrayBuffer. Выделение памяти в системах с GC обычно дёшево, но поиск и освобождение недостижимых объектов расходуют процессорное время и могут создавать latency spikes. То есть требование 1 выполняется частично, 2 – да, 3 – с проблемами.Dart: без затей и с гарантиями. Гонки данных над управляемой памятью в чистом Dart-коде исключены моделью isolates. Понятно, эта гарантия не распространяется на FFI и JavaScript-интероп. Есть GC, со всеми плюсами и минусами. То есть требования 1 и 2 выполняются, а 3 – частично.
Rust: safe Rust обеспечивает безопасность памяти и исключает гонки данных, при этом работает без GC. За статические гарантии приходится платить “магическими рунами” в тексте:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b } }. Ещё больше “затей” возникает с замыканиями; в своё время я разбирал эту тему и предлагал упрощение. Выходит, что требования 1 и 3 выполняются, а 2 – нет.
Тут напрашивается очередная неформальная трилемма: “одновременно можно получить максимум два качества из трёх”.
Goose пытается сломать этот шаблон, предлагая все три качества и даже добавляя четвёртое: “Бережно”, то есть с минимальными расходами памяти на служебные данные.
Как это работает
Проще всего представить управление памятью Goose как арены. Кто писал, к примеру, на Go или Rust, тот знает про такую концепцию. Данные с общим временем жизни здесь освобождаются вместе. Каждая такая логическая арена может использовать несколько data stacks – независимых линейных аллокаторов. У каждого стека есть курсор выделения top, то есть bump-указатель на следующий свободный байт. Компилятор заранее вычисляет, сколько data stacks одновременно понадобится программе.
Концептуально передача этого числа в runtime выглядит так:
// Компилятор рассчитал, что программе нужны три data stacks. const int64_t N = 3; // Runtime создаёт для них память. gs_stack_block(N);
Runtime получает готовое число и резервирует по одной области памяти для каждого data stack (код сильно упрощён):
static void gs_stack_block(int64_t n) { // Создаём N описателей. Сама память стеков здесь ещё не резервируется. gs_stks = calloc(n > 0 ? n : 1, sizeof(gs_stack)); // Для каждого data stack резервируем отдельную область памяти. for (int64_t i = 0; i < n; i++) { gs_stks[i].top = gs_reserve_region(&gs_stks[i]); } gs_nstks = n; }
Итого: компилятор считает N, а runtime N раз вызывает gs_reserve_region. Эта функция резервирует большой диапазон виртуальных адресов: в Linux через mmap, в Windows через VirtualAlloc. По умолчанию на каждый data stack резервируется 2 ГБ адресного пространства. Реальная память подключается по мере использования. Guard-область в конце останавливает переполнение. Исходный код runtime.
Выделение памяти внутри data stack – это запись по адресу top и сдвиг top вперёд. Например, a сразу создаётся литералом, b строится циклом, а затем растёт c:
fn make_b() -> i32[] { var result: i32[>..] = []; for i: i32 in 5 { result.push(i); } return result; } fn main() { var a: i32[] = [1, 2, 3]; var b: i32[] = make_b(); var c: i32[>..] = []; for i: i32 in 1000 { c.push(i); } print(a.len, b.len, c.len); }
Внутри make_b массив result растёт обычным циклом. Компилятор строит возвращаемый i32[] прямо на месте b, без промежуточной копии. Готовый b больше не resizable, поэтому поверх него можно разместить растущий c:
data stack 0: [ a ][ b ][ c растёт............. ] -> top
Массив c находится на вершине стека, поэтому может расти простым сдвигом top и никуда не переезжает. Правило Goose: на одном стеке может находиться только один “живой” resizable-массив. Если одновременно живут три таких массива, компилятор использует три стека:
var a: i32[>..] = []; var b: i32[>..] = []; var c: i32[>..] = []; for i: i32 in 1000 { a.push(i); b.push(i); c.push(i); }
data stack 0: [ a растёт................ ] -> top data stack 1: [ b растёт................ ] -> top data stack 2: [ c растёт................ ] -> top
Итого: Goose заранее рассчитывает необходимое число data stacks, быстро наращивает их bump-указатели и массово освобождает данные вместе с областью видимости. Формальные правила описаны в спецификации Goose.
Где компилятор бьёт по рукам
За простую модель памяти приходится платить ограничениями. Ниже – несколько характерных примеров.
Растущий массив должен быть последним полем обычной структуры. Иначе его рост затрёт следующее поле:
struct S { tail: i64[>..], after: i64, }
error: resizable field tail of struct S must be the final field
Ссылка или срез не может пережить данные, на которые указывает:
fn bad() -> i64[:] { var local: i64[1] = [42]; return local[..]; }
error: returning a reference rooted in local, which dies with this function
Нельзя уменьшать массив, пока ещё используется срез его данных:
var buf: u8[>..] = [65]; let s: const u8[:] = buf; buf.pop(); print(s);
error: cannot pop buf while s is still used
Наконец, нельзя повторно растить массив, пока на его вершине ещё строится новый элемент:
fn make(words: u8[][>..]&) -> u8[] { words.push("side effect"); return "result"; } var words: u8[][>..] = []; words.push(make(words));
error: cannot call make, which grows words: the element pushed into words is still under construction, and the growth would land inside it
За что боролись (магия)
Ниже – бенчмарки, в которых самый быстрый вариант Goose под Clang опередил и лучший safe Rust, и оптимизированный вручную C++ больше чем на 10%. Время измерено на размере large; память – пиковый рабочий набор (peak working set) той же реализации.
В таблице скорости gap показывает, во сколько раз Goose быстрее.
В таблице памяти gap показывает, во сколько раз рабочий набор Goose меньше.
Под expert C++ автор понимает не особые флаги компилятора, а вручную оптимизированное представление данных: арены с индексами вместо отдельных объектов, tagged union вместо иерархии с виртуальными методами, собственную open-addressing таблицу вместо unordered_map. Для Rust отдельной категории expert нет: все реализации остаются safe, но при наличии нескольких вариантов для сравнения выбирается самый быстрый. Поэтому быстрый Rust здесь тоже не всегда “наивный”: для деревьев это рекомендуемая Vec-арена с индексами u32, а для words – написанная вручную open-addressing таблица. Rust собран с -O -C codegen-units=1. Описание вариантов Rust.
recordsстроит журнал из 16 миллионов variant-записей и четыре раза его обходит. Goose использует встроенный variable-size enum: каждый элемент занимает место только своего варианта, а короткий текст хранится inline. Expert-вариант C++ вручную заменяетstd::stringвстроенным буфером, а Rust использует обычныеenumиString; при этом каждый элемент Rust всё равно имеет размер крупнейшего варианта. Здесь выигрыш Goose получается из возможностей языка, без собственного аллокатора или ручной упаковки записей.interpстроит дерево выражений и восемь раз его вычисляет. Goose хранит листья и внутренние узлы разного размера в одном пуле и связывает их типизированными относительными ссылками. Быстрые C++ и Rust тоже переходят на арену, но хранят узлы фиксированного максимального размера и связывают их числовыми индексами. Пул в Goose указан явно, зато код не реализует индексацию и проверку принадлежности ссылок вручную.wordsсчитает частоты в тексте из 16 миллионов слов. Здесь важно не преувеличивать: самый быстрый вариант Goose, как и варианты C++ и Rust, содержит написанную вручную open-addressing таблицу. Однако вариант Goose со стандартнымdictionaryвсё равно показал 1473,4 мс и 423,6 МБ – быстрее приведённых в таблице C++ и Rust и экономнее по памяти. То есть ручная оптимизация увеличила преимущество, но не создала его с нуля.bintreesмногократно строит и выбрасывает бинарные деревья. Во всех трёх быстрых вариантах применяется арена. В Rust этоVecс индексамиu32и ручнымclear(), в C++ –vector-арена, а в Goose – локальный массив с типизированными ссылками, который освобождается сбросомtopпри выходе из функции. Goose всё ещё требует выбрать структуру в виде пула, но не требует писать отдельную схему владения и освобождения каждого узла.
Goose часто выражает плотную компоновку, арены и ссылки между их элементами непосредственно средствами языка. Там, где в C++ и safe Rust приходится вручную переходить к буферам, индексам и специальным контейнерам, Goose требует заметно меньше вспомогательного кода. Бенчмарки отобраны только по скорости, а таблица памяти показывает даже и проигрыш: в bintrees Goose занял 31,8 МБ против 31,5 МБ у Rust.
Саморазоблачение (бенчмарки)
Тут надо сразу отметить, что автор Goose добросовестно указывает на проигрыши Goose, шум и ограничения методики. Поэтому “разоблачение” здесь относится скорее к текущему “информационному шуму” вокруг Goose.
В таблицы выше сознательно вошли только четыре убедительных выигрыша Goose, а не весь набор из 16 бенчмарков. Они показывают задачи, на которых модель памяти Goose раскрывается лучше всего, но не среднюю производительность языка.
Обобщённая таблица автора охватывает все 16 бенчмарков. В ней итоговые показатели скорости и памяти рассчитаны независимо и могут относиться к разным реализациям Goose, поэтому их нельзя воспринимать как характеристики одной программы.
Все измерения выполнены на одном компьютере без закрепления процесса за ядрами. Сам автор считает шумом различия менее чем в 10%, а для
graphиlru– менее чем в 15%. Поэтому небольшие преимущества в отдельных строках нельзя считать доказанным превосходством языка.Goose выигрывает не везде:
calcбыстрее на Rust,lruпроигрывает Rust при компиляции через MSVC и выигрывает через Clang, аsceneпроигрывает под MSVC и примерно равен Rust под Clang. Плоские численные вычисления вparticlesпрактически одинаковы во всех трёх языках.
Цена вопроса: замыкания
В Goose можно передать именованную функцию:
fn each_pair<T, F>(xs: T[:]) { for i in 0..xs.len - 1 { F(xs[i], xs[i + 1]); } } fn print_pair(a: i64, b: i64) { print(a, " ", b); } fn main() { var xs: i64[] = [4, 8, 15, 16, 23, 42]; each_pair(xs, print_pair); }
F связывается с print_pair на этапе компиляции. Компилятор создаёт специализацию each_pair для этой функции, а вызов F(...) превращает в прямой вызов или встраивает в цикл. Function values существуют только во время компиляции: это не closure-объекты и не указатели на функции.
Вложенные функции и блоки могут захватывать локальные переменные, но не могут покинуть функцию, в которой объявлены. Function value нельзя сохранить в переменной или структуре, положить в массив, выбрать через if или вернуть из функции. Поэтому обычную фабрику замыканий написать не получится:
fn make_counter() { var n = 0; fn next() -> i64 { n++; return n; } return next; }
error: function values cannot be returned
Цена такой модели – нельзя создавать долгоживущие callbacks, хранить обработчики событий или выбирать реализацию во время выполнения. Зато для map, filter, sort и похожих функций не нужны closure-объекты, косвенные вызовы и дополнительные аллокации. Подробнее это описано в спецификации Goose; ошибка проверяется отдельным тестом компилятора.
Цена вопроса: передача данных в потоки
Goose исключает data races в чистом коде весьма нехитрым способом: у потоков нет общей изменяемой памяти. Функция, объявленная как thread_fn, вместе со всем вызываемым ею кодом компилируется как отдельная программа со своими data stacks и собственными копиями глобальных переменных.
Например, worker увеличит только свою копию counter, поэтому main по-прежнему напечатает 0:
var counter = 0; thread_fn increment() { counter++; } fn main() { thread_wait(thread_spawn(increment)); print(counter); // 0 }
Задания и результаты передаются через типизированные очереди qput<T> и qget<T>. Через очередь могут проходить только плоские значения без ссылок и срезов: они лежат в памяти непрерывно и копируются одним memcpy. Поэтому языку не нужны locks, atomics и memory ordering. Подробнее это показано в документации Goose.
struct Job { n: i32 } struct Result { n: i32 } thread_fn worker() { let job = qget<Job>(); qput(Result { n: job.n * 2 }); } fn main() { let id = thread_spawn(worker); qput(Job { n: 21 }); let result = qget<Result>(); thread_wait(id); print(result.n); // 42 }
Цена такой гарантии – нельзя разделить между потоками изменяемый кеш или граф и нельзя передать ссылку на уже построенную структуру. Например, такой код не скомпилируется:
thread_fn worker(value: i64&) { print(value); } fn main() { var value = 42; thread_spawn(worker, &value); }
error: thread_fn parameters must be flat, not i64&
Цена вопроса: индексирование массивов
Если элементы имеют разный размер, у массива нет фиксированного шага. Чтобы найти entries[i], нужно прочитать размеры всех предыдущих элементов:
struct Entry { text: u8[], } fn main() { var entries: Entry[>..] = []; entries.push(Entry { text: "a" }); entries.push(Entry { text: "much longer" }); for entry in entries { print(entry.text); } // работает print(entries[1].text); // ошибка }
error: arrays of variable-size elements cannot be indexed, only iterated; for random access, keep your own array of references to them
Goose не создаёт скрытую таблицу смещений: массив с элементами разного размера можно последовательно обходить, но нельзя индексировать. Если нужен произвольный доступ, индекс нужно поддерживать явно в виде второго массива ссылок:
struct Entry { text: u8[], } fn main() { var entries: Entry[>..] = []; var entry_at: (Entry&)[>..] = []; entry_at.push(entries.push(Entry { text: "a" })); entry_at.push(entries.push(Entry { text: "much longer" })); print(entry_at[1].text); // "much longer" }
Таким образом, данные остаются плотно упакованными, а entry_at[i] обеспечивает произвольный доступ.
Официальный пример с четырёхбайтовым индексом.
Можно ли повторить это на C++?
Of course. Транспайлер Goose сам генерирует C, поэтому недоступной для C++ runtime-магии здесь нет. Можно воспроизвести основные свойства представления данных Goose:
плотные записи переменного размера без отдельной аллокации каждого объекта;
стабильные адреса при росте;
четырёхбайтовые ссылки, хранящие смещение внутри корневого массива или относительно базы заданного pool;
выделение сдвигом одного указателя;
массовое освобождение откатом
top;отдельный индекс только там, где действительно нужен произвольный доступ.
Но появляется “цена вопроса”, и она велика – это уже будет не обычный C++ со стандартными контейнерами. Для точного воспроизведения Goose потребуются собственные контейнеры, относительные ссылки, итераторы и индексы. Схема Goose с большим резервом виртуальных адресов и подключением физических страниц по мере роста потребует также обёртки над mmap или VirtualAlloc.
Компромиссы останутся теми же:
отдельное выделение нельзя произвольно освободить, а массовый откат не вызывает деструкторы;
для нескольких одновременно растущих массивов нужно несколько линейных аллокаторов или иная организация данных;
компилятор C++ не проверит, что ссылка не пережила arena, массив находится на вершине аллокатора, а относительное смещение указывает в правильный буфер.
Итого: C++ позволяет получить такое же представление данных и стоимость операций с памятью, но инварианты придётся соблюдать вручную. Чтобы получить ещё и статические гарантии Goose, понадобится собственный анализатор или генератор кода.
Можно ли повторить это на Rust?
Тут такая же ситуация – вместо стандартных контейнеров придётся написать собственные арены, линейные аллокаторы, относительные ссылки и упакованные буферы.
Главное отличие – низкоуровневую реализацию можно спрятать за safe API. Rust проверит, что ссылка не пережила arena, если это выражено через типы и lifetimes. Но borrow checker не докажет корректность числового смещения, положение массива на вершине аллокатора или безопасность отката top. За это отвечает автор unsafe-реализации.
Специфические компромиссы:
обычный
Vecнельзя наращивать, пока жива ссылка на его элемент;Goose допускает изменяемые alias, несовместимые с уникальностью
&mut T;откат
topне вызываетDrop, поэтому освобождение внешних ресурсов требует отдельного обхода;ссылки частично придётся заменить на handles,
Cell/RefCellили сырые указатели.
Итого: Rust позволяет локализовать небезопасный код лучше C++, но саму подсистему управления памятью Goose всё равно придётся реализовать вручную. В Goose эти ограничения проверяет компилятор языка.
Итак, переходим на Goose?
Вынесем за скобки зрелость проекта. Стоит ли переходить, да или нет? Нет. Или да. Зависит от контекста.
Преимущества Goose лучше всего раскрываются в программах, где данные создаются группами, некоторое время обрабатываются, а затем освобождаются целиком, как то:
Парсеры, компиляторы
Пакетная обработка
Скорее всего, Goose будет неудобен там, где нужны:
Общая изменяемая память, atomics или lock-free структуры
Передача больших структур между потоками без копирования
Долгоживущие графы, в которых требуется автоматически обнаруживать обращения к удалённым или повторно использованным узлам:
slot_pool<T>позволяет переиспользовать слоты, но контроль логического use-after-free и double free остаётся на приложении
Ну то есть Goose – не замена C++ или Rust, а язык для отдельных задач, где максимум можно выжать за счёт компоновки данных и управления памятью.

