Go under the hood
Go: Under the Hood

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 Разрешение символов и релокация

Эти два шага составляют ядро механизма компоновки, и их стоит формализовать чуть подробнее.

Релокацию можно представить как тройку (o,S,a)(o, S, a): по смещению oo внутри текущего символа находится «дыра», которую необходимо заполнить; она должна указывать на целевой символ SS и несёт добавку aa. Когда после размещения компоновщик зафиксировал окончательный адрес addr(S)\text{addr}(S) символа SS, он заполняет дыру. Два наиболее распространённых способа заполнения:

absolute reference:patch(o)=addr(S)+a \text{absolute reference:} \quad \text{patch}(o) = \text{addr}(S) + a relative reference:patch(o)=addr(S)+a−(addr(self)+o) \text{relative reference:} \quad \text{patch}(o) = \text{addr}(S) + a - (\text{addr}(\text{self}) + o)

Абсолютная релокация подставляет реальный адрес цели напрямую (например, при взятии адреса глобальной переменной); относительная релокация подставляет «расстояние до цели относительно текущей инструкции» (например, операнд rel32 инструкции CALL на x86). Последний вариант позволяет перемещать код целиком без повторного заполнения, что лежит в основе позиционно-независимого кода (PIC) и современного механизма ASLR. Go описывает эти дыры набором архитектурно-независимых типов релокаций (objabi.RelocType, таких как R_CALL, R_PCREL, R_ADDR), а на этапе релокации компоновщик транслирует их в конкретные патчи машинного кода для целевой архитектуры.

Разрешение символов, в свою очередь, строит отображение имени SS в единственное определение. Символы с одинаковым именем могут присутствовать в нескольких объектных файлах (как правило, это один и тот же экземпляр функции, порождённый инлайнингом, или дескриптор типа, сгенерированный компилятором), и компоновщик должен гарантировать, что каждый символ имеет ровно одно определение в итоговом бинарном файле, а все ссылки указывают именно на него. До 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 «затягивает» в бинарный файл длинную цепочку символов:

1
2
3
4
5
$ go build -a -ldflags=-dumpdep -o hello hello.go 2>&1 | grep 'main.main ->'
main.main -> main..stmp_0
main.main -> os.Stdout
main.main -> go:itab.*os.File,io.Writer
main.main -> fmt.Fprintln

И наоборот — то, на что никто не ссылается, тихо удаляется. В программе ниже функция unused нигде не вызывается:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import "fmt"

//go:noinline
func used()   { fmt.Println("used") }

//go:noinline
func unused() { fmt.Println("unused") } // никем не используется

func main() { used() }

Проверка с помощью go tool nm (которая выводит таблицу символов бинарного файла) показывает, что main.used присутствует, а main.unused — нет: она была удалена на этапе компоновки.

1
2
3
4
$ go build -o dc dc.go
$ go tool nm dc | grep 'main.used'
10009f4a0 T main.used
$ go tool nm dc | grep 'main.unused'    # нет вывода: удалена как мёртвый код

Удаление мёртвого кода особенно важно для Go по причине, описанной в 3.4.4: каждая Go-программа компонуется с полным рантаймом и всеми используемыми пакетами стандартной библиотеки, и без обрезки даже простейший hello world тянул бы за собой массу кода, который никогда не выполняется. Самая сложная часть этого прохода маркировки — интерфейсы и рефлексия: при вызове метода через интерфейс или через reflect точка вызова не раскрывает при статическом анализе, реализация какого типа будет задействована, поэтому компоновщик должен консервативно сохранять «методы с совпадающей сигнатурой у достижимых типов» (именно для этого в deadcodePass существуют ifaceMethod и reflectSeen). По этой же причине программы, активно использующие рефлексию, зачастую не могут существенно сократить объём кода.

3.4.4 Рантайм тоже компонуется

Ключевой тезис, который следует запомнить: рантайм компонуется вместе с программой. Пакет main, все пакеты, от которых он зависит, и весь рантайм Go (шедулер, сборщик мусора, аллокатор памяти, netpoll и т. д.) — всё это сшивается компоновщиком в один бинарный файл. Нет внешней виртуальной машины, нет интерпретатора; рантайм просто тихо располагается внутри исполняемого файла. Именно отсюда берётся представление о том, что Go-программа «несёт собственный рантайм».

Сколько он занимает? Возьмём прежний hello, печатающий одну строку:

1
2
3
4
$ go tool nm hello | wc -l           # общее количество символов в бинарном файле
2598
$ go tool nm hello | grep -c 'runtime\.'   # из них принадлежат рантайму
1717

В программе hello world более шестидесяти процентов символов принадлежат рантайму. Это объясняет впечатление, что Go-бинарный файл «начинается от нескольких мегабайт по своей природе»: в нём упакован не десяток ваших строк, а полноценный конкурентный рантайм. Для сравнения, программа на C оставляет основную часть этой поддержки (потоки, управление памятью) операционной системе и libc и потому может быть очень компактной. Оба подхода находят своё применение: Go жертвует размером ради «одного файла, который является полной средой исполнения», а следующий раздел покажет, что именно это стало его ключевым преимуществом в эпоху контейнеров.

3.4.5 Компромиссы статической компоновки

Ещё одно характерное решение Go — статическая компоновка по умолчанию. Чистая Go-программа обычно компилируется в самодостаточный исполняемый файл без зависимостей от внешних разделяемых библиотек. Скопируйте его на другую машину с той же архитектурой и ОС — и он запустится, без необходимости предварительно устанавливать какие-либо библиотеки рантайма. Это контрастирует с типичной ситуацией в C/C++, когда программа «зависит от кучи файлов .so/.dll, а при переносе на другую машину обнаруживается нехватка библиотек», и является одной из причин, почему Go так хорошо принят в эпоху cloud-native: одного Go-бинарного файла, помещённого в пустой образ FROM scratch, достаточно для работы:

1
2
3
FROM scratch
COPY hello /hello
ENTRYPOINT ["/hello"]

Утверждение «статическая компоновка по умолчанию» требует двух оговорок и не должно восприниматься как абсолют. Во-первых, cgo возвращает динамические зависимости: как только программа вызывает код на C через cgo, компоновщик должен подключить системную libc, и бинарный файл снова приобретает динамические зависимости. Во-вторых, некоторые платформы не поддерживают полностью статическую компоновку по своей природе: macOS не предоставляет статических системных библиотек, поэтому даже чистая Go-программа на ней всё равно динамически линкует libSystem. Оба момента наглядно видны в экспериментах и показывают, где пролегает граница «статичности»:

1
2
3
4
5
6
7
8
9
# На Linux отключаем cgo для получения полностью статического бинарного файла
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o hello_linux hello.go
$ file hello_linux
hello_linux: ELF 64-bit ... statically linked, ...

# Тот же исходный код на macOS: по-прежнему зависит от libSystem (особенность платформы, а не Go)
$ otool -L hello
hello:
	/usr/lib/libSystem.B.dylib ...

CGO_ENABLED=0 стал стандартным способом сборки переносимых статических бинарных файлов: он попутно отключает cgo, переключая на чисто Go-реализации (например, чистый Go-резолвер в пакете net) и тем самым устраняя динамическую зависимость от libc.

Стоимость статической компоновки тоже следует обозначить прямо. Размер увеличивается, поскольку каждый бинарный файл несёт собственную копию рантайма и кода используемых библиотек (упомянутый ранее hello — около 2.5 МБ); к счастью, удаление мёртвого кода и вырезание отладочной информации позволяют частично сократить его: -ldflags="-s -w" удаляет таблицу символов и отладочную информацию DWARF, что может уменьшить этот hello примерно с 2.5 МБ до 1.7 МБ:

1
$ go build -ldflags="-s -w" -o hello_stripped hello.go   # -s удаляет таблицу символов, -w удаляет DWARF

Более серьёзная проблема связана с обновлениями безопасности: при динамической компоновке, когда в 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).

Дополнительная литература

  1. 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
  2. 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
  3. Austin Clements et al. Building a better Go linker (linker modernization design document, a multi-release effort). https://go.dev/s/better-linker
  4. 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
  5. 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
  6. Эта книга: 2.1 Ассемблер Plan 9, 3.2 Процесс компиляции, 3.5 Загрузка и инициализация, 1.1 История и философия проектирования, 17 Модули и экосистема.