> Как планировщик 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()) работает так:
- P пытается взять G из своей локальной очереди (runnext + локальный deque).
- Если локальная очередь пуста - берет G из глобальной очереди (с периодической проверкой каждые 61 тик).
- Если глобальная пуста - выполняет work-stealing: случайным образом выбирает другой P и забирает половину его очереди.
- Если все пусто - поток 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, они попадают в ту же локальную очередь - это хорошо для кэша, но может вызвать дисбаланс, если родитель создает много задач и не завершается.
Пример кода
GOpackage mainimport ("fmt""runtime""sync")func main() {runtime.GOMAXPROCS(2) // 2 логических процессораvar wg sync.WaitGroupfor 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).
Как отвечать на собеседовании
- Начните с модели M:N и трех сущностей (G, M, P).
- Опишите основной цикл планировщика: локальная очередь → глобальная → work-stealing.
- Упомяните два типа переключения: кооперативное и вытесняющее (с Go 1.14).
- Объясните поведение при syscall и hand-off.
- Если спросят про производительность - говорите про локальность, GOMAXPROCS и netpoller.
- Не углубляйтесь в детали реализации (например, точные алгоритмы выбора P при steal), если не просят.
Что проверяет интервьюер
- Понимание разницы между goroutine и потоком ОС.
- Знание архитектуры runtime: G, M, P.
- Понимание work-stealing и его назначения.
- Осведомленность о вытесняющем планировании (асинхронное прерывание).
- Умение объяснить, почему goroutine "дешевые" и когда они становятся дорогими.
- Понимание влияния блокирующих операций на планировщик.
Типичные ошибки
- Утверждение, что goroutine - это "потоки в userspace" (это не совсем так: они выполняются на потоках ОС).
- Путаница между GOMAXPROCS и количеством потоков ОС.
- Мнение, что планировщик распределяет goroutine "равномерно" - на самом деле он оптимизирует локальность.
- Игнорирование вытесняющего планирования (до Go 1.14 бесконечный цикл мог "повесить" все P).
- Утверждение, что блокирующий syscall блокирует весь процесс - на самом деле P освобождается.
- Непонимание, что
go- это не системный вызов, а постановка задачи в очередь планировщика.
> Похожие задачи по backend
В чем разница реляционных, NoSQL и колоночных баз данных
Какие примитивы синхронизации существуют кроме каналов в Go
Какие средства синхронизации кроме мьютексов используются в Go
В чем разница между EXPLAIN и EXPLAIN ANALYZE
> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?
Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью