Go under the hood
Go: Under the Hood

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:

1
2
3
4
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
	JMP	_rt0_amd64(SB)
TEXT _rt0_amd64_darwin(SB),NOSPLIT,$-8
	JMP	_rt0_amd64(SB)

Зачем разделять точку входа по двум измерениям — «архитектура + операционная система»? Потому что после компиляции программы в машинный код набор инструкций зависит только от архитектуры процессора, тогда как различия между операционными системами проявляются на уровне системных операций, таких как системные вызовы. Одна _rt0_amd64 может совместно использоваться несколькими операционными системами, и каждая из них переходит в неё из своего собственного символа точки входа. rt0 — это сокращение от runtime0, обозначающее начало рантайма; каждый последующий объект того же рода получает суффикс 1, и именно отсюда g0 и m0 берут свои имена.

_rt0_amd64 сначала снимает со стека аргументы, переданные операционной системой, после чего переходит в rt0_go, где выполняется основная работа. При только что запущенной программе первые два слота по указателю стека SP — это argc и argv:

1
2
3
4
TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

rt0_go — стержень всей начальной загрузки. Сначала он переносит аргументы на выровненный стек, затем выполняет первый реальный шаг инициализации: на сегменте стека операционной системы, который в данный момент использует главный поток, он «занимает» стек выполнения для g0. g0 — это стек планирования каждого потока, и код самого рантайма (планирование, рост стека, некоторые фазы сборки мусора) выполняется на нём, а не на стеке пользовательской горутины:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
	MOVQ	DI, AX			// argc
	MOVQ	SI, BX			// argv
	SUBQ	$(5*8), SP		// align to an even stack
	ANDQ	$~15, SP
	MOVQ	AX, 24(SP)
	MOVQ	BX, 32(SP)

	// использовать стек, предоставленный ОС, для выделения стека выполнения g0
	MOVQ	$runtime·g0(SB), DI
	LEAQ	(-64*1024)(SP), BX
	MOVQ	BX, g_stackguard0(DI)		// g0.stackguard0
	MOVQ	BX, g_stackguard1(DI)		// g0.stackguard1
	MOVQ	BX, (g_stack+stack_lo)(DI)	// g0.stack.lo = SP - 64KB
	MOVQ	SP, (g_stack+stack_hi)(DI)	// g0.stack.hi = SP

	// получение информации о процессоре через CPUID
	MOVL	$0, AX
	CPUID
	(...)

g0 и m0 — пара глобальных переменных, которые существуют статически с самого начала работы программы и не требуют выделения памяти. g0 — это стек планирования для m0, а m0 представляет главный поток. Следующая задача — связать их друг с другом, и предварительным условием для этого является следующее: поток должен в любой момент иметь возможность определить, какая горутина «сейчас выполняется». Эту задачу решает локальное хранилище потока (TLS).

TLS предоставляет каждому потоку собственный независимый указатель на g. Значительная часть кода рантайма полагается на getg() для получения текущей горутины, и за этим стоит единственное чтение из TLS. В Linux настройка TLS сводится к системному вызову, который указывает базу сегментного регистра FS на m0.tls:

1
2
3
4
5
6
7
8
TEXT runtime·settls(SB),NOSPLIT,$32
	ADDQ	$8, DI			// соглашение ELF использует -8(FS)
	MOVQ	DI, SI
	MOVQ	$0x1002, DI		// 0x1002 == ARCH_SET_FS
	MOVQ	$SYS_arch_prctl, AX
	SYSCALL
	(...)
	RET

На этом шаге различные операционные системы расходятся: Darwin, OpenBSD, Plan 9, Solaris и illumos — у каждой свой механизм размещения TLS, и rt0_go использует условную компиляцию, чтобы пропустить обобщённый settls и предоставить обработку платформенному коду. Как только TLS готово, рантайм записывает магическое число и считывает его обратно для проверки, убеждаясь, что путь «найти текущий g» действительно работает, и лишь затем закрепляет g0 и m0 в TLS и завершает их взаимную привязку:

1
2
3
4
5
6
7
ok:
	get_tls(BX)
	LEAQ	runtime·g0(SB), CX
	MOVQ	CX, g(BX)		// текущий g в TLS = g0
	LEAQ	runtime·m0(SB), AX
	MOVQ	CX, m_g0(AX)	// m0.g0 = g0
	MOVQ	AX, g_m(CX)		// g0.m  = m0

На этом этапе главный поток имеет стек планирования, рабочий путь getg(), а g0 и m0 связаны друг с другом. Только теперь рантайм получает возможность вызывать Go-код и выполнять оставшуюся инициализацию, написанную на Go. Далее rt0_go последовательно вызывает check, args и osinit, выполняя проверку и собирая системные данные перед основной инициализацией:

1
2
3
4
5
	CALL	runtime·check(SB)		// проверка предположений компилятора о размерах типов
	(...)
	CALL	runtime·args(SB)		// сохранение argc/argv, разбор auxv
	CALL	runtime·osinit(SB)		// получение количества ядер CPU и размера физической страницы
	CALL	runtime·schedinit(SB)	// пробуждение компонентов рантайма

check — это самотестирование, проверяющее предположения компилятора: одно за другим подтверждаются допущения вроде «int8 занимает 1 байт» и ширина указателя. Если компилятор допустил здесь ошибку, все выводы рантайма окажутся неверными, поэтому он предпочтёт вызвать throw прямо здесь. args сохраняет argc/argv в глобальные переменные и в Linux читает дальше по стеку вспомогательный вектор (auxv), извлекая размер физической страницы памяти из _AT_PAGESZ. osinit получает количество ядер CPU (от которого зависит количество P, описанных ниже), а в Darwin, поскольку размер страницы не удалось получить из auxv ранее, компенсирует это здесь при помощи sysctl. Эти две системные константы — количество ядер CPU и размер физической страницы — являются основой для последующей инициализации памяти и планирования.

3.5.2 schedinit: пробуждение рантайма в правильном порядке

schedinit называется «инициализацией шедулера», однако в действительности это сборочный цех всего рантайма: память, стеки, сборка мусора, сигналы, планирование — почти каждая подсистема запускается здесь. Между ними существуют жёсткие зависимости по порядку, и эту последовательность нельзя менять. С удалёнными инициализациями блокировок и диагностическими ветвями основная последовательность выглядит следующим образом:

 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
// runtime/proc.go (упрощённая схема, сохранён только порядок ключевых вызовов)
func schedinit() {
	gp := getg()

	sched.maxmcount = 10000   // ограничение максимального числа системных потоков
	worldStopped()            // во время начальной загрузки «мир» находится в остановленном состоянии

	stackinit()    // аллокатор стеков (кеш стеков, пул стеков)
	randinit()     // источник случайных чисел, должен предшествовать mallocinit
	mallocinit()   // аллокатор памяти (см. 12)
	cpuinit(godebug)
	mcommoninit(gp.m, -1) // инициализация общих полей m0
	modulesinit()         // информация о связывании модулей и типов
	typelinksinit()
	itabsinit()

	sigsave(&gp.m.sigmask) // маска сигналов (см. 9.6)
	goargs()
	goenvs()
	gcinit()       // сборщик мусора (см. 13)

	// определение количества P на основе числа ядер CPU и GOMAXPROCS
	var procs int32
	if n, err := strconv.ParseInt(gogetenv("GOMAXPROCS"), 10, 32); err == nil && n > 0 {
		procs = n
		sched.customGOMAXPROCS = true
	} else {
		procs = defaultGOMAXPROCS(numCPUStartup)
	}
	if procresize(procs) != nil { // создание списка P (см. 9.1)
		throw("unknown runnable goroutine during bootstrap")
	}
	worldStarted() // P готовы к работе, мир официально запущен
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	CALL	runtime·schedinit(SB)

	// создание первой горутины, несущей runtime.main
	MOVQ	$runtime·mainPC(SB), AX	// адрес точки входа
	PUSHQ	AX
	CALL	runtime·newproc(SB)
	POPQ	AX

	// запуск данного M, вход в цикл планирования, из которого обычно нет возврата
	CALL	runtime·mstart(SB)

mainPC — это символ в сегменте данных, содержащий адрес точки входа runtime.main, который служит стартовой точкой первой горутины:

1
2
DATA	runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

Обратите внимание: точка входа первой горутины — не пользовательская main.main, а runtime.main. Этот уровень косвенности введён намеренно: runtime.main должна сначала выполнить ряд завершающих операций, которые удобны только «в контексте горутины» (запуск потока системного мониторинга sysmon, выполнение init каждого пакета, включение GC и т. д.), и лишь затем вызвать пользовательскую main.main. Этой теме посвящён раздел 3.6 Жизнь и смерть главной горутины.

newproc оборачивает адрес точки входа в новую горутину и ставит её в очередь, где она ожидает планирования:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// runtime/proc.go (схема)
func newproc(fn *funcval) {
	gp := getg()
	pc := sys.GetCallerPC()
	systemstack(func() {
		newg := newproc1(fn, gp, pc, false, waitReasonZero)
		pp := getg().m.p.ptr()
		runqput(pp, newg, true) // поместить в локальную очередь выполнения текущего P
		if mainStarted {
			wakep() // при необходимости разбудить свободный P для захвата работы
		}
	})
}

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.

Дополнительные материалы

  1. 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)
  2. The Go Authors. runtime/proc.go (schedinit, newproc, mstart, schedule). https://github.com/golang/go/blob/master/src/runtime/proc.go
  3. The Go Authors. runtime/cgroup_linux.go (значение GOMAXPROCS по умолчанию с учётом cgroup, Go 1.25+). https://github.com/golang/go/blob/master/src/runtime/cgroup_linux.go
  4. The Go Authors. Документация пакета runtime. https://pkg.go.dev/runtime
  5. 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)
  6. Эта книга, 1.2 Обзор языка Go (перспектива бинарного файла, содержащего миниатюрную операционную систему), 9.1 Задача планирования и модель GMP (список P и цикл планирования), 9.6 Механизм обработки сигналов.
  7. Эта книга, Глава 12. Аллокатор памяти, Глава 13. Сборщик мусора (структуры, построенные mallocinit и gcinit), 3.6 Жизнь и смерть главной горутины.