3.3 Бутстрап языка
Один вопрос звучит как парадокс: компилятор, ассемблер, компоновщик и рантайм Go сегодня написаны на Go, так откуда же взялась первая программа, способная скомпилировать Go? Без компилятора Go — как скомпилировать компилятор Go? Это и есть бутстрап (bootstrapping). Он представляет собой одновременно и классическую дилемму курицы и яйца, и конкретный инженерный эпизод из истории инструментария Go, который можно пересказать с точностью до деталей. В этом разделе даётся ответ на три вопроса: как это яйцо было высижено впервые; как после бутстрапа работает цепочка сборки новой версии Go и почему требования к версии бутстрапа растут год от года; и наконец, почему язык, который «реализует себя средствами самого себя», заслуживает серьёзного отношения.
3.3.1 Сначала яйцо: от инструментария на C к инструментарию на Go
Предпосылка бутстрапа — сначала иметь отправную точку, которая не зависит от самой себя. Самые ранние версии инструментария Go (вплоть до Go 1.4, 2014 год) — компилятор, ассемблер, компоновщик и cmd/dist — были написаны на C, а компиляторы сохраняли именование из Plan 9 (5c, 6c, 8c, 9c и так далее, где цифры соответствовали различным целевым архитектурам). Эти программы на C компилировались системным gcc или clang, поэтому для сборки Go никакой предварительно установленный Go не требовался. Это и было тем самым «яйцом».
Начиная с Go 1.5 (август 2015), Go завершил знаковый переход: компилятор и рантайм были полностью переписаны на Go, инструментарий на C был удалён, и с этого момента инструментарий мог осуществлять бутстрап самого себя. Затраты и преимущества этого шага вполне конкретны. Преимущество состоит в том, что разработчики компилятора теперь поддерживают инструментарий на Go — языке с безопасной работой с памятью, встроенной поддержкой конкурентности, средствами тестирования и профилирования, а ошибки в компиляторе можно исследовать собственными средствами отладки Go; рантайм и компилятор используют один язык, что упрощает взаимодействие на их границе (например, escape-анализ и управление стеком). Затраты заключаются в том, что сборка Go перестала быть самодостаточной в том смысле, в каком самодостаточна компиляция программы на C: теперь для неё нужен «другой Go, который уже работает». Вопрос о том, как собрать новый Go из старого Go, стал задачей, которую каждый релиз с тех пор обязан решать.
Этот переход был осуществлён в два этапа, которые соответствуют двум проектным документам Кокса:
go13compilerописывает процесс механической транспиляции компилятора, написанного на C, в Go, аgo15bootstrapизлагает полный план удаления C и бутстрапа с использованием более старой версии Go. Скриптmake.bashдо сих пор ссылается на последний документ в своих комментариях.
3.3.2 Цепочка бутстрапа: компиляция нового Go с помощью старого Go
После бутстрапа отправной точкой для сборки новой версии Go становится более старый, уже работающий инструментарий Go, путь к которому записан в переменной окружения $GOROOT_BOOTSTRAP (по умолчанию ищется в каталоге вида $HOME/sdk/go1.24.6). Весь процесс бутстрапа управляется программой cmd/dist, которая намеренно является «сдержанной» программой на Go, использующей только те возможности языка, которые уже присутствуют в версии бутстрапа, чтобы её можно было скомпилировать непосредственно этим старым Go.
Бутстрап — это не «скомпилировал один раз» и готово, а три последовательных раунда компиляции, каждый из которых нельзя пропустить. Схематично цепочка выглядит так:
flowchart TD
BOOT["Старый Go для бутстрапа<br/>($GOROOT_BOOTSTRAP, ≥ go1.24.6)"] --> DIST["Компиляция cmd/dist<br/>(с помощью старого Go)"]
DIST --> TC1["toolchain1<br/>новый компилятор/ассемблер/компоновщик<br/>собран старым Go, без build ID"]
TC1 --> GOBOOT["go_bootstrap<br/>новый cmd/go, собран toolchain1"]
GOBOOT --> TC2["toolchain2<br/>пересобран toolchain1 + go_bootstrap<br/>семантически эквивалентен, быстрее, с build ID"]
TC2 --> TC3["toolchain3<br/>пересобран toolchain2<br/>build ID стабилен, пригоден для кэширования"]
TC3 --> STD["сборка стандартной библиотеки и всех команд финальным инструментарием"]
Распределение работы между тремя раундами — суть этой схемы, и его стоит разобрать уровень за уровнем:
- toolchain1:
cmd/distсначала компилирует новый исходный код компилятора, ассемблера, компоновщика и т.д. с помощью командыgoиз старой версии бутстрапа. Этот инструментарий по поведению уже является новой версией, но поскольку он собран старой командойgo, бинарные файлы не содержат build ID. - go_bootstrap: затем toolchain1 компилирует временную команду
go. С этого момента управление сборкой передаётся отcmd/distобратно к самой командеgo. - toolchain2: с помощью toolchain1 и go_bootstrap тот же инструментарий компилируется повторно. Он семантически эквивалентен toolchain1, но поскольку собран уже новым компилятором (а не старым бутстрап-компилятором), он работает быстрее и содержит build ID.
- toolchain3: toolchain2 затем компилирует финальную версию. К этому моменту инструментарий представляет собой «новый компилятор, собранный новым компилятором, который был собран с нуля», его build ID точен и стабилен, что делает его пригодным для кэша сборки.
Зачем более одного раунда? Ключ — в build ID и воспроизводимости. toolchain1 — это продукт «нового исходного кода плюс старый компилятор»: этого достаточно для бутстрапа, но он несёт на себе следы старого бутстрап-окружения (отсутствие build ID). Два дополнительных раунда позволяют финальному инструментарию полностью избавиться от влияния бутстрап-версии Go: toolchain3 — это новый инструментарий, собранный новым инструментарием, без каких-либо связей со старым Go, которым он был изначально собран. Это одновременно служит и самопроверкой: если в новом компиляторе есть дефект, он часто проявляется на этапе toolchain2 или toolchain3.
За этим скрыто тонкое, но важное соображение: toolchain2 и toolchain3 полностью идентичны на уровне исходного кода, и в идеале оба должны быть побайтово идентичны. Причина, по которой они разделены на два этапа, состоит в следующем: toolchain2 — это «новый компилятор, скомпилированный старым компилятором и скомпилированный ещё раз», тогда как toolchain3 — это «новый компилятор, компилирующий сам себя». Только когда результаты последних двух этапов сходятся, можно быть уверенным, что новый компилятор не привнёс в выходные данные скрытых различий, зависящих от того, «кто его компилировал». Это классическая неподвижная точка компилятора (compiler fixed point): корректный бутстрап-компилятор, компилирующий себя повторно после некоторого числа итераций, должен давать стабильный результат. Go приближается к этой неподвижной точке с помощью build ID и идентификаторов версий в релизных сборках, так что финальный бинарный файл можно кэшировать и воспроизводить.
Деталь реализации, которую легко упустить: cmd/dist не компилирует «на месте», а копирует исходный код, необходимый для бутстрапа (cmd/compile, cmd/asm, cmd/link и их зависимости), в рабочее пространство $GOROOT/pkg/bootstrap и единообразно переписывает их пути импорта с префиксом bootstrap/.... Таким образом, старый Go компилирует копию, изолированную от финальной стандартной библиотеки, и два одноимённых пакета — старый и новый — не загрязняют друг друга в процессе бутстрапа.
Точка входа во всю цепочку — make.bash (all.bash дополнительно запускает раунд тестов поверх неё). В общих чертах один запуск make.bash выполняет следующую последовательность действий: проверка наличия и обнаружение $GOROOT_BOOTSTRAP, использование его для сборки cmd/dist, выполнение cmd/dist для прохождения трёх раундов от toolchain1 до toolchain3, и наконец — сборка остальной стандартной библиотеки и команд финальным инструментарием. Эти шаги видны невооружённым глазом в терминале, и их вывод практически дословно повторяет диаграмму выше:
|
|
Каждая строка соответствует одному звену цепочки: сначала старый бутстрап-Go собирает cmd/dist, затем последовательно поднимает toolchain1, временную команду go (go_bootstrap), toolchain2, toolchain3, и наконец финальный инструментарий, теперь стоящий на твёрдой основе, собирает стандартную библиотеку и команды. Прочитайте этот вывод — и бутстрап перестанет быть абстрактным понятием: это несколько строк переходов состояний в терминале, которые можно пересказать.
3.3.3 Постоянно растущая версия бутстрапа
Бутстрап развязал руки разработчикам инструментария: они могут писать сам Go, используя всё более новые возможности языка. Обратная сторона приходит вместе с этим: как только в исходном коде инструментария используется возможность, доступная лишь в некоторой более новой версии (типичный пример — дженерики), минимальная версия Go, пригодная для бутстрапа, вынуждена подниматься соответственно. Рост этой планки версии бутстрапа сам по себе является профилем эволюции Go:
| Целевая версия Go | Минимальная версия для бутстрапа |
|---|---|
| Go ≤ 1.4 | Инструментарий на C (gcc / clang) |
| Go 1.5 ~ 1.19 | Go 1.4 |
| Go 1.20 ~ 1.21 | Go 1.17 (а именно 1.17.13) |
| Go 1.22 ~ 1.23 | Go 1.20 |
| Go 1.24 и далее | определяется формулой |
Начиная с Go 1.20 (2023), базовая версия бутстрапа официально распрощалась с Go 1.4, на который она опиралась почти восемь лет. Компромисс, стоящий за этим решением, зафиксирован в issues 44505 и 54265: привязка к Go 1.4 означала, что инструментарий никогда не мог использовать для написания самого себя ни одну возможность языка, появившуюся после Go 1.5, и это ограничение всё меньше себя оправдывало. Начиная с Go 1.24, требование больше не прописывается вручную для каждой версии, а сводится к единому правилу: для сборки Go 1.N требуется Go 1.M, где , округлённое вниз до чётного числа. Таким образом, Go 1.24 и 1.25 оба требуют Go 1.22, а Go 1.26 и 1.27 — Go 1.24. Эта формула в точности соответствует requiredBootstrapVersion в cmd/dist:
|
|
Начиная с Go 1.26, минимальная версия бутстрапа дополнительно привязана к конкретному номеру патча — go1.24.6. Если кто-то запустит make.bash с более старым Go, сборка завершится ошибкой прямо при компиляции cmd/dist: дерево исходных файлов специально размещает файл, который участвует в компиляции только при //go:build !go1.24, с именем пакета building_Go_requires_Go_1_24_6_or_later, используя ошибку «cannot find a valid main package», чтобы сообщить сборщику о недостаточной версии как можно раньше и как можно заметнее.
Запас «N-2, затем округление до чётного», который оставляет формула, выбран не произвольно: он гарантирует, что любой релиз всегда может быть собран стабильной версией, выпущенной двумя-тремя циклами ранее и уже широко распространённой, так что мейнтейнерам дистрибутивов не нужно сначала добывать чрезмерно новый Go только для того, чтобы собрать новый Go. Это конкретное выражение заботы инструментария об экосистеме, которое раскрывается в следующем разделе.
3.3.4 Почему бутстрап имеет значение
Бутстрап — это не просто техническая деталь; он имеет три уровня конкретного значения.
Это сигнал зрелости языка. То, что язык способен реализовать себя средствами самого себя, — в особенности реализовать системное программное обеспечение вроде компиляторов и рантаймов, предъявляющее жёсткие требования к производительности и низкоуровневому контролю, — свидетельствует о том, что и его абстрактная мощность, и эффективность рантайма уже достаточны для серьёзной работы. Go не одинок на этом пути. Self-hosting — это практически обряд инициации для зрелости системного языка: rustc в Rust аналогично осуществляет бутстрап из предыдущей версии Rust и, как и Go, вынужден управлять ограничением «насколько старая версия может выполнять бутстрап»; GCC и LLVM также давно используют раунд самокомпиляции для проверки того, что компилятор способен скомпилировать сам себя. Различие состоит в том, куда каждый проект помещает «отправную точку»: Go предпочитает фиксировать отправную точку на публично распространяемом старом релизе, а не поддерживать, как некоторые инструментарии, первоначальный путь бутстрапа, прослеживаемый до ассемблера. У каждого из двух подходов свои компромиссы: первый прост и воспроизводим, но требует, чтобы версия бутстрапа двигалась вместе с проектом; второй более основателен в плане «возможности пересборки из самой примитивной отправной точки», но ценой поддержки этого древнего пути.
Он унифицирует опыт разработки. Авторы инструментария и обычные пользователи используют один и тот же язык и один и тот же набор инструментов. Проблемы в компиляторе можно исследовать непосредственно с помощью средств тестирования, детектора гонок и профилирования Go, а новые возможности языка могут быть «опробованы» самим инструментарием при первой возможности: включение дженериков в требования бутстрапа — именно результат такого самоприменения.
Он также накладывает ответственность. Инструментарий всегда должен допускать бутстрап из достаточно старой версии Go и не может ради удобства зависеть от слишком новых возможностей — иначе «сборка Go из исходников» стала бы затруднительной: именно для этого существует формула N-2, рассмотренная в предыдущем разделе. Эта ответственность ведёт и к более глубокой теме: Томпсон в работе Reflections on Trusting Trust указал, что самокомпилирующийся инструментарий теоретически может передавать бэкдор из поколения в поколение, не оставляя следов в исходном коде. Go ставит это доверие на независимо верифицируемую основу с помощью многораундовых воспроизводимых сборок, публичной цепочки бутстрапа и проверяемых бинарных файлов: удобство и надёжность бутстрапа необходимо оберегать совместно, с помощью инженерной дисциплины.
От яйца, написанного на C, к инструментарию, написанному на Go, ограниченному в версии бутстрапа формулой, компилирующему себя в три раунда ради воспроизводимости, — эта история бутстрапа является лучшей сноской к тому, как Go «доказывает себя средствами самого себя».
Дополнительное чтение
- The Go Authors. Go 1.5 Release Notes (компилятор и рантайм переписаны на Go, инструментарий достигает самосборки). https://go.dev/doc/go1.5
- Russ Cox. Go 1.5 Bootstrap Plan (
go15bootstrap, полный план удаления C и бутстрапа с использованием более старого Go). https://go.googlesource.com/proposal/+/master/design/go15bootstrap - Russ Cox. Go 1.3+ Compiler Overhaul (
go13compiler, механическая транспиляция компилятора с C на Go). https://go.googlesource.com/proposal/+/master/design/go13compiler - The Go Authors. Installing Go from source / Bootstrap toolchain (требования к версии бутстрапа и правило N-2). https://go.dev/doc/install/source
- The Go Authors. cmd/dist:
buildtool.go(minBootstrap, перезапись путей импорта) иbuild.go(requiredBootstrapVersion, toolchain1~3). https://github.com/golang/go/tree/master/src/cmd/dist - Go issue #44505, #54265: Почему базовая версия бутстрапа мигрировала с Go 1.4 на более новую версию. https://go.dev/issue/44505 , https://go.dev/issue/54265
- Ken Thompson. Reflections on Trusting Trust. Communications of the ACM, 1984. https://dl.acm.org/doi/10.1145/358198.358210