3.4 Компоновка модулей
Компилятор (3.2) преобразует каждый пакет в объектный файл, но объектный файл сам по себе не может быть выполнен. Объектные файлы по-прежнему ссылаются на функции и переменные друг друга, адреса ещё не зафиксированы, а поддержка рантайма отсутствует. Сборка этих фрагментов в цельную программу, пригодную для загрузки и исполнения, — задача компоновщика (cmd/link, обычно вызываемого через go build как go tool link). В этом разделе рассматривается, что делает компоновщик и какие решения Go принимает в области компоновки, — именно они объясняют, почему Go-программа так часто представляет собой единственный самодостаточный файл, который «просто работает после копирования на целевую машину».
3.4.1 Что делает компоновщик
На вход компоновщик принимает объектный файл пакета main вместе с объектными файлами всех транзитивно зависимых пакетов (включая весь рантайм); на выходе — исполняемый файл, который операционная система может загрузить и запустить. Между входом и выходом последовательно выполняется несколько ключевых операций:
- Разрешение символов: связывание каждой ссылки на внешний символ с его единственным определением в одном из объектных файлов. Символ — это просто именованный адрес: точка входа функции, место хранения глобальной переменной. Когда пакет A пишет
fmt.Println(...), компиляция A не знает, где находитсяfmt.Println, и оставляет лишь ссылку для последующего разрешения; компоновщик связывает её с определением в объектном файле пакетаfmt. - Размещение: распределение машинного кода и данных всех символов по сегментам исполняемого файла в зависимости от их типа: код попадает в text, данные для чтения и записи — в data, данные только для чтения (строковые литералы, информация о типах) — в rodata и так далее. После того как размещение зафиксировано, окончательный адрес каждого символа также определён.
- Релокация: после фиксации адресов компоновщик возвращается и подставляет реальные адреса вместо временных значений, записанных в ссылках.
- Удаление мёртвого кода: функции и переменные, недостижимые из точки входа, просто не записываются в итоговый бинарный файл.
Конечный результат — файл с полностью определённым размещением и заполненными адресами. В следующих трёх подразделах последовательно рассматриваются три наиболее важных аспекта: релокация (3.4.2), удаление мёртвого кода (3.4.3) и характерное для Go следствие — «рантайм компонуется вместе со всем остальным» (3.4.4).
flowchart LR
OBJ["main.a + объектные файлы зависимостей<br/>(включая весь рантайм)"] --> LOAD["загрузка символов<br/>чтение загрузчиком"]
LOAD --> RESOLVE["разрешение символов<br/>ссылка → определение"]
RESOLVE --> DEAD["удаление мёртвого кода<br/>обход достижимости от main.main"]
DEAD --> LAYOUT["размещение по сегментам<br/>text/data/rodata"]
LAYOUT --> RELOC["релокация<br/>подстановка адресов"]
RELOC --> BIN["исполняемый файл"]
3.4.2 Разрешение символов и релокация
Эти два шага составляют ядро механизма компоновки, и их стоит формализовать чуть подробнее.
Релокацию можно представить как тройку : по смещению внутри текущего символа находится «дыра», которую необходимо заполнить; она должна указывать на целевой символ и несёт добавку . Когда после размещения компоновщик зафиксировал окончательный адрес символа , он заполняет дыру. Два наиболее распространённых способа заполнения:
Абсолютная релокация подставляет реальный адрес цели напрямую (например, при взятии адреса глобальной переменной); относительная релокация подставляет «расстояние до цели относительно текущей инструкции» (например, операнд rel32 инструкции CALL на x86). Последний вариант позволяет перемещать код целиком без повторного заполнения, что лежит в основе позиционно-независимого кода (PIC) и современного механизма ASLR. Go описывает эти дыры набором архитектурно-независимых типов релокаций (objabi.RelocType, таких как R_CALL, R_PCREL, R_ADDR), а на этапе релокации компоновщик транслирует их в конкретные патчи машинного кода для целевой архитектуры.
Разрешение символов, в свою очередь, строит отображение имени в единственное определение. Символы с одинаковым именем могут присутствовать в нескольких объектных файлах (как правило, это один и тот же экземпляр функции, порождённый инлайнингом, или дескриптор типа, сгенерированный компилятором), и компоновщик должен гарантировать, что каждый символ имеет ровно одно определение в итоговом бинарном файле, а все ссылки указывают именно на него. До Go 1.15 этот шаг выполнялся через создание объекта *sym.Symbol для каждого символа и индексацию в одной большой глобальной хеш-таблице «строка → объект»; при рефакторинге перешли на целочисленные индексы символов (см. 3.4.6) — именно для того, чтобы сэкономить память и затраты на поиск в этой таблице.
3.4.3 Удаление мёртвого кода
Далеко не весь код скомпонованных пакетов попадает в итоговый бинарный файл. Начиная с корней программы — прежде всего main.main и функций init каждого пакета — компоновщик выполняет обход достижимости по графу символьных зависимостей «кто ссылается на кого» (reachability), и только символы, отмеченные как достижимые, участвуют в последующем размещении. Недостижимые функции, переменные и информация о типах отбрасываются целиком. В реализации go1.26 этот проход маркировки использует min-кучу в качестве рабочей очереди (deadcodePass, src/cmd/link/internal/ld/deadcode.go), где обход в порядке кучи призван улучшить локальность обращений.
Это можно наблюдать напрямую. Флаг -dumpdep заставляет компоновщик выводить граф символьных зависимостей, по которому он проходит, и позволяет увидеть, как единственный вызов fmt.Println «затягивает» в бинарный файл длинную цепочку символов:
|
|
И наоборот — то, на что никто не ссылается, тихо удаляется. В программе ниже функция unused нигде не вызывается:
|
|
Проверка с помощью go tool nm (которая выводит таблицу символов бинарного файла) показывает, что main.used присутствует, а main.unused — нет: она была удалена на этапе компоновки.
|
|
Удаление мёртвого кода особенно важно для Go по причине, описанной в 3.4.4: каждая Go-программа компонуется с полным рантаймом и всеми используемыми пакетами стандартной библиотеки, и без обрезки даже простейший hello world тянул бы за собой массу кода, который никогда не выполняется. Самая сложная часть этого прохода маркировки — интерфейсы и рефлексия: при вызове метода через интерфейс или через reflect точка вызова не раскрывает при статическом анализе, реализация какого типа будет задействована, поэтому компоновщик должен консервативно сохранять «методы с совпадающей сигнатурой у достижимых типов» (именно для этого в deadcodePass существуют ifaceMethod и reflectSeen). По этой же причине программы, активно использующие рефлексию, зачастую не могут существенно сократить объём кода.
3.4.4 Рантайм тоже компонуется
Ключевой тезис, который следует запомнить: рантайм компонуется вместе с программой. Пакет main, все пакеты, от которых он зависит, и весь рантайм Go (шедулер, сборщик мусора, аллокатор памяти, netpoll и т. д.) — всё это сшивается компоновщиком в один бинарный файл. Нет внешней виртуальной машины, нет интерпретатора; рантайм просто тихо располагается внутри исполняемого файла. Именно отсюда берётся представление о том, что Go-программа «несёт собственный рантайм».
Сколько он занимает? Возьмём прежний hello, печатающий одну строку:
|
|
В программе hello world более шестидесяти процентов символов принадлежат рантайму. Это объясняет впечатление, что Go-бинарный файл «начинается от нескольких мегабайт по своей природе»: в нём упакован не десяток ваших строк, а полноценный конкурентный рантайм. Для сравнения, программа на C оставляет основную часть этой поддержки (потоки, управление памятью) операционной системе и libc и потому может быть очень компактной. Оба подхода находят своё применение: Go жертвует размером ради «одного файла, который является полной средой исполнения», а следующий раздел покажет, что именно это стало его ключевым преимуществом в эпоху контейнеров.
3.4.5 Компромиссы статической компоновки
Ещё одно характерное решение Go — статическая компоновка по умолчанию. Чистая Go-программа обычно компилируется в самодостаточный исполняемый файл без зависимостей от внешних разделяемых библиотек. Скопируйте его на другую машину с той же архитектурой и ОС — и он запустится, без необходимости предварительно устанавливать какие-либо библиотеки рантайма. Это контрастирует с типичной ситуацией в C/C++, когда программа «зависит от кучи файлов .so/.dll, а при переносе на другую машину обнаруживается нехватка библиотек», и является одной из причин, почему Go так хорошо принят в эпоху cloud-native: одного Go-бинарного файла, помещённого в пустой образ FROM scratch, достаточно для работы:
|
|
Утверждение «статическая компоновка по умолчанию» требует двух оговорок и не должно восприниматься как абсолют. Во-первых, cgo возвращает динамические зависимости: как только программа вызывает код на C через cgo, компоновщик должен подключить системную libc, и бинарный файл снова приобретает динамические зависимости. Во-вторых, некоторые платформы не поддерживают полностью статическую компоновку по своей природе: macOS не предоставляет статических системных библиотек, поэтому даже чистая Go-программа на ней всё равно динамически линкует libSystem. Оба момента наглядно видны в экспериментах и показывают, где пролегает граница «статичности»:
|
|
CGO_ENABLED=0 стал стандартным способом сборки переносимых статических бинарных файлов: он попутно отключает cgo, переключая на чисто Go-реализации (например, чистый Go-резолвер в пакете net) и тем самым устраняя динамическую зависимость от libc.
Стоимость статической компоновки тоже следует обозначить прямо. Размер увеличивается, поскольку каждый бинарный файл несёт собственную копию рантайма и кода используемых библиотек (упомянутый ранее hello — около 2.5 МБ); к счастью, удаление мёртвого кода и вырезание отладочной информации позволяют частично сократить его: -ldflags="-s -w" удаляет таблицу символов и отладочную информацию DWARF, что может уменьшить этот hello примерно с 2.5 МБ до 1.7 МБ:
|
|
Более серьёзная проблема связана с обновлениями безопасности: при динамической компоновке, когда в libc обнаруживается уязвимость, система заменяет соответствующий .so-файл и перезапускает процесс — этого достаточно; при статической компоновке код библиотеки «впаян» в каждый бинарный файл, поэтому исправление безопасности в любой зависимости требует перекомпиляции и повторного распространения всех затронутых программ. Если взглянуть в перспективе, эта проблема не уникальна для Go: пользователи musl со статической компоновкой в мире C или Rust с его статической компоновкой по умолчанию находятся на той же линии компромисса — переносимость и простота развёртывания на одном конце, размер и возможность «централизованного обновления» на другом. Go сделал свой выбор с прицелом на серверный и контейнерный сценарий: там экономия операционных затрат от принципа «один файл — это весь артефакт поставки» обычно перевешивает затраты на перекомпиляцию и повторное распространение.
3.4.6 Эволюция и перспективы компоновщика
Компоновщик Go также берёт начало в традиции Plan 9 (2.1) и неоднократно перерабатывался на протяжении многих лет. Наиболее значимое событие — масштабный рефакторинг в районе Go 1.15, проходивший под кодовым названием dev.link. В центре рефакторинга находились формат объектных файлов и внутреннее представление символов: старая реализация разворачивала каждый символ в объект *sym.Symbol в куче и индексировала их по имени через одну глобальную хеш-таблицу, что с ростом числа символов увеличивало расход памяти и нагрузку на GC; новая реализация ввела новый формат объектных файлов и представляет символы в виде компактных целочисленных индексов, управляемых централизованно новым пакетом loader (src/cmd/link/internal/loader), «материализуя» символ в полноценный объект только по необходимости. Другим изменением стала дополнительная параллелизация внутренних фаз компоновки — например, параллельное применение релокаций к символам. Согласно данным, приведённым в заметках к релизу Go 1.15, на репрезентативном наборе крупных Go-программ компоновка на ELF-системах с архитектурой amd64 стала в среднем примерно на 20% быстрее при примерно на 30% меньшем потреблении памяти (выигрыш на других архитектурах и системах был скромнее, а новые объектные файлы оказались чуть больше, чем в версии 1.14). Это было частью масштабной работы по «переписыванию компоновщика», растянувшейся на несколько релизов; последующие версии продолжили совершенствование.
Этот рефакторинг вписывается в давнюю одержимость Go скоростью сборки (1.1). Компоновка — последний этап конвейера сборки, и от её скорости напрямую зависит время ожидания в каждом цикле «изменил строку — перезапустил»; от компиляции до компоновки цель проектирования всего конвейера остаётся единой: сохранять быструю сборку крупных Go-проектов.
У компоновки по-прежнему остаются нерешённые задачи. Для очень больших бинарных файлов время компоновки и пиковое потребление памяти могут оставаться узким местом, а генерация отладочной информации DWARF составляет значительную часть затрат (именно поэтому в CI часто используют флаг -w для ускорения); инкрементальная компоновка, более агрессивная параллелизация и более тесное взаимодействие с компилятором — всё это направления, которые продолжают развиваться. Следующий шаг — рассмотрение того, как скомпонованный бинарный файл, загруженный операционной системой, выполняет первоначальную «загрузку» (3.5).
Дополнительная литература
- The Go Authors. cmd/link documentation and source (including link options such as
-dumpdep,-s,-w). https://pkg.go.dev/cmd/link ; https://github.com/golang/go/tree/master/src/cmd/link - The Go Authors. src/cmd/link/internal/ld/deadcode.go (reachability marking starting from
main.main), src/cmd/link/internal/loader (new object file format and symbol indices). https://github.com/golang/go/tree/master/src/cmd/link/internal - Austin Clements et al. Building a better Go linker (linker modernization design document, a multi-release effort). https://go.dev/s/better-linker
- The Go Authors. Go 1.15 Release Notes (linker: new object file format, parallel relocation, on average about 20% faster and about 30% lower in memory). https://go.dev/doc/go1.15
- Rob Pike. Go at Google: Language Design in the Service of Software Engineering (build speed as a first-class design goal). 2012. https://go.dev/talks/2012/splash.article
- Эта книга: 2.1 Ассемблер Plan 9, 3.2 Процесс компиляции, 3.5 Загрузка и инициализация, 1.1 История и философия проектирования, 17 Модули и экосистема.