Golang · middle
Горутина — это не поток
Чем горутина отличается от потока ОС и как Go runtime планирует выполнение.
- АвторРедакция Вектора
- Чтение6 мин
- Опубликовано
- Обновлено
Короткий ответ: горутина — это задача, которой управляет планировщик Go, а поток — ресурс операционной системы. Runtime размещает много горутин на меньшем числе потоков ОС, переносит их между потоками и останавливает, когда они ждут канал, таймер или сеть. Поэтому go f() не означает "создать новый поток".
На собеседовании этого ответа обычно достаточно для старта. Дальше стоит объяснить модель G-M-P и показать, где удобная абстракция перестаёт быть бесплатной.
Что именно планирует Go
В исходниках runtime используются три сущности:
- G хранит состояние горутины: её стек, точку продолжения и статус;
- M соответствует потоку ОС;
- P содержит ресурсы, нужные M для выполнения пользовательского Go-кода, включая локальную очередь runnable-горутин.
Число P равно GOMAXPROCS. Чтобы исполнять Go-код, планировщик соединяет одну G, один M и один P. Когда горутина блокируется на канале, runtime может припарковать её и дать тому же M/P другую работу. Если M ушёл в блокирующий системный вызов, P можно передать другому M.
Именно здесь часто возникает путаница. GOMAXPROCS ограничивает число потоков, которые одновременно исполняют пользовательский Go-код. Он не задаёт ни количество горутин, ни жёсткий верхний предел для всех M: дополнительные потоки могут понадобиться, пока другие застряли в системных вызовах.
Схема в упрощённом виде выглядит так:
local run queue local run queue
| |
v v
G + P + M G + P + M G (waiting on network)
| | |
+------ OS scheduler / CPU cores ----------+

В реальном runtime есть глобальная очередь, stealing между P, таймеры и отдельный сетевой poller. Но начинать ответ с этих деталей не нужно. Сначала покажите, что понимаете разделение задачи, потока и права выполнять Go-код.
Почему горутин может быть много
Поток ОС обычно создаётся и переключается ядром. У него есть системный стек и связанные с ОС структуры. Горутина создаётся внутри процесса Go, а её пользовательский стек начинает с небольшого размера и может расти или уменьшаться. В текущем описании runtime указан стартовый размер порядка 2 КБ, но воспринимать это как навсегда закреплённую гарантию языка не стоит: это деталь реализации.
Небольшой растущий стек и пользовательский планировщик делают запуск горутины заметно дешевле запуска отдельного потока. "Дешевле" всё же не равно "бесплатно". Каждая горутина удерживает стек и служебное состояние. Она может также удерживать ссылки на крупные объекты, открытые соединения или место в очереди.
Вот код, который выглядит безобидно, но способен создать неограниченное число ожидающих горутин:
func consume(jobs <-chan Job) {
for job := range jobs {
go func(j Job) {
_ = callSlowDependency(j)
}(job)
}
}
Если зависимость замедлится, вход продолжит создавать работу быстрее, чем она завершается. Память растёт, а лимит у внешнего сервиса обходится огромным числом одновременных запросов.

Рабочая версия задаёт границу конкурентности:
func consume(ctx context.Context, jobs <-chan Job, workers int) error {
g, ctx := errgroup.WithContext(ctx)
for range workers {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
if err := callSlowDependency(job); err != nil {
return err
}
}
}
})
}
return g.Wait()
}
Здесь число одновременно выполняемых вызовов видно из кода. Контекст останавливает остальных воркеров после ошибки. Это прозаичнее, чем go на каждой итерации, зато систему можно нагрузочно тестировать и прогнозировать.
Что происходит при ожидании
Для разработчика важно различать блокировку горутины и блокировку потока.
При ожидании канала или совместимого с runtime сетевого ввода-вывода горутина паркуется. Она исчезает из очереди готовых к исполнению задач до события, после чего снова становится runnable. Поток в это время может выполнять другую G.
Блокирующий системный вызов устроен иначе: M может застрять внутри ядра. Runtime старается не оставлять P без работы и передаёт его другому M. Поэтому число потоков процесса иногда выше GOMAXPROCS. Cgo и некоторые низкоуровневые вызовы тоже делают картину сложнее.
Go умеет вытеснять горутины, которые долго не отдают управление сами. Это не повод писать бесконечные CPU-циклы без оглядки: лишняя конкуренция всё равно создаёт очереди, переключения и работу для сборщика мусора.
GOMAXPROCS в контейнере
Есть практическая деталь, из-за которой старые ответы на собеседовании устаревают. До Go 1.25 значение GOMAXPROCS по умолчанию ориентировалось на логические CPU хоста. Процесс в контейнере с лимитом в 2 CPU на машине со 128 CPU мог планировать гораздо больше параллельной работы, чем разрешал cgroup, и попадать под периодический throttling.
Runtime учитывает лимит контейнера по умолчанию, когда директива go в go.mod имеет версию 1.25 или новее и приложение само не задало GOMAXPROCS. Он также может обновлять значение при изменении лимита. CPU limit и GOMAXPROCS всё равно описывают разные вещи: первый ограничивает процессорное время за период, второй ограничивает параллельное исполнение Go-кода.
Для production-диагностики мало посмотреть на число горутин. Сопоставьте профиль горутин, GOMAXPROCS, CPU throttling контейнера, задержки зависимостей и размер очередей. Большое число G бывает нормальным у сервера с множеством ожидающих соединений. Быстро растущее число G вместе с растущей памятью и незавершающимися стеками уже похоже на утечку.
Разбор Вектора: какой ответ звучит по-мидловому
Слабый ответ заканчивается на фразе "горутины лёгкие". Хороший связывает механику с инженерным решением:
- Горутина является планируемой задачей runtime, а M является потоком ОС.
- P ограничивает параллельное выполнение через
GOMAXPROCS. - Ожидание может припарковать G, не занимая M, но системный вызов способен заблокировать M.
- Число горутин всё равно нужно ограничивать там, где есть конечный ресурс: база, внешний API, память или очередь.
Последний пункт отличает знание терминов от понимания эксплуатации. Если кандидат предлагает "запустить горутину на каждый запрос", полезно спросить, что произойдёт при деградации базы на 30 секунд. Ответ про backpressure, таймаут и ограниченный пул обычно ценнее пересказа всех очередей планировщика.
Частые ошибки на собеседовании
- Говорить, что одна горутина всегда привязана к одному потоку. Runtime может продолжить её на другом M.
- Приравнивать
GOMAXPROCSк количеству горутин или всех потоков процесса. - Обещать точный размер стека как свойство спецификации Go. Начальный размер относится к реализации runtime и может измениться.
- Делать вывод, что миллионы горутин безопасны при любой нагрузке. Важны удерживаемая память, внешние лимиты и скорость завершения.
- Объяснять конкурентность словом "параллельно". На одном P горутины могут выполняться конкурентно, но не одновременно.
Вопросы для самопроверки
- Зачем runtime нужна сущность P, если уже есть G и M?
- Может ли процесс иметь больше потоков, чем
GOMAXPROCS, и почему? - Что произойдёт с P, если M войдёт в долгий системный вызов?
- Почему ограниченный worker pool бывает безопаснее
goна каждый элемент? - Чем CPU limit контейнера отличается от лимита параллелизма
GOMAXPROCS?
Продолжить можно с архитектурной задачей про Rate Limiter на 100K RPS и разбором потоковой репликации PostgreSQL. Там тот же навык проверяется на другом масштабе: сначала модель, затем границы и отказные сценарии.
Если хочется разбирать такие ответы на живых примерах, в Векторе есть тренажёр собеседований и обсуждения с backend-разработчиками. Посмотреть программу сообщества.
Официальные материалы
Продолжайте практику
Закрепляйте прочитанное в задачах, реальных вопросах компаний и тренировочных собеседованиях.
Зарегистрироваться Открыть собеседования