open atlas
↑ К треку
Go с нуля до senior GO · 02 · 01

Горутины и планировщик: дешёвые стеки, GMP и утечки, переживающие запрос

Горутина стартует с ~2 КБ стека и растёт; планировщик GMP мультиплексирует их на потоки ОС с work stealing и, с 1.14, вытеснением по сигналу. GOMAXPROCS ограничивает параллелизм. Классический отказ — утечка горутин, навсегда заблокированных на канале; ищется через pprof.

GO Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

API-шлюз шесть недель был «в порядке», а потом память поползла вверх на 40 МБ в час при ровном трафике. OOM ещё не случился — просто наклонная. Кто-то наконец открыл goroutine-профиль pprof, и число в первой строке гласило: 1 184 202. Миллион горутин, и каждая припаркована на одной и той же строке — приём из канала внутри fan-out-хелпера. Хендлер, который их порождал, имел таймаут 2 секунды: он возвращался, ответ уходил клиенту, а горутина оставалась ждать канал, в который никто никогда не напишет. Каждая утёкшая горутина держала ~4 КБ стека плюс захваченные буферы запроса. Исправление заняло три строки прокидывания контекста; урок же в том, что горутины настолько дёшево запускать, что ничто не заставляет думать о том, как они останавливаются, — а рантайм с радостью будет держать миллион зомби живыми ради вас.

Почему горутина стоит ~2 КБ, а поток — мегабайты

Поток ОС дорог по двум статьям: его стек резервируется заранее (8 МБ виртуальной памяти по умолчанию на Linux — проверьте ulimit -s), а каждая блокировка/пробуждение — это переключение контекста в ядре, примерно 1–2 мкс плюс загрязнение кэшей. Горутина стартует со стеком ~2 КБ, который рантайм растит и сжимает по требованию: пролог каждой функции проверяет запас, и при переполнении рантайм выделяет сегмент побольше и копирует стек целиком (непрерывные стеки, с Go 1.4 — указатели внутрь стека переписываются при копировании). Переключение между горутинами — обмен регистров в пользовательском пространстве, порядка десятков наносекунд, без ядра. Этот разрыв в три порядка — вся суть: 10 000 потоков — инцидент, 10 000 горутин — обычный вторник. Честная цена: горутины невидимы для ОС, поэтому системные инструменты (top, счётчики потоков) ничего не скажут о их количестве — это знает только рантайм.

GMP: как планировщик на самом деле размещает работу

Планировщик рантайма жонглирует тремя сущностями. G — горутина: стек плюс сохранённые регистры. M — поток ОС, которым владеет рантайм. Pпроцессор: токен планирования с локальной очередью G; их ровно GOMAXPROCS (по умолчанию — число ядер CPU, а с Go 1.25 значение по умолчанию учитывает и cgroup-лимиты CPU в контейнерах — до этого под с квотой 2 CPU на 64-ядерной ноде получал GOMAXPROCS=64 и сам себя троттлил; классическим фиксом был automaxprocs от Uber). Чтобы исполнять Go-код, M обязан держать P. Хореография такая:

  • Новая G попадает в локальную очередь текущего P (256 слотов); переполнение сливается в глобальную очередь.
  • Простаивающий P с пустой очередью крадёт половину очереди случайного другого P — work stealing держит ядра занятыми без центрального лока.
  • Когда G блокируется в сисколле, её M застрял в ядре — и рантайм передаёт P другому M (припаркованному или новому), а остальные G продолжают работать. Поэтому одно медленное чтение с диска не останавливает остальные 9 999 горутин.
  • Блокировка на канале или мьютексе ещё дешевле: G паркуется внутри рантайма (ни один поток не блокируется вовсе), а M берёт следующую G из очереди.
// Наблюдаем за механикой: горутина на задачу, ограничена ядрами GOMAXPROCS.
func main() {
	fmt.Println("GOMAXPROCS =", runtime.GOMAXPROCS(0)) // 0 = читать, не устанавливать

	var wg sync.WaitGroup
	for i := 0; i < 100_000; i++ { // 100k горутин: ~200 МБ стеков — нормально
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			work(id) // рантайм планирует их по GOMAXPROCS P
		}(i)
	}
	wg.Wait()
	fmt.Println("still alive:", runtime.NumGoroutine()) // должно быть ~1
}
Почему это работает

Зачем вообще слой P — почему не планировать G прямо на M? Потому что M приходят и уходят (сисколлы их паркуют, рантайм порождает новые), и всё закэшированное на уровне потока терялось бы или становилось предметом конкуренции. P — стабильный дом для состояния планировщика: локальная очередь, кэш аллокатора (mcache), пул defer. M, вернувшийся из сисколла, обязан заново получить P, прежде чем исполнять Go-код; если все P заняты, M паркуется, а его G уходит в глобальную очередь. Фиксированное число P, равное GOMAXPROCS, ограничивает истинный параллелизм, позволяя числу M плавать в зависимости от того, сколько горутин застряло в ядре.

Вытеснение: кооперативное до 1.14, потом сигналы

До Go 1.14 планировщик был кооперативным: горутина уступала только в точках вызова функций (где живёт проверка стека). Плотный цикл — for { x++ } — не уступал никогда: он мог навечно занять P, заморить stop-the-world фазу сборщика мусора и подвесить программу. С Go 1.14 наблюдающий поток рантайма (sysmon) замечает G, работающую дольше ~10 мс, и шлёт её M сигнал (SIGURG на Unix); обработчик прерывает горутину на ближайшей безопасной инструкции. Циклы без вызовов больше не могут заклинить рантайм. Оставшаяся острая грань: вытеснение всё равно не мгновенно и не бесплатно — CPU-bound горутина получает кванты ~10 мс, поэтому 8 горячих циклов при GOMAXPROCS=8 всё равно заставят каждую чувствительную к задержке горутину ждать своей очереди. Тяжёлой по CPU работе место в ограниченном пуле, а не в безлимитных go.

Викторина

Горутина вызывает read() на медленном NFS и блокируется в ядре на 4 секунды. Что происходит с другими горутинами, стоявшими в очереди того же P?

Режим отказа: утечки горутин и pprof как перепись населения

Утечка горутины — это горутина, которая никогда не возобновится: заблокирована на канале, в который никто не отправит, на приёме, который никто не закроет, на ctx.Done(), который она не слушает, или на мьютексе, который держит уже завершившаяся горутина. Сборщик мусора не может её собрать — припаркованная горутина является GC-корнем (объектом, от которого сборщик мусора начинает обход и который никогда не освобождает), поэтому весь её стек и всё, на что он ссылается, остаётся живым. Утечки накапливаются: одна на неудачный запрос, умноженная на миллион запросов, — это наклонная из вступления. Обнаружение — goroutine-профиль:

import _ "net/http/pprof" // открывает /debug/pprof/ на дефолтном mux

// затем: go tool pprof http://localhost:6060/debug/pprof/goroutine
// или:   curl localhost:6060/debug/pprof/goroutine?debug=1
// Вывод группирует горутины по одинаковому стеку — утечка видна как
// один стек с огромным счётчиком, например:
//   1184202 @ 0x43a1c5 ... chan receive
//     github.com/acme/gw/internal/fanout.collect+0x9e

Читайте его как гистограмму: тысячи горутин, припаркованных на одной и той же строке, означают, что тот, кто их порождает, не позаботился об их выходе. Лекарство структурное, а не косметическое — каждый go должен отвечать на вопрос «как эта горутина завершится?»: контекст, на который она делает select, канал, который закроют, WaitGroup, которую кто-то ждёт. Выведите runtime.NumGoroutine() на дашборд; счётчик, который только растёт, — утечка в процессе. Честные числа: припаркованная горутина стоит ~2–4 КБ стека плюс всё, что захватило её замыкание (часто это и есть настоящая цена — захваченный буфер в 64 КБ превращает миллион утечек в 64 ГБ).

Викторина

Хендлер порождает горутину, которая шлёт результат в небуферизованный канал; хендлер принимает с таймаутом 2 с через select. Таймаут срабатывает, хендлер возвращается. Что происходит с горутиной-воркером?

Вспомните перед уходом
  1. 01
    Разберите G, M и P: что есть что, зачем нужен P и что происходит при блокирующем сисколле против ожидания на канале.
  2. 02
    Что такое утечка горутин, почему GC не может её собрать и как найти её в проде?
Итог

Горутины дёшевы, потому что ими владеет рантайм: непрерывный стек ~2 КБ, растущий копированием (с переписыванием указателей), переключения в пользовательском пространстве за десятки наносекунд против 1–2 мкс контекстных переключений ядра и сэкономленные мегабайты зарезервированного стека потока. Планировщик GMP отображает их на железо: G — горутина, M — поток ОС, P — один из GOMAXPROCS слотов планирования с локальной очередью на 256 записей и переливом в глобальную. Простаивающие P крадут половину чужой очереди; блокирующий сисколл пригвождает M в ядре, и рантайм отцепляет P и передаёт его другому потоку, тогда как ожидания на каналах и мьютексах паркуют G, не блокируя ни одного потока. GOMAXPROCS по умолчанию равен числу ядер и с Go 1.25 учитывает cgroup-лимиты контейнера — до этого подам с маленькими квотами CPU был нужен automaxprocs, чтобы не троттлиться. Вытеснение было кооперативным до Go 1.14; с тех пор sysmon шлёт SIGURG горутинам, работающим дольше ~10 мс, так что горячие циклы больше не могут занять P или остановить GC, хотя CPU-bound работа всё равно заслуживает ограниченного пула. Фирменный отказ — утечка горутин: горутина, навечно припаркованная на канале или забытом контексте, является GC-корнем и держит свой стек и захваченные буферы живыми — миллион таких и есть наклонная памяти из вступления. Goroutine-профиль pprof группирует припаркованные горутины по стеку и обнажает утечку одним абсурдным счётчиком; runtime.NumGoroutine() на дашборде ловит её рано; а дисциплина, которая её предотвращает, — спрашивать у каждого go, как эта горутина закончится. Теперь, когда увидишь, что график памяти при ровном трафике ползёт вверх, — сразу открывай goroutine-профиль pprof: один стек с огромным счётчиком немедленно покажет, какой именно go забыл путь к выходу.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.