> Как планировщик Go распределяет goroutine между потоками (Go)

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

Компании: InDrive, YADRO, VK

Стек: Go

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

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

Планировщик Go использует модель M:N, где M - потоки ОС, а N - goroutine. Каждый поток (M) привязан к логическому процессору (P), который содержит локальную очередь runqueue. Goroutine распределяются через work-stealing: когда P исчерпывает свою очередь, он крадет задачи из очередей других P или из глобальной очереди. Также используется hand-off: при блокировке syscall поток M освобождает P, и планировщик передает его другому потоку.

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

Планировщик Go - это часть runtime, реализующая кооперативную и вытесняющую многозадачность. Архитектура состоит из трех сущностей:

  • G (goroutine) - легковесная задача со своим стеком и состоянием.
  • M (machine/thread) - поток ОС, который выполняет G.
  • P (processor) - логический процессор, ограничивающий параллелизм (обычно равен GOMAXPROCS).

Основной цикл планировщика (schedule()) работает так:

  1. P пытается взять G из своей локальной очереди (runnext + локальный deque).
  2. Если локальная очередь пуста - берет G из глобальной очереди (с периодической проверкой каждые 61 тик).
  3. Если глобальная пуста - выполняет work-stealing: случайным образом выбирает другой P и забирает половину его очереди.
  4. Если все пусто - поток M блокируется (паркуется) до появления новых задач.

Ключевые механизмы:

  • Кооперативное переключение: происходит на точках входа в функции (stack check), при вызовах runtime (например, channel, mutex, time.Sleep).
  • Вытесняющее переключение: с Go 1.14 - асинхронное, через сигнал SIGURG, отправляемый потоку, который работает слишком долго (более 10 мс). Это защищает от "зависших" циклов без вызова runtime.
  • Syscall handling: если G выполняет блокирующий syscall, M отвязывается от P, P передается другому M (или создается новый M). После завершения syscall G возвращается в очередь P.
  • Hand-off: если M блокируется на runtime-операции (например, на мьютексе), он может передать свой P другому свободному M, чтобы не простаивать процессор.

Распределение между потоками не является "равномерным" - планировщик не балансирует нагрузку постоянно. Вместо этого используется локальность: G, созданные на одном P, выполняются на нем же, что уменьшает contention на кэше. Work-stealing включается только при дисбалансе.

На практике

Для senior-разработчика важно понимать, как планировщик влияет на производительность:

  • GOMAXPROCS - не "максимум потоков", а максимум одновременно выполняющихся G. Потоков M может быть больше (из-за syscall или блокировок).
  • Блокирующие операции (например, sync.Mutex в старых версиях) могут приводить к переключению M на другой G, но с Go 1.18 мьютексы стали более "дружелюбными" к планировщику.
  • Сетевые операции не блокируют M: runtime использует netpoller (epoll/kqueue), и G просто "засыпает" до готовности данных.
  • Чрезмерное создание goroutine (миллионы) - это нормально по памяти (стек растет от 2 КБ), но может увеличить latency из-за работы GC и планировщика.
  • Локальность данных: если G создает дочерние G, они попадают в ту же локальную очередь - это хорошо для кэша, но может вызвать дисбаланс, если родитель создает много задач и не завершается.

Пример кода

GO
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(2) // 2 логических процессора
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Точка кооперативного переключения: runtime.Gosched()
if id%2 == 0 {
runtime.Gosched() // уступаем планировщику
}
fmt.Println("goroutine", id)
}(i)
}
wg.Wait()
}

Здесь runtime.Gosched() - явная передача управления планировщику. Без него переключение произошло бы автоматически на stack check или при вызове fmt.Println (который использует runtime).

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

  1. Начните с модели M:N и трех сущностей (G, M, P).
  2. Опишите основной цикл планировщика: локальная очередь → глобальная → work-stealing.
  3. Упомяните два типа переключения: кооперативное и вытесняющее (с Go 1.14).
  4. Объясните поведение при syscall и hand-off.
  5. Если спросят про производительность - говорите про локальность, GOMAXPROCS и netpoller.
  6. Не углубляйтесь в детали реализации (например, точные алгоритмы выбора P при steal), если не просят.

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

  • Понимание разницы между goroutine и потоком ОС.
  • Знание архитектуры runtime: G, M, P.
  • Понимание work-stealing и его назначения.
  • Осведомленность о вытесняющем планировании (асинхронное прерывание).
  • Умение объяснить, почему goroutine "дешевые" и когда они становятся дорогими.
  • Понимание влияния блокирующих операций на планировщик.

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

  • Утверждение, что goroutine - это "потоки в userspace" (это не совсем так: они выполняются на потоках ОС).
  • Путаница между GOMAXPROCS и количеством потоков ОС.
  • Мнение, что планировщик распределяет goroutine "равномерно" - на самом деле он оптимизирует локальность.
  • Игнорирование вытесняющего планирования (до Go 1.14 бесконечный цикл мог "повесить" все P).
  • Утверждение, что блокирующий syscall блокирует весь процесс - на самом деле P освобождается.
  • Непонимание, что go - это не системный вызов, а постановка задачи в очередь планировщика.

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

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