3.1 Начинаем с команды go
Жизненный цикл программы на Go начинается с запуска команды go. Команды go build, go test
и go run, которые читатель набирает каждый день, на первый взгляд лишь «превращают исходный код
в бинарный файл», но за ними скрывается факт, который часто понимают неверно: go сама по себе
не является компилятором. Это оркестратор сборки, который разбивает единый процесс на множество
отдельных шагов, определяет их порядок и степень параллелизма, а затем последовательно вызывает
настоящие инструменты: компилятор compile (3.2), ассемблер asm и линкер link
(3.4). В этом разделе мы сначала проясним саму схему оркестрации — она задаёт
структуру для всех последующих разделов: поняв, как go декомпозирует сборку в граф и как
использует кэш на основе содержимого для устранения повторной работы, читатель получит опору
для погружения в детали компиляции и линковки.
3.1.1 go — это оркестратор сборки, а не компилятор
Самый наглядный способ увидеть процесс сборки минимальной программы — go build -x: эта опция
выводит каждую подкоманду, которую go фактически выполняет. Для пакета main с единственным
вызовом fmt.Println скелет вывода выглядит так:
|
|
Всё, что делает go сама, — создаёт временную рабочую директорию, записывает набор файлов
importcfg (которые сообщают компилятору и линкеру, где на диске находится архив каждого
зависимого пакета), а затем через exec запускает два независимых исполняемых файла — compile
и link. Реальное преобразование исходных файлов в объектный код происходит в инструментах,
внешних по отношению к go. Эта структура — «главная программа лишь оркестрирует, а конкретную
работу передаёт независимым инструментам» — выражена внутри команды go двумя типами данных:
Builder хранит общее состояние всей сборки (рабочая директория, различные кэши, управление
параллелизмом), а Action представляет узел графа сборки, описывая «шаг, который необходимо
выполнить»:
|
|
Поля Mode, Deps и Actor структуры Action вместе отвечают на вопросы: «что это за шаг, от
чего он зависит и кто его выполняет». Одна команда go build — это не прямая линия, а граф из
таких узлов, который go строит и затем обходит. В следующем разделе мы рассмотрим, как этот граф
вырастает из go.mod и исходного кода.
3.1.2 От go.mod к графу действий
Первый этап сборки — выяснить, «какие пакеты нужно скомпилировать и кто среди них от кого зависит».
Ответ даёт система модулей: go разбирает go.mod, определяет точную версию каждой зависимости
по алгоритму Minimal Version Selection (MVS) и загружает граф зависимостей пакетов (подробности
разрешения модулей изложены в главе 17). Граф
зависимостей пакетов — это входные данные; go должна преобразовать его в граф действий
(action graph), то есть в направленный ациклический граф (DAG) из узлов Action.
Правила преобразования просты до механичности. Для каждого собираемого пакета рекурсивно создаётся
шаг компиляции для каждого из его импортов в качестве зависимости текущего шага; когда встречается
пакет main, вместо этого создаётся шаг линковки:
|
|
Когда рекурсия завершается, вырисовывается весь граф действий: листья — это пакеты без
зависимостей (для их компиляции нужен только их исходный код), корень — шаг линковки. Линковка
зависит от компиляции пакета main, а компиляция пакета main, в свою очередь, зависит от
компиляции каждого импортируемого им пакета, слой за слоем вниз. Проверка на main внутри
AutoAction — это именно та граница на графе сборки, которая разделяет разделы
3.2 Компиляция и 3.4 Линковка.
После построения графа его исполнение осуществляется функцией Builder.Do. Ключевое ограничение
здесь — топологический порядок: шаг может начаться только после завершения всех его
зависимостей. go не выполняет полную топологическую сортировку, а вместо этого поддерживает
счётчик pending для каждого Action (число ещё не завершённых зависимостей), уменьшает pending
у соответствующих triggers сразу после завершения зависимости, и шаг, счётчик которого
достигает нуля, попадает в очередь готовых, упорядоченную по priority. Несколько взаимно
независимых шагов могут выполняться параллельно; степень параллелизма контролируется флагом -p
(по умолчанию равным GOMAXPROCS) через семафор readySema. Всю схему планирования можно
представить так:
flowchart TD
MOD["Разбор go.mod / MVS<br/>загрузка графа зависимостей пакетов"] --> GRAPH["Построение графа действий (DAG)<br/>рекурсия CompileAction + AutoAction выбирает link"]
GRAPH --> DO["Builder.Do управляет выполнением"]
DO --> READY{"Есть шаг с pending=0?"}
READY -->|"Да, параллельно под семафором -p"| RUN["Выполнение: пропуск при попадании в кэш<br/>иначе exec compile / link"]
RUN --> DEC["После завершения уменьшить pending у triggers"]
DEC --> READY
READY -->|"Нет, всё выполнено"| END["Сборка завершена"]
Отдельного внимания заслуживает шаг «пропуск при попадании в кэш» на диаграмме: большинство узлов
графа действий при большинстве сборок вообще не вызывают компилятор. Сначала запрашивается кэш
сборки: «совпадают ли входные данные этого шага с прошлым разом?» — и если да, результат прошлого
выполнения извлекается напрямую. Именно этот кэш — фундаментальная причина того, что команда go
работает так быстро, что компиляция словно и не происходит. Ему посвящён следующий раздел.
3.1.3 Кэш сборки на основе содержимого
Чистая сборка go build может компилировать сотни и тысячи пакетов, но повторная сборка часто
завершается за считанные секунды. Секрет — в кэше сборки на основе содержимого
(content-addressed): результат каждого шага сохраняется в кэше (по умолчанию в $GOCACHE,
то есть go env GOCACHE), а ключом служит хэш «всех входных данных, породивших этот результат».
При следующей сборке, если хэш входных данных того же шага не изменился, go заключает, что
результат должен быть идентичен, и использует его повторно, даже не запуская компилятор.
Всё зависит от того, как формируется «хэш входных данных». Функция buildActionID подаёт все
входные данные шага компиляции в SHA-256:
|
|
Включив GODEBUG=gocachehash=1, можно увидеть реальный процесс подачи входных данных в хэш.
Для рассмотренного выше пакета demo строка шестнадцатеричных символов на последней строке — это
итоговый actionID:
|
|
Обратите внимание на последние строки: зависимости fmt и runtime участвуют в хэше этого пакета
через свои buildID (идентификаторы содержимого выходных данных), а buildID, в свою очередь,
вычисляется из собственного actionID зависимости плюс содержимого её выходных данных. Таким
образом, весь ключ кэша вкладывается слой за слоем вдоль графа действий, формируя
дерево хэшей Меркла (Merkle hash tree): любое изменение исходного файла, аргумента компиляции,
выходных данных зависимости или даже версии компилятора приводит к изменению ключей всех шагов
выше по графу, и кэш естественным образом инвалидируется. И наоборот — поддерево, которое
не было затронуто, сохраняет постоянный ключ и переиспользуется целиком. В этом фундаментальное
преимущество адресации по содержимому перед «определением новизны по временной метке» (как
в традиционном make): критерием служит содержимое, а не время, поэтому переключение
веток через git checkout или скачки mtime файлов никогда не приведут к ошибочному решению.
Для корректности этой схемы необходим инвариант: каждый вход, способный повлиять на результат,
должен без исключения попасть в buildActionID. В исходном коде это правило сформулировано
явно: в файле exec.go, рядом с функцией выполнения шага, написано, что «любое новое влияние
на эту логику должно быть аналогичным образом зарегистрировано в buildActionID выше». Пропуск
хотя бы одного входа означает, что вход изменился, а ключ — нет, и сборка ошибочно переиспользует
устаревший результат. Это один из самых коварных классов ошибок в кэширующих системах. Для защиты
этого инварианта go предоставляет GODEBUG=gocacheverify=1: при этом кэш фактически
игнорируется, каждый шаг выполняется заново, а затем новый результат побайтово сравнивается
со старым результатом из кэша, и при обнаружении расхождения выдаётся ошибка. По сути, это
проверка в рантайме того, что «одному и тому же ключу действительно соответствует один и тот же
результат».
3.1.4 Воспроизводимые сборки
Кэш на основе содержимого может работать корректно только при условии, что сборка воспроизводима: одни и те же входные данные должны давать побайтово идентичные выходные данные. Если скомпилированный результат содержит абсолютный путь, временную метку или случайное число времени сборки, один и тот же исходный код на двух машинах даст разные бинарные файлы, частота попаданий в кэш упадёт, и ни о какой независимой верификации третьей стороной («этот бинарный файл действительно скомпилирован из этого исходного кода») не может быть и речи. Go накладывает для этого ряд ограничений на уровне инструментальной цепочки.
Наиболее прямолинейное — соль в виде версии Go в ключе кэша: cache.NewHash подмешивает
runtime.Version() в самом начале, поэтому команды go разных версий никогда не используют
совместно один набор записей кэша, и баг одной версии не загрязняет сборку другой. Второе
ограничение — -trimpath: по умолчанию отладочная информация содержит абсолютный путь к директории
с исходным кодом (строки вида dir /tmp/..., опущенные в трассе хэша выше, относятся именно
к этому), поэтому смена машины или директории даёт другой результат. С -trimpath путь
перезаписывается как путь модуля плюс версия, и сборка перестаёт зависеть от конкретного
расположения в файловой системе. Добавим к этому, что сам компилятор Go намеренно не вносит
временных меток сборки и устраняет недетерминизм наподобие порядка итерации по map, — и в результате
«один и тот же исходный код, та же версия, те же аргументы дают один и тот же бинарный файл»
становится достижимой целью. Верификация модулей (go.sum, база данных контрольных сумм)
запечатывает входные данные со стороны зависимостей; соответствующие механизмы подробно описаны
в главе 17. Воспроизводимость служит как
корректности кэша, так и верифицируемости цепочки поставок программного обеспечения — это именно
то направление, которое проект Reproducible Builds отстаивает уже много лет.
3.1.5 Единая инструментальная цепочка vs. фрагментация
Оглядываясь на список команд, упомянутый в 3.1.2, можно
заметить, что go — это гораздо больше, чем подкоманда build. Разбор, сборка, тестирование,
форматирование, статический анализ, документация, профилирование — в Go всё это собрано под одной
командой go:
|
|
Сравнение с миром C/C++ выявляет разницу. Там компиляция опирается на gcc/clang, оркестрация
сборки — на make или CMake, autotools, ninja, а при работе с разными версиями и платформами
ещё требуется pkg-config для посредничества; чтобы не перекомпилировать всё заново каждый раз,
сообщество создало отдельный «внешний кэш компиляции» вроде ccache; форматирование — это
clang-format, статический анализ — clang-tidy, а управление зависимостями долгое время вообще
не имело общепринятого решения. Все эти инструменты существуют независимо друг от друга, с
раздельными конфигурациями, и собрать их воедино для проекта, заставить работать и обеспечить
единообразие в команде — это само по себе отдельное ремесло. Go встраивает всё это в один бинарный
файл: кэширование, разрешение зависимостей, кросс-платформенная кросс-компиляция — всё встроено
и не требует настройки. На новой машине после git clone команда go build просто работает,
без необходимости предварительно разбираться в скриптах сборки.
Эта унификация не обходится без издержек. Модель сборки go фиксирована: она исходит из допущения
«пакет — это директория, импорт — это зависимость» и, в отличие от make, не может выражать
произвольную межъязыковую логику сборки с пользовательскими правилами. Когда требуется генерировать
код во время сборки, встраивать ресурсы или объединять в цепочку не-Go-инструменты, go generate
и //go:embed покрывают лишь типовые сценарии, а для более сложных случаев приходится прибегать
к внешним скриптам. Это осознанный компромисс, который обменивает гибкость на единообразие:
отказ от выразительности «можно описать любую сборку» в обмен на согласованность «сборку вообще
не нужно описывать». Для подавляющего большинства проектов на Go второе значительно ценнее первого,
и это вписывается в последовательную позицию Go в дизайне языка — сужать выбор, чтобы большинству
не приходилось выбирать.
Если расширить обзор ещё на один уровень, внутренний инструмент Google — Bazel — идёт другим путём:
он также строится вокруг кэша действий на основе содержимого, но делает правила сборки явными
и не привязанными к конкретному языку, а также поддерживает распределение кэша и выполнения
на удалённый кластер для обслуживания огромного многоязычного монорепозитория. Кэш сборки go
можно рассматривать как облегчённое воплощение этой идеи в условиях единственного языка и нулевой
конфигурации. Расстояние между ними сокращается: протокол GOCACHEPROG, введённый в Go 1.21
(proposal #59719, Brad Fitzpatrick), позволяет делегировать чтение и запись кэша сборки внешней
программе, передавая операции get/put через JSON-протокол для подключения к удалённым, общим
или даже P2P-бэкендам кэша. Это шаг go в направлении расширяемого кэша в стиле Bazel при
сохранении варианта по умолчанию, не требующего настройки; пока это относительно продвинутая
возможность, нацеленная на авторов инфраструктуры сборки, и в повседневной работе она не нужна.
К этому моменту полная картина команды go как оркестратора ясна: она выращивает граф действий
из go.mod и исходного кода, обходит его в топологическом порядке с параллелизмом, использует
кэш на основе содержимого для устранения повторной работы, а затем передаёт реальную работу каждого
узла инструментам compile и link. Следующий раздел, 3.2 Компиляция, заходит
в первый из этих двух инструментов и рассматривает, как исходный код пакета преобразуется
в объектный код.
Дополнительные материалы
- The Go Authors. Command go (справочник по команде
go, включая Build and test caching и различные подкоманды). https://pkg.go.dev/cmd/go ; см. такжеgo help build,go help cache. - The Go Authors. Go Modules Reference (
go.mod, MVS,go.sumи верифицируемые сборки). https://go.dev/ref/mod - The Go Authors. cmd/go/internal/work/{action.go, exec.go}, cmd/go/internal/cache/hash.go.
https://github.com/golang/go/tree/master/src/cmd/go (
buildActionID, граф действий иBuilder.Do). - Brad Fitzpatrick et al. proposal: cmd/go: support a GOCACHEPROG to use an alternative build cache. golang/go#59719 (начиная с Go 1.21). https://go.dev/issue/59719
- The Reproducible Builds Project. https://reproducible-builds.org/
(путь от исходного кода к бинарному файлу с независимой верификацией — контекст для понимания мотивации
-trimpathи соли в виде версии). - The Bazel Authors. Bazel: Remote Caching. https://bazel.build/remote/caching (промышленная форма кэша действий на основе содержимого).
- Данная книга: 3.2 Компиляция, 3.4 Линковка, Глава 17 Модули.