В предыдущих частях мы разобрались с тем, во что превращается 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 традиционно делят на такие сущности:
Запуск и инициализация программы;
Планировщик goroutine. Go Scheduler;
Управление стеками;
Управление памятью;
GC, garbage collector, сборщик мусора;
Netpoller;
Таймеры;
Системные вызовы и сигналы;
panic и defer;
Диагностика.

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

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