Ты не бойся, это Goose

Введение
Язык и компилятор Goose спроектировал Wouter van Oortmerssen. Согласно странице проекта, реализация компилятора, бенчмарки, примеры и документация созданы с помощью ИИ-модели Claude Fable. В чём автор был замечен ранее: ЯП 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 быстрее.
Бенчмарк | Goose, мс | Expert C++, мс | Gap | Safe Rust, мс | Gap |
|---|---|---|---|---|---|
441,0 | 625,5 | 1,42× | 891,8 | 2,02× | |
67,8 | 93,8 | 1,38× | 87,5 | 1,29× | |
1199,5 | 1987,8 | 1,66× | 2084,0 | 1,74× | |
203,8 | 406,9 | 2,00× | 235,4 | 1,16× |
В таблице памяти gap показывает, во сколько раз рабочий набор Goose меньше.
Бенчмарк | Goose, МБ | Expert C++, МБ | Gap | Safe Rust, МБ | Gap |
|---|---|---|---|---|---|
149,0 | 556,9 | 3,74× | 593,4 | 3,98× | |
28,0 | 63,4 | 2,26× | 57,0 | 2,04× | |
535,6 | 638,4 | 1,19× | 675,9 | 1,26× | |
31,8 | 35,8 | 1,13× | 31,5 | на 1% больше |
Под 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] обеспечивает произвольный доступ.
Официальный пример с четырёхбайтовым индексом.
Итак, переходим на Goose?
Вынесем за скобки зрелость проекта. Стоит ли переходить, да или нет? Нет. Или да. Зависит от контекста.
Преимущества Goose лучше всего раскрываются в программах, где данные создаются группами, некоторое время обрабатываются, а затем освобождаются целиком, как то:
Парсеры, компиляторы
Пакетная обработка
Скорее всего, Goose будет неудобен там, где нужны:
Общая изменяемая память, atomics или lock-free структуры
Передача больших структур между потоками без копирования
Долгоживущие графы, в которых требуется автоматически обнаруживать обращения к удалённым или повторно использованным узлам:
slot_pool<T>позволяет переиспользовать слоты, но контроль логического use-after-free и double free остаётся на приложении
Ну то есть Goose – не замена C++ или Rust, а язык для отдельных задач, где максимум можно выжать за счёт компоновки данных и управления памятью.
Источники и дополнительные материалы
Goose
Контекст
Модели памяти и конкурентности
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.