Go under the hood
Go: Under the Hood

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, но перед передачей управления пользователю она решает ряд задач, которые могут быть выполнены только «первой горутиной». Ниже приведён сокращённый набросок, содержащий только каркас, относящийся к жизненному циклу:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Точка входа главной горутины (сокращено из runtime/proc.go)
func main() {
	mp := getg().m

	// Ограничение стека выполнения: 1 ГБ на 64-битных, 250 МБ на 32-битных системах
	// (в десятичном виде удобнее читать в выводе при аварийном завершении)
	if goarch.PtrSize == 8 {
		maxstacksize = 1000000000
	} else {
		maxstacksize = 250000000
	}

	mainStarted = true            // разрешить newproc запускать новые M

	if haveSysmon {               // запуск потока системного монитора (см. 9.8)
		systemstack(func() {
			newm(sysmon, nil, -1)
		})
	}

	lockOSThread()                // привязать главную G к главному потоку ОС на время init
	if mp != &m0 {
		throw("runtime.main not on m0")
	}

	runtimeInitTime = nanotime()  // зафиксировать момент «начала мира», должен предшествовать doInit

	doInit(runtime_inittasks)     // запустить собственную инициализацию рантайма (вкл. GC, типы defer и т.д.)

	gcenable()                    // включить сборщик мусора (см. 13)

	// запустить задачи инициализации всех модулей (вкл. пользовательские пакеты) в порядке зависимостей
	last := lastmoduledatap
	for m := &firstmoduledata; true; m = m.next {
		doInit(m.inittasks)
		if m == last {
			break
		}
	}

	unlockOSThread()

	fn := main_main               // косвенный вызов: компоновщик ещё не знает адрес пакета main
	fn()

	exit(0)                       // по завершении главной G весь процесс завершается
}

В этом каркасе есть несколько мест, на которых стоит остановиться.

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 для поочерёдного выполнения:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Задача инициализации каждого пакета (сокращено из runtime/proc.go)
type initTask struct {
	state uint32 // 0 — не инициализирован, 1 — выполняется, 2 — завершён
	nfns  uint32 // nfns указателей на функции init, следующих непосредственно за структурой
}

func doInit1(t *initTask) {
	switch t.state {
	case 2:                 // завершён: вернуться сразу, гарантируя «однократное выполнение для пакета»
		return
	case 1:                 // повторный вход в процессе выполнения: не должно происходить в графе, означает ошибку компоновщика
		throw("recursive call during initialization - linker skew")
	default:
		t.state = 1
		firstFunc := add(unsafe.Pointer(t), 8)
		for i := uint32(0); i < t.nfns; i++ {  // вызвать каждую init этого пакета по порядку
			p := add(firstFunc, uintptr(i)*goarch.PtrSize)
			f := *(*func())(unsafe.Pointer(&p))
			f()
		}
		t.state = 2         // пометить как завершённый
	}
}

Три состояния поля state точно отображают два ограничения спецификации в код: case 2 гарантирует, что каждый пакет инициализируется не более одного раза (даже если импортируется из нескольких мест), а throw в case 1 — это проверка рантайма на «отсутствие циклической инициализации»; если она срабатывает, это означает ошибку в упорядочении, произведённом компоновщиком.

Внутри пакета: сначала зависимости переменных, затем init в порядке исходного кода

Внутри одного пакета спецификация требует, чтобы сначала были инициализированы все переменные уровня пакета, а затем вызваны функции init этого пакета. Порядок инициализации переменных — не просто сверху вниз; он продвигается пошагово, следуя зависимостям: на каждом шаге выбирается переменная, которая «первая по порядку объявления и чьё выражение инициализации не зависит от ещё не инициализированных переменных». Пример из спецификации наглядно иллюстрирует это:

1
2
3
4
5
6
7
8
var (
	a = c + b  // результат 9
	b = f()    // результат 4
	c = f()    // результат 5
	d = 3      // станет 5 после завершения инициализации
)
func f() int { d++; return d }
// порядок инициализации: d, b, c, a

Хотя a записана первой, поскольку она зависит от b и c, её значение определяется последним; d не имеет зависимостей и используется в f, поэтому инициализируется первой. Анализ зависимостей рассматривает только лексические ссылки в исходном коде (и берёт транзитивное замыкание), а не значения во время выполнения, поэтому a = c + b и a = b + c дают одинаковый порядок. При наличии нескольких файлов «порядок объявления» переменной определяется порядком, в котором файлы предъявляются компилятору; спецификация рекомендует системам сборки предъявлять файлы в лексикографическом порядке имён для обеспечения воспроизводимости.

После того как переменные готовы, функции init этого пакета вызываются в порядке их появления в исходном коде, возможно, из нескольких файлов, причём один файл может объявлять несколько таких функций. На функцию init нельзя ссылаться; она не принимает аргументов и не возвращает значений; единственное назначение её существования — «быть выполненной один раз при инициализации». Это в точности соответствует простому циклу for i в doInit1: компоновщик уже расположил указатели в порядке исходного кода, а рантайм просто вызывает их в этой последовательности.

Объединяя два уровня правил, инициализация программы представляет собой дерево следующего вида, обходимое в глубину, с посещением каждого узла только один раз:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Иллюстрация: связи импорта определяют общий порядок init
package main

import (
	"fmt"        // сначала инициализировать fmt и его зависимости
	_ "net/http" // затем инициализировать net/http и его зависимости
)

var x = compute() // переменные уровня пакета готовы до main.init

func init() { fmt.Println("main init") } // вызывается после готовности переменных

func main() { fmt.Println("main") }

Зависимости 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 Примитивы и паттерны синхронизации. Разница между двумя фрагментами ниже — это именно «ожидание» против «отсутствия ожидания»:

1
2
3
4
func main() {
	go func() { fmt.Println("может так и не быть напечатано") }()
	// main возвращается немедленно; дочерняя горутина, вероятно, ещё не запланирована, а процесс уже завершён
}
1
2
3
4
5
6
func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() { defer wg.Done(); fmt.Println("гарантированно будет напечатано") }()
	wg.Wait() // главная G ожидает здесь, возвращаясь только после завершения дочерней горутины
}

Эта асимметрия также подтверждает другое проектное решение: Go не предоставляет API для «уничтожения горутины извне» (см. 11 Примитивы и паттерны синхронизации). Момент завершения горутины может быть определён только ею самой (нормальный возврат или runtime.Goexit), и единственное исключение — «коллективное» прерывание всех горутин при завершении главной. Иначе говоря, завершение на уровне процесса — единственный путь в Go для «принудительного завершения горутин извне»; он груб и тотален. Именно поэтому решение о моменте выхода передано главной горутине, а от разработчиков требуется явная синхронизация — таков компромисс этой модели: в обмен достигается простота собственного состояния горутины (не нужно обрабатывать промежуточное состояние «асинхронно уничтожена»), ценой необходимости для разработчиков самостоятельно управлять моментом завершения.

На этом основная линия повествования о том, как Go-программа проходит путь от начальной загрузки через инициализацию до завершения в целом, завершена. У читателя могут остаться вопросы: как именно mstart планирует главную горутину? Что делает sysmon в фоновом режиме? И как на самом деле работает GC, подключённый через gcenable? Всё это оставлено соответствующим главам (9 Шедулер, 13 Сборка мусора).

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

  1. The Go Authors. The Go Programming Language Specification: Package initialization & Program initialization. https://go.dev/ref/spec#Package_initialization
  2. The Go Authors. runtime/proc.go (func main, doInit, doInit1, initTask). https://github.com/golang/go/blob/master/src/runtime/proc.go
  3. The Go Authors. Effective Go: The init function. https://go.dev/doc/effective_go#init
  4. Russ Cox. main_init_done can be implemented more efficiently. Go issue #15943. https://github.com/golang/go/issues/15943
  5. The Go Authors. Command compile. https://go.dev/cmd/compile/
  6. Эта книга: 3.5 Загрузка и инициализация Go-программы, 9.8 Системный монитор, 11 Примитивы и паттерны синхронизации.