3.2 Конвейер компиляции Go
В разделе 3.1 было показано, что реальную работу go build выполняют две программы — compile и link. Этот раздел фокусируется на compile и прослеживает полный путь от одного исходного файла .go до одного объектного файла .o: что получает каждая стадия, что она порождает и почему работа разделена именно так. Этот раздел представляет собой панораму конвейера компиляции. Внутреннее устройство каждой стадии (как устроена грамматика, какой алгоритм используется для проверки типов, правила оптимизации SSA) раскрывается в главе 15. Здесь мы лишь выстраиваем их в единую линию и указываем на две сквозные темы.
Чтобы не скатиться в пересказ структуры каталогов компилятора, последующее изложение использует один небольшой пример, проходящий через весь путь, чтобы связать стадии воедино. Запомните эту функцию — конвейер будет преобразовывать её снова и снова, пока она не превратится в машинный код:
|
|
Это всего две строки, но они как раз скрывают вторую сквозную тему, которую этот раздел стремится подчеркнуть: языковые ключевые слова вроде go и <- в конечном счёте реализуются не напрямую инструкциями процессора — они транслируются компилятором в вызовы рантайма. Сначала пройдём весь конвейер, а затем вернёмся и разберём эти две строки.
3.2.1 Две сквозные темы: почему о них говорится в первую очередь
Прежде чем излагать детали конвейера, обозначим две проектные установки, пронизывающие его целиком, — каждая последующая стадия им служит.
Первая тема: скорость компиляции — это первоклассное ограничение. С самого начала Go рассматривал требование «компиляция должна быть быстрой» как цель языка, а не как нечто вторичное (1.1). Эта цель проникла во все слои: синтаксис, управление зависимостями, структуру компилятора. Грамматика спроектирована так, чтобы разбираться быстро, без откатов; зависимости передаются через компактные экспортные данные, формируемые во время компиляции, а не через исходный код; отсутствует механизм #include в стиле C/C++, при котором заголовочные файлы загрязняют друг друга слой за слоем. В Go at Google Роб Пайк указывает это как прямую мотивацию для создания Go: в то время одна крупная сборка C++ в Google занимала часы, а Go стремился к тому, чтобы «нажал Enter — и компиляция завершена».
Вторая тема — сговор между компилятором и рантаймом. Многие возможности языка Go реализуются не одним только компилятором: компилятор генерирует код, вызывающий рантайм, а рантайм обеспечивает его работу во время выполнения программы. go f() транслируется в runtime.newproc, <-ch транслируется в runtime.chanrecv, в начало каждой функции вставляется пролог роста стека, типы, содержащие указатели, снабжаются битовой картой указателей для GC и дескриптором типа, а в каждое место записи указателя вставляется барьер записи. Можно сказать, что компилятор выкладывает один согласованный интерфейс за другим для рантайма, и только вместе они образуют полную семантику. Эта тема раскрывается в 3.2.6 и 3.2.7.
3.2.2 Панорама конвейера
В go1.26 compile логически делится на три сегмента — фронтенд, мидл-энд и бэкенд, — которые подразделяются на стадии конвейера, показанные ниже. Код распределён по нескольким подпакетам cmd/compile/internal, но читателю не нужно запоминать имена каталогов — достаточно отслеживать изменение формы данных: исходный код → синтаксическое дерево → типизированное синтаксическое дерево → собственное промежуточное представление компилятора (IR) → SSA → машинно-зависимый SSA → машинный код.
flowchart TD
SRC[".go — исходный файл"] --> LEX["Лексический и синтаксический анализ<br/>пакет syntax, формирует AST"]
LEX --> TC["Проверка типов<br/>пакет types2: разрешение имён + вывод типов"]
TC --> IR["Построение IR (noding)<br/>пакет noder: преобразование в собственный IR компилятора"]
IR --> MID["Оптимизации мидл-энда<br/>inline / devirtualize / escape"]
MID --> WALK["walk: понижение + упорядочение<br/>go / отправка-получение / map заменяются вызовами рантайма"]
WALK --> SSA["Обобщённый SSA<br/>пакеты ssagen + ssa: машинно-независимые оптимизации"]
SSA --> LOWER["lower + архитектурно-специфичные оптимизации<br/>барьеры записи, распределение регистров, разметка стекового фрейма"]
LOWER --> OBJ["Генерация машинного кода<br/>cmd/internal/obj: вставка пролога роста стека"]
OBJ --> O[".o — объектный файл<br/>машинный код + экспортные данные + данные рефлексии/отладки"]
style SRC fill:#e8f0fe
style O fill:#e8f0fe
Сегмент между фронтендом и бэкендом (мидл-энд плюс walk) часто называют «мидл-эндом»; именно здесь оптимизации наиболее плотны и наиболее отчётливо проявляются проектные компромиссы Go. Следуя этой линии, рассмотрим, что происходит с функцией send на каждой остановке.
3.2.3 Фронтенд: от потока символов к типизированному синтаксическому дереву
Лексический и синтаксический анализ (cmd/compile/internal/syntax) — первая остановка. Сначала исходный текст разрезается на поток токенов (лексический анализ), затем собирается в абстрактное синтаксическое дерево (AST) в соответствии с правилами грамматики (синтаксический анализ). Тело send разбирается в два узла-оператора: оператор go и оператор отправки; каждый узел несёт точную позицию в исходном коде для последующей выдачи ошибок и генерации отладочной информации. Грамматика Go спроектирована для скорости и допускает однопроходный разбор без откатов — именно об этом пойдёт речь в первом пункте 3.2.6 (подробнее о грамматике см. 15.1).
Проверка типов (cmd/compile/internal/types2) следует далее. Пакет types2 — это порт go/types, использующий AST из пакета syntax. Он выполняет две задачи: разрешение имён (на какое объявление указывает каждый идентификатор) и вывод типов (какой тип имеет каждое выражение), а также дополнительные проверки, например «объявлено, но не используется». Вывод типов для дженериков и разрешение ограничений также происходят на этой стадии и являются одной из наиболее сложных частей всего компилятора (8.3). Для send этот шаг подтверждает, что ch имеет тип chan int, что 1 в ch <- 1 присваиваем типу int, и что work — это функция без параметров. После завершения проверки каждое выражение в AST несёт определённый тип.
Стоит отметить, что публичные пакеты go/parser и go/types компилятором не используются. Изначально компилятор был написан на C, а серия пакетов go/* была позднее разработана отдельно для инструментов вроде gofmt и vet; у них общее происхождение, но пути разошлись.
3.2.4 Мидл-энд: IR, инлайнинг и анализ утечек
Представление после проверки типов ещё не удобно для оптимизации, поэтому выполняется построение IR (noding, cmd/compile/internal/noder): преобразование представления syntax + types2 в собственный IR компилятора (пакет ir) и систему типов (пакет types). Этот IR — наследие тех времён, когда компилятор был ещё написан на C, и весь код мидл-энда и бэкенда построен на нём. В go1.26 noding использует подход Unified IR: после проверки типов код сериализуется в промежуточный продукт, из которого затем заново строится IR; этот же продукт служит носителем для импорта/экспорта пакетов и инлайнинга. Этот момент критически важен для первой сквозной темы, и 3.2.6 вернётся к нему.
Когда IR готов, мидл-энд выполняет над ним несколько проходов оптимизации: удаление мёртвого кода, (ранняя) девиртуализация, инлайнинг функций (пакет inline) и анализ утечек (escape analysis, пакет escape). Анализ утечек особенно важен: он определяет, может ли каждая переменная безопасно существовать на стеке или должна быть аллоцирована в куче. Этот шаг напрямую определяет, кто управляет каждым объектом: объекты, остающиеся на стеке, автоматически освобождаются при возврате из функции, а объекты, утекающие в кучу, попадают в поле зрения сборщика мусора (13.1). В функции send функция, переданная в go work(), и любая переменная, захваченная новой горутиной, будут признаны утекающими, поскольку время жизни новой горутины превышает время жизни стекового фрейма send.
3.2.5 Walk и бэкенд: понижение, SSA и машинный код
После мидл-энда следует walk (cmd/compile/internal/walk) — последний проход по IR, который выполняет две задачи. Первая — упорядочение: разбиение составных операторов на простые операторы с временными переменными и фиксация порядка вычисления. Вторая — понижение (desugaring): перевод высокоуровневых языковых конструкций в более примитивные формы. Вторая сквозная тема этого раздела впервые проявляется именно здесь. Оператор switch переписывается в бинарный поиск или таблицу переходов, а операции с map и каналами, а также оператор go, заменяются вызовами рантайма. После этого шага две строки send выглядят приблизительно так:
|
|
Далее следует обобщённый SSA (ssagen преобразует IR в SSA, пакет ssa его обрабатывает). SSA (static single assignment, статическое однократное присваивание) — это низкоуровневое промежуточное представление, в котором каждому значению присваивается ровно один раз, что делает анализ потоков данных и оптимизацию лаконичными (подробнее см. 15.2). При преобразовании применяются интринсики (компилятор встраивает высокооптимизированные реализации для определённых функций), и понижение конструкций продолжается (например, copy превращается в перемещение памяти, а цикл range переписывается в цикл for). Затем выполняется серия машинно-независимых оптимизаций: удаление мёртвого кода, устранение избыточных проверок на nil, свёртка констант, замена умножений и операций с плавающей точкой на более эффективные формы. Эти правила не зависят от конкретной архитектуры и выполняются одинаково на любом GOARCH.
Последним идёт машинно-зависимый бэкенд. Он начинается с прохода lower, который переписывает обобщённые SSA-значения в варианты, специализированные для целевой архитектуры (например, на amd64 несколько отдельных load-store объединяются в одну инструкцию с операндом в памяти). После lower выполняется ещё один раунд оптимизаций вместе с несколькими ключевыми задачами: распределение регистров, разметка стекового фрейма (назначение смещений на стеке для локальных переменных), анализ живости указателей (вычисление того, какие указатели на стеке живы в каждой точке безопасности GC — предпосылка для точного GC, см. 4.1 и главу 13 «Сборка мусора») и вставка барьеров записи (отдельный проход writebarrier в пакете ssa). На этом этапе функция превращена в последовательность инструкций obj.Prog, передаваемую ассемблеру (cmd/internal/obj). Ассемблер преобразует их в машинный код и вставляет пролог роста стека в начало каждой функции (stacksplit в cmd/internal/obj), после чего записывает финальный объектный файл. Помимо машинного кода, объектный файл содержит экспортные данные, данные рефлексии и отладочную информацию.
Чтобы своими глазами увидеть, как send меняется между проходами SSA, можно экспортировать визуализированный SSA одной командой: GOSSAFUNC=send go build, которая сгенерирует файл ssa.html с эволюцией каждого значения на каждом проходе.
3.2.6 Сквозная тема первая: скорость компиляции как первоклассное ограничение
Вернёмся к первой теме. Go закладывает «быстроту» в три слоя конвейера, каждый из которых соответствует одной из остановок выше.
Слой грамматики. Грамматика Go допускает однопроходное сканирование без откатов, а лексический анализ настолько прост, что близок к регулярному. Это сжимает первую остановку 3.2.3 до линейного времени — в резком контрасте с грамматикой в стиле C++, которая требует неограниченного просмотра вперёд и переплетает разбор с семантикой.
Слой зависимостей. Это наиболее изящный ход. При компиляции пакета P Unified IR, упомянутый в 3.2.4, также записывает блок экспортных данных: он сериализует в объектный файл информацию о типах всех экспортируемых объявлений P, тела инлайнируемых функций, тела обобщённых функций и выводы анализа утечек о параметрах. Когда пакет Q импортирует P, компилятор читает только эти экспортные данные P и не обращается к исходному коду P. Более того, эти экспортные данные обычно «глубокие»: они уже содержат информацию, от которой P транзитивно зависит и которая может понадобиться Q, так что Q достаточно прочитать один файл на каждую прямую зависимость, без рекурсивного развёртывания всего графа зависимостей. Это точно устраняет фатальный недостаток #include в C/C++, при котором заголовочный файл загрязняется слой за слоем по цепочке зависимостей, а широко используемый заголовок заново разбирается сотнями и тысячами единиц трансляции. Go заменяет эту повторяющуюся работу одним компактным бинарным представлением, формируемым во время компиляции.
Слой структуры. Go использует пакет в качестве единицы компиляции и компилирует пакеты параллельно; пакеты связаны только через узкий интерфейс экспортных данных, что позволяет всей сборке быть высокопараллельной (в 3.1 уже описано, как go build планирует эти параллельные компиляции).
Это не обходится без издержек. Глубокие экспортные данные склонны «раздуваться по мере продвижения вверх по графу зависимостей»: набор широко используемых типов с объёмным API приводит к тому, что экспортные данные почти каждого пакета содержат их копию. Именно эта проблема породила более экономичные форматы экспорта, такие как «indexed» и «shallow» (последний принят gopls, обменивая размер на чтение по запросу). Компромиссы производительности никогда не бесплатны; здесь некоторая избыточность обменивается на простую однопроходную систему сборки.
3.2.7 Сквозная тема вторая: сговор между компилятором и рантаймом
Вторая тема — ключ к пониманию рантайма Go. Многие читатели полагают, что горутины, каналы и GC — это «дело рантайма» и к компилятору отношения не имеют, но на самом деле они действуют сообща: рантайм определяет набор согласованных интерфейсов, а компилятор в нужных местах генерирует их вызовы или сопутствующие метаданные; без любой из сторон семантика остаётся неполной. Соберём здесь несколько точек этого сговора, разбросанных по тексту выше:
| Языковая возможность | Что делает компилятор | Стадия | Сторона рантайма |
|---|---|---|---|
go f() |
понижается в runtime.newproc(f) |
walk | шедулер создаёт и ставит горутину в очередь (9.4) |
ch <- v / <-ch |
понижается в runtime.chansend / chanrecv |
walk | отправка/получение через канал и блокировка/пробуждение (глава 10) |
| вход в функцию | вставляется пролог роста стека (stacksplit) |
ассемблирование obj | morestack инициирует рост стека (2.2, глава 14) |
запись указателя *p = q |
вставляется барьер записи | проход SSA writebarrier |
GC использует записи барьера для поддержания трёхцветного инварианта (13.2) |
| типы, содержащие указатели | генерируется дескриптор типа + битовая карта указателей для GC | бэкенд / данные рефлексии | GC использует битовую карту для точного сканирования объектов (4.1, 13.1) |
Эта таблица завершает историю двух строк send: go work() стала вызовом newproc, перехваченным шедулером; ch <- 1 стала вызовом chansend, перехваченным реализацией каналов в рантайме. А невидимый пролог роста стека присутствует в каждой функции Go: на входе он сравнивает указатель стека с границей стека, и если текущий вызов выходит за пределы стека, сначала происходит переход к morestack для его расширения, а затем выполнение продолжается. Это и есть основа реализации, позволяющая горутине стартовать с небольшого стека в несколько килобайт и расти по мере необходимости.
Если объединить две сквозные темы, роль компилятора в Go становится ясной: с одной стороны, ограничение «быстро» (тема первая) заставляет его оставаться компактным, с другой — он несёт на себе нагрузку по формированию интерфейсов для рантайма (тема вторая). Натяжение между этими двумя силами определяет каждый компромисс от грамматики до объектного файла. Следующий раздел (3.3) задаёт более фундаментальный вопрос: этот компилятор, сам написанный на Go, — откуда он изначально взялся?
Дополнительные материалы
- The Go Authors. Introduction to the Go compiler (
cmd/compile/README). https://github.com/golang/go/blob/master/src/cmd/compile/README (первоисточник для деления конвейера в этом разделе, включая экспортные данные §7a). - The Go Authors. Introduction to the Go compiler’s SSA backend
(
cmd/compile/internal/ssa/README). https://github.com/golang/go/blob/master/src/cmd/compile/internal/ssa/README - Rob Pike. Go at Google: Language Design in the Service of Software Engineering. 2012. https://go.dev/talks/2012/splash.article (указывает скорость компиляции как мотивацию дизайна Go).
- Ron Cytron, Jeanne Ferrante, et al. “Efficiently Computing Static Single Assignment Form and the Control Dependence Graph.” ACM TOPLAS, 13(4), 1991. https://doi.org/10.1145/115372.115320 (теоретическое происхождение SSA).
- The Go Authors. Unified IR (
cmd/compile/internal/noder/README). https://github.com/golang/go/blob/master/src/cmd/compile/internal/noder/README (построение IR и формат экспортных данных). - Данная книга: 15.1 Лексический и синтаксический анализ, 15.2 Промежуточное представление, 8.3 Методы проверки типов.
- Данная книга: 1.1 Эволюция языков программирования, 13.2 Методы барьеров записи, Глава 14 Управление стеком выполнения.