Go Рантайм Go изнутри: планировщик, стеки, escape-анализ и сборщик мусора
0%

Рантайм Go изнутри: планировщик, стеки, escape-анализ и сборщик мусора

Рантайм Go изнутри

Go компилируется в нативный код, но это не «C с горутинами». В каждый бинарник линкуется рантайм — несколько мегабайт кода, который живёт рядом с вашей программой и делает три вещи: раскладывает горутины по потокам ОС, выделяет память и собирает мусор. Вы его не вызываете напрямую, но именно он определяет, почему сервис ест три гигабайта вместо трёхсот мегабайт, почему p99 внезапно скачет до секунды и почему GOMAXPROCS равен 64 в контейнере с лимитом в два ядра.

Эта глава — про механику. Не ради эрудиции: почти каждый нетривиальный прод-инцидент в Go-сервисе упирается в одну из трёх подсистем ниже, и без модели происходящего вы будете лечить симптомы.

Что вообще входит в рантайм

Сравнение с JVM помогает уложить это в голове. У JVM есть виртуальная машина, байткод и JIT, который компилирует горячие места в машинный код на лету. У Go ничего этого нет: компиляция полностью опережающая (AOT), машинный код готов до запуска. Но библиотека рантайма всё равно нужна, потому что язык обещает вещи, которые не выражаются одними инструкциями процессора:

  • Планировщик — мультиплексирует горутины на потоки ОС.
  • Аллокатор памяти — раздаёт куски кучи без похода в ядро на каждый объект.
  • Сборщик мусора — освобождает то, на что больше нет ссылок.
  • Сетевой поллер (netpoller) — превращает «блокирующие» сетевые вызовы в неблокирующие.
  • Плюс мелочь: рост стеков, паника и восстановление, карты, каналы, select, интерфейсные проверки типов.

Всё это лежит в пакете runtime стандартной библиотеки — исходники читаемы и хорошо прокомментированы, это лучший источник правды. Отсюда же берётся «налог» на минимальный бинарник: пустая программа на Go весит около 1,5 МБ, потому что тащит GC и планировщик.

Планировщик G-P-M

Зачем понадобилась третья буква

Есть три классических способа исполнять конкурентные задачи:

  • 1:1 — каждая задача = поток ОС. Просто, но поток стоит мегабайты стека и сотни наносекунд на переключение контекста через ядро. Миллион соединений так не обслужить.
  • N:1 — все задачи на одном потоке (классический event loop). Дёшево, но не использует несколько ядер, и один блокирующий вызов вешает всё.
  • M:N — множество задач на меньшем множестве потоков. Даёт и дешевизну, и параллелизм, но требует собственного планировщика.

Go выбрал M:N. Сущностей три:

  • G (goroutine) — сама горутина: её стек, счётчик команд, состояние. Это структура в памяти, ничего больше.
  • M (machine) — поток ОС. Именно он реально исполняет код на ядре процессора.
  • P (processor) — логический процессор: право исполнять Go-код плюс контекст планировщика (локальная очередь готовых горутин, кеш аллокатора). Количество P задаётся GOMAXPROCS и по умолчанию равно числу ядер.

Ключевая идея: чтобы исполнять Go-код, поток M обязан держать P. P — это лицензия. Их ровно GOMAXPROCS штук, поэтому одновременно Go-код выполняют максимум GOMAXPROCS потоков, сколько бы M ни было создано.

Как P выбирает следующую горутину

Порядок примерно такой:

  1. Слот runnext — одна «горячая» горутина, которую только что разбудили. Оптимизация под пинг-понг двух горутин через канал: они не уходят в конец очереди.
  2. Локальная очередь этого P — кольцевой буфер на 256 элементов, доступ без блокировок, потому что очередь принадлежит одному P.
  3. Глобальная очередь — общая, под замком. Чтобы горутины оттуда не голодали, P заглядывает в неё принудительно раз в 61 планирование.
  4. Netpoller — забрать горутины, чьё сетевое ожидание завершилось.
  5. Work stealing — украсть половину локальной очереди у случайного другого P.

Такая многоуровневость — компромисс между дешевизной (локальные очереди без блокировок) и справедливостью (глобальная очередь и воровство не дают одному P простаивать, пока другой завален).

Когда горутина теряет процессор

Это второй по частоте вопрос после «сколько стоит горутина». Точки переключения:

  • Операция с каналом, которая заблокировалась.
  • sync.Mutex, который занят.
  • Сетевой ввод-вывод (уход в netpoller).
  • time.Sleep.
  • Системный вызов.
  • Вызов функции — компилятор вставляет в пролог проверку «не пора ли уступить».
  • Асинхронное вытеснение (с Go 1.14): sysmon посылает потоку сигнал SIGURG, и горутина снимается даже посреди цикла без вызовов функций.

До 1.14 последний пункт отсутствовал, и цикл вида for {} без вызовов мог намертво занять P — классическая ловушка старых версий. Сейчас планировщик действительно вытесняющий, хотя большинство переключений всё равно кооперативные и потому дешёвые: это не переключение контекста ядра, а сохранение трёх регистров, порядка десятков наносекунд.

Системные вызовы и netpoller

Здесь прячется главный фокус Go. Когда горутина делает блокирующий системный вызов (запись в файл, getaddrinfo через cgo), поток M застревает в ядре. Рантайм это замечает: sysmon — фоновый поток без P — отбирает P у застрявшего M примерно через 20 микросекунд и отдаёт другому потоку. Итог: одна медленная операция с диском не останавливает исполнение остальных горутин, но временно рождает лишние потоки ОС.

С сетью всё иначе. Ваш код пишет привычное блокирующее:

n, err := conn.Read(buf) // выглядит как блокирующий вызов

А рантайм под капотом ставит сокет в неблокирующий режим, регистрирует его в epoll (Linux), kqueue (BSD/macOS) или IOCP (Windows) и паркует горутину, освобождая P мгновенно. Когда ядро сообщает о готовности данных, netpoller возвращает горутину в очередь готовых. Так один поток обслуживает десятки тысяч соединений, а вы пишете простой последовательный код — это и есть ответ на вопрос, почему Go «держит сотни тысяч соединений» без асинхронного синтаксиса вроде async/await. Устройство epoll и цену системных вызовов разбирает глава про ввод-вывод и системные вызовы.

Жизненный цикл горутины

Состояние Dead не означает освобождение памяти: рантайм кеширует структуры G и переиспользует их. Именно поэтому запуск горутины стоит порядка сотен наносекунд, а не микросекунд.

GOMAXPROCS и контейнеры — самая дорогая ошибка

GOMAXPROCS по умолчанию равен числу логических ядер машины. В контейнере с ограничением CPU это почти всегда неправильно: Kubernetes ограничивает вас не числом ядер, а квотой CFS (например, «200 мс процессорного времени за 100 мс периода» = 2 ядра), а Go видит все 64 ядра узла и создаёт 64 P.

Последствия: планировщик раскладывает работу на 64 «процессора», ядро постоянно тормозит их квотой, растут переключения контекста и хвостовые задержки, GC запускает 64 воркера там, где ему выделено два ядра. Симптом — необъяснимо высокий p99 при низкой средней нагрузке.

Лечение — одно из двух:

// Вариант 1: библиотека, читающая cgroup-лимит и выставляющая GOMAXPROCS
import _ "go.uber.org/automaxprocs"

// Вариант 2: явно из манифеста деплоя
// env: GOMAXPROCS = ceil(cpu limit)

Начиная с Go 1.25 рантайм умеет учитывать cgroup-лимит самостоятельно, но пока в проде живут более старые версии, automaxprocs остаётся стандартом де-факто. Проверить текущее значение просто: runtime.GOMAXPROCS(0).

Как посмотреть на планировщик глазами

# Сводка планировщика раз в секунду: сколько потоков, горутин, что в очередях
GODEBUG=schedtrace=1000 ./server
# SCHED 1000ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 idlethreads=4 runqueue=0 [0 1 0 0 0 0 0 2]

# Полная трассировка исполнения: где горутины ждали, когда работал GC, syscalls
curl -o trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out

go tool trace — недооценённый инструмент. В отличие от pprof, который отвечает «где горит CPU», трассировщик отвечает «почему ничего не происходит»: видно простаивающие P, длинные системные вызовы, ожидания на каналах и паузы GC на общей временной шкале. Профилирование как метод разбирает глава про CPU-профилирование, а подключение pprof к сервису — глава про наблюдаемость.

Стеки горутин: почему их можно миллион

Поток ОС резервирует под стек 1–8 МБ виртуальной памяти. Миллион потоков — терабайты адресного пространства, не считая структур ядра. Горутина стартует со стеком в 2 КБ.

Фокус в том, что стек растёт. Компилятор вставляет в пролог каждой функции проверку: хватает ли места до конца стека. Если нет, вызывается morestack: рантайм выделяет вдвое больший кусок, копирует туда старый стек и правит все указатели, которые на него ссылались.

Отсюда два важных следствия:

  1. Адрес локальной переменной может измениться. Именно поэтому в Go нет арифметики указателей: рантайм должен уметь честно переписать все ссылки при копировании стека. Безопасность памяти здесь не идеология, а необходимость реализации.
  2. Рост стека не бесплатен. Функция, вызываемая в цикле у самой границы стека, может вызывать постоянные рост и сжатие — редкая, но реальная патология («stack thrashing»).

Максимальный размер стека горутины — 1 ГБ на 64-битных платформах (меняется через debug.SetMaxStack). При переполнении программа умирает с fatal error: stack overflow — это не паника, её нельзя перехватить recover.

Практический вывод: горутина стоит примерно 2 КБ памяти плюс сотни наносекунд на запуск. Миллион горутин — это два гигабайта только под стеки, так что «горутины бесплатны» — миф. Они дёшевы, а это не одно и то же.

Escape-анализ: стек или куча

Компилятор Go сам решает, где разместить значение. Правило одно: если компилятор может доказать, что значение не переживёт кадр стека, оно размещается на стеке. Стек освобождается автоматически при возврате из функции — ноль работы для GC. Если доказать не удалось, значение «убегает» (escapes) в кучу, и его судьба становится заботой сборщика мусора.

Смотреть решения компилятора можно прямо:

go build -gcflags='-m' ./...
# ./main.go:14:13: make([]byte, 512) does not escape
# ./main.go:23:21: &User{...} escapes to heap
# ./main.go:23:21:   ... because: returned by function

go build -gcflags='-m -m' ./...   # с объяснением цепочки причин

Типичные причины побега в кучу:

// 1. Возврат указателя на локальное значение — переживает кадр
func newUser(name string) *User {
	u := User{Name: name}
	return &u          // escapes to heap
}

// 2. Присвоение в интерфейс — размер и тип неизвестны статически
var w io.Writer = &buf // &buf escapes
fmt.Println(x)         // x escapes: аргумент типа any

// 3. Захват замыканием, которое переживает функцию
go func() { use(data) }()  // data escapes

// 4. Размер неизвестен на этапе компиляции
n := userInput()
b := make([]byte, n)       // escapes: компилятор не знает n

// 5. Слишком большой объект
big := make([]int, 100000) // неявные аллокации крупнее 64 КБ уходят в кучу

Из этого списка растут практические приёмы:

  • Предвыделяйте срезы, когда знаете размер: make([]T, 0, n) вместо роста через append избавляет от серии перевыделений и копирований.
  • strings.Builder вместо конкатенации в цикле. s += x в цикле — это N аллокаций и N копирований, классический O(n²) по памяти.
  • Передавайте небольшие структуры по значению. Указатель на маленькую структуру часто хуже: он тащит её в кучу, тогда как копия из четырёх полей остаётся на стеке.
  • Осторожнее с fmt в горячем пути. Любой аргумент fmt.Sprintf попадает в any и уезжает в кучу; вдобавок форматирование идёт через рефлексию.
  • sync.Pool для крупных переиспользуемых буферов (подробнее ниже).

И сразу оговорка, без которой этот раздел вреден: не оптимизируйте вслепую. Escape-анализ — инструмент для горячих путей, найденных профилировщиком, а не повод переписывать весь код. Методику «сначала измерь» разбирает глава про рабочий процесс оптимизации, а честный замер эффекта — глава про бенчмаркинг и флаг -benchmem из главы про тестирование.

Аллокатор: куда попадает то, что убежало

Куча Go устроена по мотивам TCMalloc от Google, и её главная задача — раздавать память без блокировок в типичном случае.

Путь одной аллокации в Go: escape-анализ, mcache, mcentral, mheap

Как это работает:

  • Память нарезана на спаны — куски по 8 КБ и больше, каждый отдан под объекты одного класса размеров. Классов около 68: 8, 16, 24, 32, 48, 64 байта и так далее до 32 КБ. Запрос на 20 байт округляется до класса 24 — отсюда внутренняя фрагментация в единицы процентов, зато выделение сводится к «снять голову списка».
  • mcache — кеш спанов, привязанный к P. Раз P один на поток, конкуренции нет: аллокация мелкого объекта — это несколько инструкций без единого атомарного обращения.
  • mcentral — общий на класс размеров склад спанов, куда mcache идёт, когда свой спан кончился. Уже под блокировкой, но заходят туда редко.
  • mheap — куча целиком, просит страницы у ОС через mmap. Объекты крупнее 32 КБ выделяются здесь напрямую, минуя кеши.
  • Tiny-аллокатор — объекты меньше 16 байт без указателей (например, маленькие []byte или int64) упаковываются по нескольку штук в один блок. Это заметно снижает накладные расходы на мелочи.

Практический вывод: аллокация в Go дешёвая (десятки наносекунд), но не бесплатная, и главная её цена — не сама выдача памяти, а последующая работа GC. Именно поэтому в бенчмарках смотрят на allocs/op не меньше, чем на ns/op.

Сборщик мусора

Что он гарантирует и чем платит

GC в Go — конкурентный трёхцветный mark-and-sweep, неперемещающий. Разберём по словам:

  • Конкурентный — большую часть работы делает параллельно с вашим кодом, а не останавливая его.
  • Трёхцветный — объекты помечаются белым (не обнаружен), серым (обнаружен, потомки не обойдены), чёрным (обнаружен и обойден). В конце всё белое считается мусором. Теорию алгоритма разбирает глава про сборку мусора.
  • Неперемещающий — объекты не двигаются в памяти. Плюс: адреса стабильны, cgo и unsafe работают предсказуемо. Минус: куча не уплотняется, и фрагментация возможна.

Явно названный дизайнерами приоритет — короткие паузы, а не максимальная пропускная способность. GC жертвует процентами CPU (обычно 5–25%) ради того, чтобы остановки мира измерялись десятками микросекунд.

Две тонкости, которые видно на диаграмме:

  • Барьер записи (write barrier) — код, вставляемый компилятором в каждое присваивание указателя во время маркировки. Он не даёт вашему коду «спрятать» живой объект от сборщика. Барьер включён только на фазе маркировки, но именно поэтому у записи указателей есть небольшая цена.
  • Mark assist — если горутина аллоцирует быстрее, чем GC успевает метить, рантайм заставляет её саму немного поработать сборщиком. Прикладной симптом: под всплеском аллокаций латентность растёт «непонятно откуда» — это ассист.

GOGC и GOMEMLIMIT

Единственный числовой рычаг классического GC — переменная GOGC (по умолчанию 100). Формула цели:

следующий запуск GC ≈ живая куча × (1 + GOGC / 100)

При GOGC=100 и 400 МБ живых данных сборка запустится около 800 МБ. Отсюда — самая частая производственная авария:

Контейнер с лимитом 1 ГБ. Живых данных 600 МБ. Цель GC — 1,2 ГБ. Процесс упирается в лимит cgroup и получает OOMKilled раньше, чем сборщик решит, что пора работать. В логах — ничего, под просто перезапускается.

С Go 1.19 у проблемы есть штатное решение — GOMEMLIMIT, мягкий лимит на общий объём памяти рантайма. При приближении к нему GC начинает работать чаще, независимо от GOGC.

# Типичная прод-конфигурация для контейнера с лимитом 1 ГБ:
# оставляем запас на стеки, метаданные рантайма и всплески
GOMEMLIMIT=800MiB ./server

# Агрессивный вариант: не собирать по проценту роста, только по лимиту памяти.
# Даёт минимум работы GC при известном потолке. Опасен, если потолок неверен.
GOGC=off GOMEMLIMIT=800MiB ./server

GOMEMLIMIT — именно мягкий лимит: если живых данных реально больше, GC не сможет их удалить и будет крутиться вхолостую, съедая CPU («GC death spiral»). Поэтому лимит ставят с запасом, а не впритык.

Наблюдать за сборщиком можно без всяких инструментов:

GODEBUG=gctrace=1 ./server
# gc 12 @4.212s 1%: 0.031+3.4+0.008 ms clock, 0.25+0.51/3.1/0.9+0.06 ms cpu, 82->84->41 MB, 87 MB goal, 8 P
#    │      │    │   └── три числа STW/конкурентно/STW              └── было->пик->живое, цель кучи
#    │      │    └── доля CPU, потраченная на GC с начала работы
#    │      └── время от старта программы
#    └── номер цикла сборки

Три числа «82->84->41 MB» — самое полезное: сколько было в начале цикла, пик и сколько осталось живым. Если третье число растёт от цикла к циклу — у вас утечка, и дальше идти надо с дифференциальным профилем кучи (методика — в главе про память).

sync.Pool: когда оправдан

sync.Pool — пул переиспользуемых объектов, снимающий давление на GC:

var bufPool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

func handle(w http.ResponseWriter, r *http.Request) {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset()                 // ОБЯЗАТЕЛЬНО: из пула приходит грязный объект
	defer bufPool.Put(buf)
	// ... используем buf
}

Важные оговорки:

  • Пул очищается при каждой сборке мусора. Это кеш, а не хранилище: не держите в нём ничего, что нельзя потерять.
  • Он оправдан для крупных, часто создаваемых, одинаковых по размеру объектов: буферов, парсеров, срезов под сериализацию. Для мелких структур выигрыш обычно теряется на фоне синхронизации.
  • Складывая в пул срез, который успел вырасти до мегабайта, вы фиксируете этот мегабайт в памяти. В net/http из-за этого исторически ограничивают размер возвращаемых буферов.

Первая мысль при виде sync.Pool в чужом коде должна быть не «оптимизация», а «покажите бенчмарк, который это оправдывает».

Модель памяти Go: happens-before без магии

-race из главы про конкурентность ловит гонки, но правила стоит знать и без детектора. Модель памяти Go формулирует, при каких условиях запись, сделанная одной горутиной, гарантированно видна другой. Гарантии дают:

  • Запуск горутины: всё, что произошло до go f(), видно внутри f.
  • Канал: отправка в канал происходит-раньше завершения соответствующего приёма. Для небуферизованного канала верно и обратное — приём происходит-раньше завершения отправки.
  • Закрытие канала происходит-раньше приёма нулевого значения из закрытого канала.
  • Мьютекс: n-й вызов Unlock происходит-раньше (n+1)-го Lock.
  • sync.Once: тело Do завершается раньше возврата из любого другого Do.
  • sync/atomic: атомарные операции над одной переменной последовательно согласованы.

Чего гарантий нет: у обычных чтений и записей без синхронизации. Никакие «volatile-подобные» трюки, сон на 10 миллисекунд или «я же только читаю» не работают — компилятор и процессор вправе переупорядочить обращения. Практическое правило: если к переменной обращаются две горутины и хотя бы одна пишет — нужен мьютекс, атомик или канал. Третьего не дано.

Диагностика: что смотреть в проде

  • runtime/metrics — современный типизированный источник метрик рантайма (паузы GC как гистограмма, размеры кучи, число горутин). Предпочтителен старому runtime.ReadMemStats, который делает короткий STW.
  • /debug/pprof/heap — профиль кучи. Помните разницу: inuse_space — что живо сейчас (для утечек), alloc_space — что аллоцировалось за всё время (для давления на GC).
  • /debug/pprof/goroutine?debug=2 — стеки всех горутин; так ловят утечки горутин.
  • go tool trace — временная шкала: паузы GC, ассисты, простой P, ожидания на каналах.
  • GODEBUG=gctrace=1 и GODEBUG=schedtrace=1000 — включаются без пересборки, дают картину «на живую».

Мифы и типичные ошибки

  • «GC можно отключить». GOGC=off не отключает сборку, а убирает порог по росту: память будет расти, пока не упрётся в GOMEMLIMIT или в лимит контейнера.
  • «Указатель — всегда куча». Нет: escape-анализ часто оставляет &x на стеке, если указатель не выходит за пределы функции.
  • «Горутины бесплатны». 2 КБ стека каждая, плюс работа планировщика. Миллион горутин — реальные гигабайты.
  • GOMAXPROCS по числу ядер узла в контейнере — причина скачков хвостовой латентности.
  • Срез, удерживающий огромный массив. small := big[:10] не освобождает big: срез ссылается на весь нижележащий массив. Лечится slices.Clone(big[:10]). Механику срезов разбирает глава про основы.
  • Незакрытый time.Ticker. Ticker держит горутину и память до Stop(). time.After в цикле select тоже копит таймеры до срабатывания — в горячих циклах используйте переиспользуемый Timer.
  • Оптимизация без профиля. Самая дорогая ошибка из списка: время инженера тратится там, где нет узкого места.

Мини-итог

  • Рантайм линкуется в бинарник и отвечает за планировщик, память, GC и netpoller.
  • G-P-M: P — это лицензия на исполнение Go-кода, их GOMAXPROCS штук; локальные очереди без блокировок плюс work stealing.
  • Сеть не блокирует потоки: горутина паркуется в netpoller и возвращается по готовности.
  • Стек горутины стартует с 2 КБ и копируется при росте — отсюда запрет на арифметику указателей.
  • Escape-анализ решает, стек или куча; смотрите -gcflags=-m, но только после профиля.
  • GC конкурентный, неперемещающий, с короткими STW; управляется GOGC и GOMEMLIMIT.
  • В контейнерах обязательны GOMEMLIMIT и корректный GOMAXPROCS.

Источники

  • A Guide to the Go Garbage Collector — официальное, лучшее и с интерактивными графиками.
  • The Go Memory Model — короткая спецификация гарантий видимости.
  • Серия Scheduling in Go Билла Кеннеди (Ardan Labs) — самый подробный разбор планировщика.
  • Getting to Go: The Journey of Go’s Garbage Collector — Рик Хадсон об эволюции GC и о том, почему выбрали короткие паузы.
  • go.uber.org/automaxprocs — GOMAXPROCS по cgroup-лимиту.
  • Исходники runtime: файлы proc.go, malloc.go, mgc.go — комментарии в них лучше большинства статей.

Что дальше

Мы разобрали, как рантайм исполняет ваш код. Теперь вернёмся к самому языку: посмотрим на его самую свежую и самую спорную часть — дженерики и итераторы, появившиеся в версиях 1.18–1.23.

10. Дженерики и итераторы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов