golang

Деконструкция Go: Runtime или программа, которая запускает вашу программу. Часть 3.1

  • вторник, 28 июля 2026 г. в 00:00:08
https://habr.com/ru/articles/1063194/

В предыдущих частях мы разобрались с тем, во что превращается Go‑код, как процессор работает с памятью и каким образом реализуются примитивные атомарные операции.

Но всё это порождает закономерный вопрос.

Если CPU умеет выполнять только машинные инструкции, а операционная система знает лишь о процессах и системных потоках, то кто вообще знает о существовании горутин?

Кто увеличивает их стеки?

Кто решает, какую из них сейчас выполнять?

Кто собирает мусор?

Ответ на всё это один — Go runtime.

Но runtime — это не какая‑то магическая программа, которая находится между нашим приложением и операционной системой. Это часть самого приложения.

Давайте разбираться!

Who is runtime

Для начала введем определение

Runtime — это код и внутренние структуры данных, обеспечивающие выполнение тех свойств языка, которые невозможно выразить только машинными инструкциями или средствами операционной системы.

Обращу внимание на то, что Runtime — это не:

  • Отдельный процесс;

  • Сервис операционной системы;

  • Аналог JVM;

  • Интерпретатор;

  • Только планировщик;

  • Импортируемый пакет runtime.(вообще этот пакет предоставляет только публичные функции)

Проще говоря, это некий набор системных функций, которые управляют какими‑либо частями нашего кода(например, планирование операций или выделение памяти)

То есть если мы сделаем например такой код:

package main


func main() {
	
}

А потом прокатим его через

go build -o app main.go
go tool nm app

То увидим нечто в духе

1400233e0 T runtime.gcAssistAlloc.func2
140023480 T runtime.gcAssistAlloc1
140020880 T runtime.gcBeginWork
140020120 T runtime.gcBgMarkStartWorkers
140020280 T runtime.gcBgMarkStartWorkers.gowrap1
1400202c0 T runtime.gcBgMarkWorker
14006cb20 T runtime.gcBgMarkWorker.func1
140020620 T runtime.gcBgMarkWorker.func2
14016818c D runtime.gcBgMarkWorkerCount
140168300 D runtime.gcBgMarkWorkerPool
140168480 D runtime.gcBitsArenas
14016814c D runtime.gcBlackenEnabled
1401685c0 D runtime.gcCPULimiter
140123100 D runtime.gcCleanups

Значения тут примерно такие:

  • 1400233e0 — адрес символа в бинарнике(то есть либо адрес переменной, либо начало блока функции);

  • T/D — исполняемый код(text)/глобальная переменная;

  • runtime.gcAssistAlloc.func2 — название функции/переменной.

Конкретный список зависит от версии Go, архитектуры, режима линковки и оптимизаций, поэтому не могу обещать полностью одинаковый вывод.

Мы не импортировали большую часть этих функций! Их добавил toolchain, потому что без них программа не сможет выполняться по правилам Go.

И да, стандартный Go toolchain действительно включает runtime library в каждое приложение.

Неоднократно слышал, что runtime — это отдельный тред. Так вот, это неправда! Go runtime — это встроенная подсистема выполнения Go‑кода. Инструментарий.

Сначала была функция и функция была main

Это разве что для нас, как для пользователей языка. На самом деле все начинается с инициализации runtime, иначе как можно выполнять без среды выполнения?

Когда мы пишем Go‑программу, её точкой входа для нас является функция:

func main() { 
  // Стартап, который изменит мир 
}

Передает ли операционная система после запуска бинарника сразу передаёт управление в main.main? Нет!

Но операционка же умеет только создавать треды, перекладывать байтики, работать с адресным пространством.

И откуда тут взяться всем нашим GC и планировщикам? А всё просто:

Для обычного исполняемого файла под amd64 при внутренней линковке такой точкой входа является _rt0 amd64(сори, что без подчеркивания после rt0, тут маркдаун).

Посмотрим на исходный код runtime:

TEXT _rt0_amd64(SB),NOSPLIT,$-8 
  MOVQ 0(SP), DI; Из начального стека процесса извлекается количество аргументов командной строки - argc
  LEAQ 8(SP), SI; В регистр SI помещается адрес массива аргументов - argv.
  JMP runtime·rt0_go(SB) ; передаем управление Go

Да, кстати, в том числе по этой причине мы не пишем в Go int argc, byte **argv, как мы это делаем например в Си

Но что делает runtime.rt0_go?

Мы это разберем отдельно, а на данный момент остановимся на том, что он:

  • Создаёт начальные структуры g0 и m0;

  • Настраивает доступ к данным текущего системного потока;

  • Получает аргументы программы;

  • Выполняет платформенную инициализацию;

  • Инициализирует планировщик;

  • Создаёт первую обычную goroutine;

  • Запускает выполнение системного потока.

А если хочется посмотреть исходники, то можно поискать вот такой фрагмент:

CALL runtime·args(SB) 
CALL runtime·osinit(SB) 
CALL runtime·schedinit(SB) 
MOVQ $runtime·mainPC(SB), AX 
CALL runtime·newproc(SB) 
CALL runtime·mstart(SB)

Как видите, runtime.mainPC содержит ссылку на функцию runtime.main. Она передаётся в runtime.newproc, которая создаёт новую goroutine и помещает её в очередь готовых к выполнению gorутин. После этого runtime.mstart запускает начальный системный поток runtime.

runtime.mainпродолжает инициализацию среды выполнения:

  • Устанавливает ограничения стеков;

  • Разрешает создание дополнительных системных потоков;

  • Запускает системный монитор sysmon;

  • Выполняет функции инициализации самого runtime;

  • Включает сборщик мусора;

  • Выполняет init всех пакетов программы;

  • Вызывает пользовательскую main.main.

Что внутри runtime

Итак, мы уже поняли, что runtime — это не одна функция и не один фоновый процесс.

Это набор связанных между собой подсистем, каждая из которых отвечает за определённую часть выполнения Go‑программы.

В целом runtime традиционно делят на такие сущности:

  1. Запуск и инициализация программы;

  2. Планировщик goroutine. Go Scheduler;

  3. Управление стеками;

  4. Управление памятью;

  5. GC, garbage collector, сборщик мусора;

  6. Netpoller;

  7. Таймеры;

  8. Системные вызовы и сигналы;

  9. panic и defer;

  10. Диагностика.

Но если что, это не строго независимые модули! В исходниках Go это представляет из себя страшное спагетти, но чисто логически часто разделяют примерно так.

Центральной сущностью здесь является планировщик. Собственно именно он и выдает права настоящим тредам ОС выполнять Go код(советую мыслить именно в этой парадигме). Как я ранее сказал, никакого отдельного треда для runtime нет. Есть только переменные и функции, которые наш дорогой CPU выполняет. Если рассматривать runtime под таким углом, то вырисовывается следующая схема выполнения:

  1. Реальный тред через планировщик узнает, может ли он выполнить код(то есть смотрит необходимые переменные)

  2. Получает готовую горутину

  3. Переключается на её стек и выполняет

Опять же функции планировщика могут исполнять разные потоки и дать права на выполнение другому треду. Получаем очередное спагетти

Планировщик управляет системными потоками, но функции самого планировщика выполняются этими же системными потоками.

Senior Gopher
Senior Gopher

В следующих статьях будем уже разбираться как внутри работает каждый компонент Go Runtime, так что ждем‑с, не теряемся

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вам статья?
33.33%Отлично4
33.33%Хорошо4
8.33%Нормально1
25%Плохо3
0%Ужасно0
Проголосовали 12 пользователей. Воздержались 2 пользователя.