3.5 Начальная загрузка Go-программы
Функция main, которую пишет читатель, не является первой инструкцией программы. Когда
операционная система передаёт управление исполняемому файлу Go, первым начинает работу рантайм:
он должен разметить стек выполнения на главном потоке, привязать локальное хранилище потока
(TLS), определить количество ядер CPU и размер физической страницы памяти, последовательно
пробудить аллокатор памяти, сборщик мусора и шедулер, и лишь затем создать горутину,
несущую main, и передать её в цикл планирования для запуска. Иначе говоря, бинарный файл Go
содержит внутри себя миниатюрную операционную систему (1.2), которая
запускается раньше пользовательского кода. В этом разделе мы пройдём всю цепочку начальной
загрузки от начала до конца — от точки входа, определённой операционной системой, до момента
планирования первой горутины, — чтобы ясно увидеть, что происходит «до main».
Полезно представлять структуру цепочки начальной загрузки в виде трёх этапов:
ассемблерная точка входа вручную выстраивает g0 и m0 на главном потоке
(3.5.1), schedinit запускает каждую подсистему
в порядке зависимостей (3.5.2),
а затем newproc конструирует первую горутину, после чего mstart входит в цикл планирования
для её запуска (3.5.3).
3.5.1 Ассемблерная точка входа: g0, m0 и TLS
Настоящая точка входа в рантайм написана на ассемблере в пакете runtime. На примере AMD64:
точки входа для Linux и macOS находятся в файлах runtime/rt0_linux_amd64.s и
runtime/rt0_darwin_amd64.s соответственно, и обе представляют собой единственный переход
в общую _rt0_amd64:
|
|
Зачем разделять точку входа по двум измерениям — «архитектура + операционная система»? Потому что
после компиляции программы в машинный код набор инструкций зависит только от архитектуры процессора,
тогда как различия между операционными системами проявляются на уровне системных операций, таких как
системные вызовы. Одна _rt0_amd64 может совместно использоваться несколькими операционными
системами, и каждая из них переходит в неё из своего собственного символа точки входа. rt0 — это
сокращение от runtime0, обозначающее начало рантайма; каждый последующий объект того же рода
получает суффикс 1, и именно отсюда g0 и m0 берут свои имена.
_rt0_amd64 сначала снимает со стека аргументы, переданные операционной системой, после чего
переходит в rt0_go, где выполняется основная работа. При только что запущенной программе первые
два слота по указателю стека SP — это argc и argv:
|
|
rt0_go — стержень всей начальной загрузки. Сначала он переносит аргументы на выровненный стек,
затем выполняет первый реальный шаг инициализации: на сегменте стека операционной системы, который
в данный момент использует главный поток, он «занимает» стек выполнения для g0. g0 — это
стек планирования каждого потока, и код самого рантайма (планирование, рост стека, некоторые фазы
сборки мусора) выполняется на нём, а не на стеке пользовательской горутины:
|
|
g0 и m0 — пара глобальных переменных, которые существуют статически с самого начала работы
программы и не требуют выделения памяти. g0 — это стек планирования для m0, а m0 представляет
главный поток. Следующая задача — связать их друг с другом, и предварительным условием для этого
является следующее: поток должен в любой момент иметь возможность определить, какая горутина
«сейчас выполняется». Эту задачу решает локальное хранилище потока (TLS).
TLS предоставляет каждому потоку собственный независимый указатель на g. Значительная часть
кода рантайма полагается на getg() для получения текущей горутины, и за этим стоит единственное
чтение из TLS. В Linux настройка TLS сводится к системному вызову, который указывает базу
сегментного регистра FS на m0.tls:
|
|
На этом шаге различные операционные системы расходятся: Darwin, OpenBSD, Plan 9, Solaris и illumos —
у каждой свой механизм размещения TLS, и rt0_go использует условную компиляцию, чтобы пропустить
обобщённый settls и предоставить обработку платформенному коду. Как только TLS готово, рантайм
записывает магическое число и считывает его обратно для проверки, убеждаясь, что путь «найти
текущий g» действительно работает, и лишь затем закрепляет g0 и m0 в TLS и завершает их
взаимную привязку:
|
|
На этом этапе главный поток имеет стек планирования, рабочий путь getg(), а g0 и m0
связаны друг с другом. Только теперь рантайм получает возможность вызывать Go-код и выполнять
оставшуюся инициализацию, написанную на Go. Далее rt0_go последовательно вызывает check,
args и osinit, выполняя проверку и собирая системные данные перед основной инициализацией:
|
|
check — это самотестирование, проверяющее предположения компилятора: одно за другим
подтверждаются допущения вроде «int8 занимает 1 байт» и ширина указателя. Если компилятор
допустил здесь ошибку, все выводы рантайма окажутся неверными, поэтому он предпочтёт
вызвать throw прямо здесь. args сохраняет argc/argv в глобальные переменные и в Linux
читает дальше по стеку вспомогательный вектор (auxv), извлекая размер физической страницы
памяти из _AT_PAGESZ. osinit получает количество ядер CPU (от которого зависит количество P,
описанных ниже), а в Darwin, поскольку размер страницы не удалось получить из auxv ранее,
компенсирует это здесь при помощи sysctl. Эти две системные константы — количество ядер CPU
и размер физической страницы — являются основой для последующей инициализации памяти и
планирования.
3.5.2 schedinit: пробуждение рантайма в правильном порядке
schedinit называется «инициализацией шедулера», однако в действительности это сборочный цех
всего рантайма: память, стеки, сборка мусора, сигналы, планирование — почти каждая подсистема
запускается здесь. Между ними существуют жёсткие зависимости по порядку, и эту
последовательность нельзя менять. С удалёнными инициализациями блокировок и диагностическими
ветвями основная последовательность выглядит следующим образом:
|
|
Порядок этой цепочки вызовов сам по себе является спецификацией зависимостей. stackinit
идёт раньше всего, потому что последующая инициализация сама нуждается в стеке; randinit
должен предшествовать mallocinit, поскольку часть рандомизации аллокатора использует его;
mallocinit (12) выстраивает многоуровневую структуру из
mcache, mcentral и mheap, и, в свою очередь, должен идти до gcinit, поскольку сборщик мусора
(13) работает поверх арены и битовой карты, которые подготовил
аллокатор. sigsave записывает начальную маску сигналов, закладывая основу для последующего
механизма обработки сигналов (9.6). Этот линейный
порядок, при котором «зависящий размещается после того, от чего зависит», — отличительная черта
кода начальной загрузки в противоположность обычному коду рантайма: в этот момент параллелизма
ещё нет, всё продвигается однопоточно на главном потоке, и порядок равнозначен корректности.
Вся инициализация завершается вызовом procresize(procs), который создаёт список процессоров P
в соответствии с числом procs (9.1). Как
определяется procs? Если переменная окружения GOMAXPROCS задана явно, используется её
значение; в противном случае берётся defaultGOMAXPROCS(numCPUStartup). Здесь стоит отметить
одну эволюцию: ранние версии напрямую использовали количество ядер CPU машины в качестве значения
по умолчанию, но внутри контейнера они зачастую «видели» все ядра хост-машины, а не квоту CPU,
выделенную cgroup данному процессу, поэтому создавалось слишком много P, и планирование вместе
со сборкой мусора фактически страдали. Начиная с Go 1.25, значение по умолчанию в Linux стало
учитывать cgroup: при запуске рантайм читает /proc/self/cgroup и cpu.max и округляет квоту
CPU вверх для вычисления количества P по умолчанию, более точно соответствующего реальной
ситуации контейнера (см. runtime/cgroup_linux.go). Это конкретное воплощение принципа
«значения по умолчанию должны соответствовать реальности развёртывания».
К моменту возврата из procresize локальные очереди выполнения шедулера, mcache каждого P и
прочие структуры уже на месте. Возврат ненулевой горутины в состоянии runnable не должен
происходить (на данный момент ни одна горутина ещё не создана), поэтому throw служит
страховкой. Затем worldStarted объявляет, что «мир» запущен, и P теперь соответствует
условиям для выполнения горутин. Ствол рантайма к этому моменту собран — не хватает лишь
первой исполнительной единицы для планирования.
3.5.3 Первая горутина и цикл планирования
После возврата из schedinit в rt0_go остаётся всего три шага, однако именно они завершают
опасный прыжок от «рантайм готов» к «пользовательский код выполняется»:
|
|
mainPC — это символ в сегменте данных, содержащий адрес точки входа runtime.main, который
служит стартовой точкой первой горутины:
|
|
Обратите внимание: точка входа первой горутины — не пользовательская main.main, а runtime.main.
Этот уровень косвенности введён намеренно: runtime.main должна сначала выполнить ряд завершающих
операций, которые удобны только «в контексте горутины» (запуск потока системного мониторинга sysmon,
выполнение init каждого пакета, включение GC и т. д.), и лишь затем вызвать пользовательскую
main.main. Этой теме посвящён раздел 3.6 Жизнь и смерть главной горутины.
newproc оборачивает адрес точки входа в новую горутину и ставит её в очередь, где она ожидает
планирования:
|
|
newproc1 запрашивает структуру g (по возможности используя повторно из списка свободных),
выделяет для неё стек выполнения, заполняет счётчик команд и кадр стека, устанавливает
состояние _Grunnable, после чего runqput помещает её в локальную очередь выполнения текущего P
(9.1). В этот момент она лишь «готова к выполнению»,
но ещё фактически не выполняется.
Последний шаг, mstart, передаёт главный поток шедулеру. mstart переключается на стек g0
и входит в цикл планирования schedule: цикл выбирает горутину в состоянии runnable из
локальной или глобальной очереди выполнения, выполняет execute над ней и передаёт управление
на её стек и счётчик команд. Единственный элемент в очереди в данный момент — именно только что
поставленная горутина, несущая runtime.main. Поэтому шедулер выбирает её, переходит в
runtime.main, и занавес над миром пользователя поднимается. mstart обычно не возвращается,
и главный поток с этого момента навсегда остаётся в цикле планирования.
Собирая всю цепочку воедино, получаем следующий граф вызовов начальной загрузки:
flowchart TD
OS["ОС загружает исполняемый файл"] --> RT0["_rt0_amd64_*: получение argc/argv"]
RT0 --> RTGO["runtime·rt0_go"]
subgraph BOOT["на главном потоке, однопоточное продвижение (параллелизма ещё нет)"]
RTGO --> G0["выделение стека выполнения g0, опрос CPUID"]
G0 --> TLS["settls: привязка TLS, закрепление g0/m0 и их связывание"]
TLS --> SYS["check / args / osinit: самотест типов, auxv, число ядер и размер страницы"]
SYS --> SI["schedinit: пробуждение каждой подсистемы"]
SI --> SI1["stackinit → mallocinit (12) → gcinit (13)"]
SI1 --> SI2["sigsave (9.6)"]
SI2 --> SI3["procresize: построение списка P по GOMAXPROCS (9.1, с учётом контейнеров)"]
end
SI3 --> NP["newproc: создание первой горутины с runtime.main, постановка в очередь"]
NP --> MS["mstart входит в цикл планирования schedule"]
MS --> RM["выбор этой горутины, переход в runtime.main (см. 3.6)"]
Возвращаясь к фразе, которой мы открыли раздел: к тому моменту, когда шедулер впервые переходит
в runtime.main, аллокатор, сборщик, шедулер, обработка сигналов и набор P с их локальными кешами
уже работают. main читателя — не более чем первая пользовательская задача, поставленная на
выполнение после завершения запуска этой миниатюрной операционной системы. Понять начальную
загрузку — значит понять фундаментальную характеристику «рантайм и пользовательский код
сосуществуют в одном бинарном файле»: программа, которую вы пишете, никогда не работает в
одиночку — она всегда работает поверх слоя рантайма, который уже пробудился. Как именно каждый
компонент инициализируется внутри, оставлено для раскрытия в соответствующей главе; следующий
раздел 3.6 продолжает рассмотрением того, как runtime.main завершает процесс
запуска и как позволяет всей программе сделать свой финальный поклон после возврата
пользовательской main.
Дополнительные материалы
- The Go Authors. runtime/asm_amd64.s (
runtime·rt0_go), rt0_linux_amd64.s, rt0_darwin_amd64.s. https://github.com/golang/go/tree/master/src/runtime (ассемблерная точка входа, настройка g0/m0/TLS) - The Go Authors. runtime/proc.go (
schedinit,newproc,mstart,schedule). https://github.com/golang/go/blob/master/src/runtime/proc.go - The Go Authors. runtime/cgroup_linux.go (значение
GOMAXPROCSпо умолчанию с учётом cgroup, Go 1.25+). https://github.com/golang/go/blob/master/src/runtime/cgroup_linux.go - The Go Authors. Документация пакета runtime. https://pkg.go.dev/runtime
- Michael Matz, Jan Hubička, Andreas Jaeger, Mark Mitchell. System V Application Binary Interface: AMD64 Architecture Processor Supplement. 2014. https://www.uclibc.org/docs/psABI-x86_64.pdf (разметка стека процесса ELF и вспомогательный вектор auxv)
- Эта книга, 1.2 Обзор языка Go (перспектива бинарного файла, содержащего миниатюрную операционную систему), 9.1 Задача планирования и модель GMP (список P и цикл планирования), 9.6 Механизм обработки сигналов.
- Эта книга, Глава 12. Аллокатор памяти,
Глава 13. Сборщик мусора (структуры, построенные
mallocinitиgcinit), 3.6 Жизнь и смерть главной горутины.