> Зачем запускать много горутин на процессоре с ограниченным числом ядер (Go)

Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы

Компании: Black Wall Group (BWG)

Стек: Go

> Пример ответа

Короткий ответ

Горутины запускают не "на ядрах", а как конкурентные задачи, которые планировщик Go распределяет по доступным потокам ОС (M), привязанным к ядрам. Много горутин нужно не для параллелизма, а для конкурентности: они позволяют выразить асинхронные операции (I/O, ожидание сети, таймеры) как последовательный код, не блокируя поток. Даже на одном ядре тысячи горутин дают выигрыш за счёт эффективного переключения контекста и утилизации времени ожидания.

Подробное объяснение

Основная причина - не параллельное выполнение, а конкурентная модель. Горутина - это легковесная единица работы (стек от 2 КБ, переключение ~наносекунды), тогда как поток ОС - тяжёлый (стек ~1 МБ, переключение ~микросекунды). Планировщик Go (GMP-модель) мультиплексирует горутины на пул потоков ОС, число которых по умолчанию равно GOMAXPROCS (обычно числу ядер).

Когда горутина блокируется на системном вызове (чтение из сети, файла, канал), планировщик не держит поток - он переключает другой поток на это ядро, а заблокированная горутина "засыпает" в очереди. Таким образом, одно ядро обслуживает тысячи горутин, чередуя их выполнение: пока одна ждёт ответа от БД, другая обрабатывает запрос, третья - пишет в лог.

Параллелизм (одновременное выполнение) ограничен числом ядер, но конкурентность (межслоевое переплетение) - нет. Даже на 4 ядрах можно запустить 100 000 горутин: они будут выполняться порциями, но суммарная пропускная способность вырастет за счёт перекрытия ожиданий.

Дополнительно: горутины позволяют избежать блокировок на уровне ОС. Если бы вы создавали потоки под каждую задачу, при 10 000 одновременных соединений вы бы упёрлись в лимит памяти и переключений. Горутины решают эту проблему ценой небольшого накладного расхода на планировщик.

На практике

В реальных backend-сервисах (HTTP, gRPC, очереди) каждый запрос обрабатывается в отдельной горутине. Даже если ядер 8, одновременно может быть 50 000 активных соединений - большинство из них ждут I/O. Горутины позволяют писать код линейно: resp, err := http.Get(url) - без callback-ада и async/await.

Важно понимать: если горутины выполняют CPU-bound работу (численные расчёты, парсинг), то больше ядер - больше параллелизма. Но для I/O-bound сценариев число горутин не привязано к ядрам, и это нормально.

Практические следствия:

  • GOMAXPROCS не ограничивает число горутин, только число параллельно исполняемых потоков.
  • Не нужно создавать пул горутин под каждое ядро - планировщик сам распределит.
  • Стоит использовать runtime.NumCPU() для CPU-bound задач, но не для I/O-bound.
  • При работе с каналами и мьютексами важно избегать гонок - планировщик не гарантирует порядок.

Пример кода

GO
// I/O-bound: 100k горутин на 4 ядрах - нормально
func handleRequests(requests <-chan Request) {
for req := range requests {
go func(r Request) {
resp, err := http.Post(r.URL, "application/json", r.Body)
if err != nil {
log.Printf("error: %v", err)
return
}
defer resp.Body.Close()
// обработка ответа
}(req)
}
}
// CPU-bound: ограничиваем параллелизм числом ядер
func processData(items []Item) {
sem := make(chan struct{}, runtime.NumCPU())
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // блокируем, если все ядра заняты
go func(it Item) {
defer wg.Done()
defer func() { <-sem }()
heavyComputation(it)
}(item)
}
wg.Wait()
}

Как отвечать на собеседовании

Начните с разделения понятий: конкурентность vs параллелизм. Скажите, что горутины - это модель конкурентности, а не способ задействовать ядра. Упомяните GMP-модель: G (goroutine), M (machine/thread), P (processor). Объясните, что планировщик переключает горутины на системных вызовах, поэтому I/O-bound задачи выигрывают от большого числа горутин.

Приведите пример: HTTP-сервер с 10k соединений на 4 ядрах - это нормально, потому что 99% времени горутины ждут сеть. Подчеркните, что CPU-bound задачи не получат выигрыша от тысяч горутин - там важно число ядер.

Если спросят про GOMAXPROCS - скажите, что он управляет числом потоков ОС, а не горутин. Можно упомянуть, что с Go 1.5 значение по умолчанию - число ядер, и менять его стоит только в специфичных случаях (например, для ограничения в контейнерах).

Что проверяет интервьюер

  • Понимание разницы между конкурентностью и параллелизмом.
  • Знание устройства планировщика Go (GMP, work stealing, локальные очереди).
  • Умение объяснить, почему горутины дешевле потоков ОС.
  • Понимание, когда много горутин полезно (I/O-bound), а когда вредно (CPU-bound с гонками за память).
  • Осознание trade-off: память на стек, накладные расходы на планировщик, сложность отладки.

Типичные ошибки

  • Утверждение "много горутин = быстрее" - без учёта типа нагрузки.
  • Смешение GOMAXPROCS с лимитом горутин.
  • Игнорирование того, что горутины не гарантируют параллельное выполнение на многоядерной системе.
  • Забывают про гонки данных - при большом числе горутин вероятность конфликтов растёт.
  • Создание горутин без контроля (утечки) - например, без WaitGroup или канала завершения.
  • Ответ "потому что так быстрее" без объяснения механизма планировщика.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью