В предыдущих частях мы разобрались с тем, во что превращается 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, так что ждем-с, не теряемся

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