3.6 Жизнь и смерть главной горутины
В 3.5, после того как schedinit собрал фундамент рантайма, прямого вызова runtime.main не происходит. Вместо этого адрес точки входа этой функции помещается на стек, передаётся в newproc для создания первой горутины, а затем mstart запускает цикл планирования и выбирает эту горутину для выполнения. Детали планирования оставлены для 9 Шедулер. В данном разделе внимание сосредоточено на одном конкретном моменте: первая горутина уже запущена и готова выполнить runtime.main.
Эта горутина отличается от всех остальных. Она первой рождается в программе, несёт пользовательскую main.main и единолично обладает властью над жизнью и смертью всего процесса: в момент её завершения процесс прекращается, даже если тысяча других горутин где-то ещё продолжают работу. Понимание того, что она делает и когда останавливается, — последний фрагмент в картине «как Go-программа запускается и как завершается в целом».
3.6.1 runtime.main: подготовка перед пользовательским кодом
Функция main в пакете runtime (то есть runtime.main) выполняется в той же горутине, что и пользовательская main.main, но перед передачей управления пользователю она решает ряд задач, которые могут быть выполнены только «первой горутиной». Ниже приведён сокращённый набросок, содержащий только каркас, относящийся к жизненному циклу:
|
|
В этом каркасе есть несколько мест, на которых стоит остановиться.
runtimeInitTime = nanotime() — первая временна́я метка, поставленная на весь рантайм, «мир начинается в этот момент». Частота срабатываний GC, статистика планирования и трассировка init (строка @x ms, выводимая при GODEBUG=inittrace=1) — все они принимают это значение за нулевую точку, поэтому оно должно быть установлено до начала любой init.
newm(sysmon, nil, -1) порождает на системном стеке поток-монитор sysmon, не привязанный ни к одному P. Это «фоновый надзиратель» рантайма, периодически вытесняющий долго работающие горутины, освобождающий простаивающие ресурсы и при необходимости инициирующий GC. Подробности его работы описаны в 9.8 Системный монитор. Обратите внимание на условие haveSysmon: на однопоточных платформах, таких как wasm, этот поток не создаётся.
До вызова gcenable() сборщик мусора «выключен»; во время инициализации его вмешательство нежелательно. Этот шаг по-настоящему подключает GC, после чего рост кучи начинает подчиняться его ритму. Механизм описан в 13 Сборка мусора.
Что касается пары lockOSThread()/unlockOSThread(), она существует потому, что на ряде платформ определённые вызовы при инициализации обязаны выполняться в главном потоке ОС; если пользователь вызывает runtime.LockOSThread внутри init, то и main.main будет удержана в главном потоке.
По завершении подготовительной работы главный действующий персонаж выходит на сцену в два шага: сначала doInit проходит через все init, затем main_main запускает пользовательскую main.main. Здесь кроется часто упускаемый из виду факт: все пользовательские функции init и main.main выполняются в одной и той же горутине, строго последовательно. Если init порождает другую горутину, та может выполняться параллельно с последующими init, однако порядок между init и от init к main.main всегда является последовательным.
3.6.2 Порядок инициализации пакетов: doInit и граф зависимостей
«В каком порядке выполняются init» — один из вопросов, который чаще всего вызывает путаницу у читателей, и ответ на него заслуживает полной ясности (этот раздел отвечает на отслеживаемый issue #75). Ответ строится из двух уровней правил: между пакетами порядок следует графу зависимостей; внутри пакета порядок определяется зависимостями переменных и расположением в исходном коде. Оба уровня явно определены спецификацией языка Go, а не деталями реализации.
Между пакетами: импортируемые пакеты инициализируются первыми, каждый пакет — однократно
Спецификация формулирует это прямо в разделе Program initialization: если пакет имеет импорты, импортируемые пакеты инициализируются раньше него; когда несколько пакетов импортируют один и тот же пакет, он инициализируется только один раз. Точнее, из всех пакетов, упорядоченных по пути импорта, на каждом шаге выбирается первый пакет, который «ещё не инициализирован, и все его импорты уже инициализированы», — он инициализируется, и процесс повторяется до готовности всех пакетов. Связи по импорту естественным образом формируют направленный ациклический граф, поэтому такое упорядочение всегда может быть завершено; циклической инициализации не бывает.
Компоновщик фиксирует этот порядок зависимостей в списке inittasks каждого модуля (moduledata), а рантайм просто принимает его как данность. Тот самый цикл for m := &firstmoduledata в runtime.main — это именно «обход модулей в порядке зависимостей и вызов doInit для каждого». Сама функция doInit лишь передаёт последовательность initTask функции doInit1 для поочерёдного выполнения:
|
|
Три состояния поля state точно отображают два ограничения спецификации в код: case 2 гарантирует, что каждый пакет инициализируется не более одного раза (даже если импортируется из нескольких мест), а throw в case 1 — это проверка рантайма на «отсутствие циклической инициализации»; если она срабатывает, это означает ошибку в упорядочении, произведённом компоновщиком.
Внутри пакета: сначала зависимости переменных, затем init в порядке исходного кода
Внутри одного пакета спецификация требует, чтобы сначала были инициализированы все переменные уровня пакета, а затем вызваны функции init этого пакета. Порядок инициализации переменных — не просто сверху вниз; он продвигается пошагово, следуя зависимостям: на каждом шаге выбирается переменная, которая «первая по порядку объявления и чьё выражение инициализации не зависит от ещё не инициализированных переменных». Пример из спецификации наглядно иллюстрирует это:
|
|
Хотя a записана первой, поскольку она зависит от b и c, её значение определяется последним; d не имеет зависимостей и используется в f, поэтому инициализируется первой. Анализ зависимостей рассматривает только лексические ссылки в исходном коде (и берёт транзитивное замыкание), а не значения во время выполнения, поэтому a = c + b и a = b + c дают одинаковый порядок. При наличии нескольких файлов «порядок объявления» переменной определяется порядком, в котором файлы предъявляются компилятору; спецификация рекомендует системам сборки предъявлять файлы в лексикографическом порядке имён для обеспечения воспроизводимости.
После того как переменные готовы, функции init этого пакета вызываются в порядке их появления в исходном коде, возможно, из нескольких файлов, причём один файл может объявлять несколько таких функций. На функцию init нельзя ссылаться; она не принимает аргументов и не возвращает значений; единственное назначение её существования — «быть выполненной один раз при инициализации». Это в точности соответствует простому циклу for i в doInit1: компоновщик уже расположил указатели в порядке исходного кода, а рантайм просто вызывает их в этой последовательности.
Объединяя два уровня правил, инициализация программы представляет собой дерево следующего вида, обходимое в глубину, с посещением каждого узла только один раз:
|
|
Зависимости fmt и net/http инициализируются аккуратно в глубину, а низкоуровневые пакеты, разделяемые несколькими пакетами (такие как runtime, internal/*), инициализируются лишь однажды; когда очередь доходит до пакета main, сначала подготавливается x, затем запускается init, и лишь после этого управление передаётся в main.main. Весь процесс происходит последовательно в главной горутине.
3.6.3 Ключевая асимметрия: со смертью главной горутины завершается процесс
После возврата из main_main функция runtime.main не предпринимает никаких попыток «подождать завершения остальных горутин»; она немедленно переходит к exit(0), и процесс исчезает вместе с ней. За этим скрывается асимметрия в модели конкурентности Go, которую необходимо твёрдо помнить:
Главная горутина и обычная горутина неравноправны. Когда обычная горутина завершается, со сцены уходит только она; когда завершается главная горутина, она забирает с собой весь процесс. В момент возврата из
main.mainвсе ещё работающие горутины прерываются на месте, без очистки и без предупреждения.
Диаграмма ниже иллюстрирует этот односторонний жизненный цикл:
stateDiagram-v2
[*] --> schedinit: инициализация рантайма (3.5)
schedinit --> runtimeMain: mstart планирует первую G
runtimeMain --> sysmon: newm(sysmon) запускает монитор (9.8)
runtimeMain --> doInit: после gcenable
doInit --> mainMain: все init завершены
mainMain --> exit: main.main возвращается
mainMain --> otherG: go f() в init/main
otherG --> killed: главная G завершилась — неважно, работала ли
exit --> [*]: exit(0) завершает весь процесс
killed --> [*]
Прямое следствие этого правила: если main.main не выполняет явного ожидания, фоновая горутина может быть уничтожена, не успев выполнить ни одной строки. Поэтому в конкурентной программе главная горутина обязана выполнять явную синхронизацию; наиболее распространённые средства — sync.WaitGroup или канал, а механизмы описаны в 11 Примитивы и паттерны синхронизации. Разница между двумя фрагментами ниже — это именно «ожидание» против «отсутствия ожидания»:
|
|
|
|
Эта асимметрия также подтверждает другое проектное решение: Go не предоставляет API для «уничтожения горутины извне» (см. 11 Примитивы и паттерны синхронизации). Момент завершения горутины может быть определён только ею самой (нормальный возврат или runtime.Goexit), и единственное исключение — «коллективное» прерывание всех горутин при завершении главной. Иначе говоря, завершение на уровне процесса — единственный путь в Go для «принудительного завершения горутин извне»; он груб и тотален. Именно поэтому решение о моменте выхода передано главной горутине, а от разработчиков требуется явная синхронизация — таков компромисс этой модели: в обмен достигается простота собственного состояния горутины (не нужно обрабатывать промежуточное состояние «асинхронно уничтожена»), ценой необходимости для разработчиков самостоятельно управлять моментом завершения.
На этом основная линия повествования о том, как Go-программа проходит путь от начальной загрузки через инициализацию до завершения в целом, завершена. У читателя могут остаться вопросы: как именно mstart планирует главную горутину? Что делает sysmon в фоновом режиме? И как на самом деле работает GC, подключённый через gcenable? Всё это оставлено соответствующим главам (9 Шедулер, 13 Сборка мусора).
Дополнительная литература
- The Go Authors. The Go Programming Language Specification: Package initialization & Program initialization. https://go.dev/ref/spec#Package_initialization
- The Go Authors. runtime/proc.go (
func main,doInit,doInit1,initTask). https://github.com/golang/go/blob/master/src/runtime/proc.go - The Go Authors. Effective Go: The init function. https://go.dev/doc/effective_go#init
- Russ Cox.
main_init_donecan be implemented more efficiently. Go issue #15943. https://github.com/golang/go/issues/15943 - The Go Authors. Command compile. https://go.dev/cmd/compile/
- Эта книга: 3.5 Загрузка и инициализация Go-программы, 9.8 Системный монитор, 11 Примитивы и паттерны синхронизации.