Линковщик Go выполняет устранение мертвого кода с помощью алгоритма анализа достижимости, который строит граф зависимостей, начиная с точек входа программы: main.main и всех функций init пакета. Он проходит по графу вызовов, помечая каждую функцию и глобальную переменную, которые статически ссылаются, затем отбрасывает немаркированные символы перед записью финального двоичного файла. Этот процесс консервативен; если адрес функции берется и сохраняется в интерфейсе, передается в reflect.Value.Call или ссылается через ассемблерный код или директиву //go:linkname, линковщик должен сохранить его, поскольку он не может доказать, что функция не будет вызвана во время выполнения. Кроме того, экспортированные функции CGO и методы, зарегистрированные для декодирования на основе рефлексии (например, json.Unmarshal в interface{}, который динамически направляется к конкретным типам), могут принудить удержание иначе недостижимых путей кода. Оптимизация включена по умолчанию и работает между пакетами, что означает, что неиспользуемый код в сторонних зависимостях может быть устранен, если нет ссылок из достижимого кода приложения.
Ситуация из жизни
Команда платформы заметила, что их инструментарий CLI стал 47 МБ после введения обширной библиотеки наблюдаемости, которая поддерживала несколько телеметрических бекендов (Jaeger, Zipkin, Prometheus), даже несмотря на то, что сервис экспортировал только метрики Prometheus. Проблема заключалась в монолитной архитектуре библиотеки, где импорт пакета инициализировал глобальные реестры для всех бекендов, подтягивая дорогие зависимости, такие как клиенты Kafka и библиотеки gRPC для Zipkin, которые никогда не использовались.
Первое решение, которое рассматривалось, состояло в ручном ведении форка библиотеки с удалёнными неиспользуемыми бекендами. Хотя это гарантировало устранение мертвого кода, это создавало неприемлемую нагрузку по обслуживанию, требующую ручных патчей безопасности и разрешения конфликтов с верхним уровнем.
Второй подход, который был протестирован, заключался в применении сжатия UPX к бинарному файлу, что уменьшило размер до 13 МБ. Однако это привело к значительной задержке при запуске из-за распаковки во время выполнения и вызвало ложные срабатывания в корпоративных антивирусных сканерах, что сделало его неприемлемым для развертывания в производственной среде.
Третий вариант включал использование ldflags="-s -w" для удаления отладочной информации и таблиц символов. Это дало лишь снижение на 3 МБ без решения проблемы раздувания самого машинного кода, так как реализации неиспользуемых бекендов оставались в бинарном файле.
Команда решила переработать свой код, чтобы избежать проблемного импорта. Они определили минимальный интерфейс метрик в основном приложении, а затем переместили конкретную реализацию Prometheus в подпакет, импортируемый только main. Это обеспечило, что неиспользуемые пути кода Zipkin и Jaeger не были ссылаемы никаким символом, досягаемым из main.main или функций init. Они также провели аудит на предмет любых методов reflect.Type, которые могли бы случайно сохранить конструкторы бекендов. Это архитектурное изменение позволило линковщику Go выполнять агрессивное сжатие дерева.
Результат составил 9 МБ без внешнего сжатия, более быстрые загрузки артефактов CI и уменьшенные времена запуска контейнера, сохраняя возможность обновления библиотеки наблюдаемости без патчей.
Что кандидаты часто упускают
Почему линковщик удерживает функции, которые ссылаются только внутри блоков кода, охраняемых условием компиляции, которое всегда ложно, например, if false?
Линковщик Go действует на уровне зависимости символов, а не на уровне базовых блоков внутри функций. Хотя оптимизации компилятора SSA (Статическая Единственная Ассоциация) могут устранять мертвые ветви, такие как if false, если функция, содержащая ветвь, сама достижима, любая функция, которую она напрямую вызывает (не через логическую условность), создает ссылку в объектном файле. Более критично, если пакет импортирован, его функция init безусловно считается корнем графа достижимости. Таким образом, любая функция, вызываемая функцией init, удерживается независимо от того, используется ли публичный API пакета приложением. Разработчики часто предполагают, что неиспользуемые импорты безвредны, но они могут значительно увеличивать размеры двоичных файлов, если эти импорты выполняют тяжелую инициализацию.
Как взятие адреса функции с помощью &fn влияет на устранение мертвого кода по сравнению с прямым вызовом, и почему это может вызвать неожиданные увеличения размера двоичного файла в регистрах обратных вызовов?
Когда адрес функции берется и сохраняется в глобальной переменной или структуре данных во время инициализации пакета (например, var defaultHandler = &unusedFunction), линковщик должен пометить unusedFunction как достижимую, потому что присваивание создает статическую ссылку данных, которую линковщик не может отличить от динамического использования. В отличие от прямых вызовов функций, которые могут быть устранены, если вызывающая функция сама становится недостижимой, взятие адреса создает постоянную ссылку в секции данных двоичного файла. Это часто удивляет разработчиков, реализующих системы плагинов или регистры обработчиков HTTP, использующие переменные уровня пакета map[string]func(), так как каждая функция, добавленная в карту, выживает во время устранения мертвого кода, даже если карта никогда не используется.
Что отличает влияние директивы //go:linkname на удержание символов по сравнению со стандартными экспортированными функциями, и почему связывание с внутренней функцией стандартной библиотеки может предотвратить устранение целого пакета?
Директива //go:linkname позволяет пакету A ссылаться на символ из пакета B, используя имя символа линковщика, а не механизм экспорта языка. Когда символ является объектом директивы //go:linkname из любого пакета в сборке, линковщик рассматривает его как корень графа достижимости, аналогично main.main. Это происходит потому, что директива часто используется runtime и стандартной библиотекой для доступа к неэкспортируемым функциям через границы пакетов (например, runtime вызывает внутренности syscall). В отличие от обычных экспортированных функций, которые удерживаются только в том случае, если существует транзитивный путь вызова из main или init, цели linkname выживают даже если пакет, содержащий директиву, никогда не импортируется приложением. Следовательно, пользовательский код, который связывается с внутренними символами стандартной библиотеки, может непреднамеренно заставить линковщик удерживать большие части пакетов runtime или syscall, которые в противном случае были бы устранены.