Планировщик Go
К этому моменту мы умеем запускать горутины, синхронизировать их через каналы и sync-примитивы, управлять отменой через контекст. Осталось понять как всё это работает внутри: кто решает какая горутина запустится следующей, как сотни тысяч горутин умещаются на нескольких OS-потоках и почему горутины не блокируют друг друга при системных вызовах. Это и есть планировщик Go.
Проблема, которую решает планировщик
OS-потоки дороги: каждый занимает 1–8 МБ стека, создание требует syscall, переключение контекста — работа ядра. Если запустить 100 000 потоков — система встанет.
Go решает это через мультиплексирование: тысячи горутин исполняются на небольшом фиксированном пуле OS-потоков. Планировщик Go работает в userspace и сам решает, какую горутину на каком потоке запустить — без участия ядра ОС.
Модель G/M/P
Планировщик Go построен на трёх сущностях:
G (Goroutine) — горутина. Содержит стек, указатель на текущую инструкцию и служебные поля. Это единица работы с точки зрения планировщика.
M (Machine) — OS-поток. Реальный поток операционной системы, который выполняет код. M нужен P чтобы исполнять горутины.
P (Processor) — логический процессор. Содержит локальную очередь горутин и необходимые ресурсы для их исполнения. Количество P равно GOMAXPROCS.
Связь между ними:
G G G G G G G G G
| | | | | | | | |
└──┴──┴──┘ └──┴──┘ └──┘
Local Queue P0 Local Queue P1 Local Queue P2
│ │ │
M0 M1 M2
│ │ │
OS Thread OS Thread OS Thread
M исполняет горутины из очереди своего P. Без P — M не может исполнять горутины. Это ключевое ограничение, объясняющее поведение при syscall.
GOMAXPROCS
GOMAXPROCS определяет количество P — то есть максимальное число горутин, исполняющихся параллельно:
import "runtime"
// По умолчанию равно числу логических CPU
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = только прочитать, не менять
// Изменить
runtime.GOMAXPROCS(4)
По умолчанию GOMAXPROCS равен числу логических ядер процессора. Это разумный дефолт для CPU-bound задач. Для IO-bound задач (большинство backend-сервисов) значение не имеет принципиального значения — горутины всё равно большую часть времени ждут IO, освобождая P.
В контейнерах до Go 1.25 была проблема: Go читал GOMAXPROCS из /proc/cpuinfo хоста, а не из cgroups контейнера. Контейнер с лимитом в 2 CPU на хосте с 32 ядрами получал GOMAXPROCS=32 — лишние потоки конкурировали за 2 реальных ядра. Решение — библиотека automaxprocs от Uber, которая читает лимиты из cgroups. Начиная с Go 1.25 это поведение исправлено в стандартной библиотеке.
import _ "go.uber.org/automaxprocs" // автоматически для Go < 1.25
Глобальная очередь и локальные очереди
У каждого P есть локальная очередь горутин (до 256 элементов). Кроме этого существует одна глобальная очередь для всего рантайма:
Global Queue: [G] [G] [G] [G] ...
P0 Local: [G] [G] [G] P1 Local: [G] [G] P2 Local: []
Когда горутина создаётся через go func(), планировщик сначала пытается положить её в локальную очередь текущего P. Если локальная очередь переполнена — половина горутин переносится в глобальную очередь.
M берёт горутины для исполнения в следующем порядке:
- Из локальной очереди своего P
- Из глобальной очереди (раз в ~61 такт, чтобы глобальные горутины не голодали)
- Через work stealing у других P
Work Stealing
Если локальная очередь P пуста — M не простаивает, а крадёт горутины у других P:
P0 Local: [] ← пусто P1 Local: [G1] [G2] [G3] [G4]
P0 крадёт половину у P1 →
P0 Local: [G3] [G4] P1 Local: [G1] [G2]
M с пустым P крадёт ровно половину горутин из случайно выбранного P. Это обеспечивает равномерное распределение нагрузки без централизованной координации — каждый P самостоятельно балансирует нагрузку.
Что происходит при системном вызове
Здесь планировщик делает нечто умное. Когда горутина выполняет блокирующий syscall (например, чтение файла) — она блокирует весь M, потому что syscall выполняется на уровне OS-потока.
Если бы P остался привязан к этому M — никакие другие горутины не могли бы исполняться. Планировщик решает это так:
До syscall: Во время syscall:
P ── M0 ── G (syscall) M0 ── G (syscall) ← P отвязан
│
└── очередь горутин P ── M1 (новый или из пула)
│
└── очередь горутин (продолжают работу)
- Перед syscall P отвязывается от M0
- M1 (свободный поток из пула или новый) подхватывает P
- Остальные горутины продолжают исполняться на M1
- Когда syscall завершается — M0 пытается получить P обратно. Если P свободен — забирает. Если нет — G кладётся в глобальную очередь, M0 уходит в пул
Именно поэтому Go может эффективно обрабатывать тысячи конкурентных IO-операций: каждая горутина блокируется только на своём syscall, не мешая остальным.
Сетевой IO — особый случай
Сетевой IO в Go обрабатывается иначе через netpoller — асинхронный механизм на основе epoll (Linux), kqueue (macOS) или IOCP (Windows):
Горутина делает net.Read()
│
├── данные готовы → читаем сразу, горутина не блокируется
│
└── данных нет → горутина паркуется (не блокирует M!)
netpoller ждёт данных через epoll
когда данные пришли → горутина возвращается в очередь
Сетевая горутина паркуется без блокировки M — поэтому тысячи горутин с открытыми TCP-соединениями не создают тысячи OS-потоков. M освобождается и исполняет другие горутины.
Preemption — вытеснение горутин
Ранние версии Go (до 1.14) использовали кооперативное вытеснение: горутина могла быть вытеснена только в точках, где она явно передавала управление — при вызове функций, операциях с каналами, syscall. Горутина в плотном цикле без вызовов функций могла монополизировать M:
// До Go 1.14: этот цикл держал M и не давал другим горутинам работать
go func() {
for {
// плотный цикл без function calls — планировщик не может вытеснить
i++
}
}()
С Go 1.14 введено асинхронное вытеснение (asynchronous preemption): планировщик посылает горутине сигнал SIGURG (на Unix), который прерывает исполнение в любой точке кода — даже в середине плотного цикла. Горутина сохраняет состояние и помещается обратно в очередь:
// Go 1.14+: этот цикл корректно вытесняется планировщиком
go func() {
for {
i++ // SIGURG может прийти в любой момент
}
}()
Вытеснение происходит примерно каждые 10 мс — это квант времени Go-планировщика.
spinning потоки
Когда очереди горутин пусты, M не засыпает немедленно. Несколько M переходят в spinning режим — активно опрашивают очереди в ожидании новых горутин:
M0: исполняет G
M1: spinning ← активно проверяет очереди
M2: sleeping ← припаркован, ждёт сигнала
Spinning позволяет мгновенно подхватить новую горутину без latency на пробуждение потока (которое занимает микросекунды). Количество spinning потоков ограничено — не более GOMAXPROCS/2, чтобы не жечь CPU впустую.
runtime.Gosched и ручное управление
В редких случаях горутина может явно уступить управление:
func heavyComputation() {
for i := 0; i < 1_000_000_000; i++ {
doWork(i)
if i%10_000 == 0 {
runtime.Gosched() // явно уступаем — даём другим горутинам поработать
}
}
}
runtime.Gosched() — аналог yield в других языках. Горутина помещается в конец очереди, планировщик выбирает следующую. С Go 1.14 это редко нужно — асинхронное вытеснение справляется само.
// Другие полезные функции
runtime.NumGoroutine() // текущее количество горутин
runtime.NumCPU() // число логических CPU
runtime.GOOS // операционная система (константа)
Как планировщик влияет на код
Понимание планировщика объясняет несколько неочевидных вещей:
Порядок исполнения горутин не гарантирован. Даже если горутина создана раньше — она может исполниться позже. Это зависит от состояния очередей и work stealing:
for i := 0; i < 5; i++ {
go fmt.Println(i) // порядок вывода непредсказуем
}
runtime.LockOSThread() — привязка к потоку. Некоторые C-библиотеки (OpenGL, GUI) требуют вызовов из одного и того же OS-потока. LockOSThread привязывает текущую горутину к текущему M навсегда (пока не вызовет UnlockOSThread):
func glWorker() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// все вызовы OpenGL из этой горутины — на одном M
gl.Init()
renderLoop()
}
Горутины на syscall создают дополнительные M. Если все горутины одновременно делают блокирующие syscall — рантайм создаёт новые M. Их количество не ограничено GOMAXPROCS и может стать очень большим. В экстремальных случаях это приводит к исчерпанию ресурсов. Ограничение через runtime/debug.SetMaxThreads (по умолчанию 10 000).
Вопросы на собеседовании
Q: Что такое G, M и P в планировщике Go?
A: G — горутина, единица работы со своим стеком и контекстом исполнения. M — OS-поток, реальный поток операционной системы. P — логический процессор, содержит локальную очередь горутин и необходимые ресурсы. M исполняет горутины только при наличии P. Количество P определяется GOMAXPROCS.
Q: Что такое work stealing?
A: Механизм балансировки нагрузки: если локальная очередь P пуста, M крадёт половину горутин из случайно выбранного другого P. Это обеспечивает равномерное распределение без централизованной координации и позволяет избежать простоя потоков.
Q: Что происходит когда горутина делает блокирующий syscall?
A: P отвязывается от текущего M. Свободный M (или новый) подхватывает P и продолжает исполнять другие горутины. Когда syscall завершается, M пытается вернуть P. Если P занят — горутина кладётся в глобальную очередь, M уходит в пул.
Q: Как Go обрабатывает тысячи сетевых соединений не создавая тысячи потоков?
A: Через netpoller на основе epoll/kqueue/IOCP. Горутина, ожидающая сетевого IO, паркуется без блокировки M — поток освобождается и исполняет другие горутины. Когда данные готовы, netpoller возвращает горутину в очередь планировщика.
Q: Что такое кооперативное и асинхронное вытеснение? Когда изменилось?
A: До Go 1.14 — кооперативное: горутина вытесняется только в точках явной передачи управления (вызов функции, канал, syscall). Плотный цикл без вызовов мог монополизировать поток. С Go 1.14 — асинхронное: планировщик посылает SIGURG, прерывающий горутину в любой точке. Квант времени — ~10 мс.
Q: Что такое GOMAXPROCS и как правильно настраивать в контейнере?
A: Количество P — максимум параллельно исполняемых горутин. По умолчанию равно числу логических CPU. В контейнерах до Go 1.25 читался из хоста, а не из cgroups — решение: automaxprocs от Uber. В Go 1.25 исправлено в стандартной библиотеке.
Q: Зачем нужны spinning потоки?
A: Когда очереди пусты, часть M активно опрашивают очереди вместо немедленного засыпания. Это позволяет мгновенно подхватить новую горутину без latency на пробуждение потока. Количество spinning M ограничено GOMAXPROCS/2.
Q: Когда и зачем использовать runtime.LockOSThread?
A: Когда C-библиотека требует вызовов из одного и того же OS-потока (OpenGL, некоторые GUI-фреймворки, CGo с thread-local state). LockOSThread привязывает горутину к текущему M — планировщик не перенесёт её на другой поток.
Q: Почему количество M не ограничено GOMAXPROCS?
A: GOMAXPROCS ограничивает только P — активно исполняющие горутины. M создаются под каждый блокирующий syscall чтобы P мог продолжать работу с другим M. В теории M может быть намного больше GOMAXPROCS если много горутин одновременно в syscall. Лимит — runtime/debug.SetMaxThreads, по умолчанию 10 000.
Задачи: Планировщик
Задачи по планировщику — концептуальные. Здесь важно объяснение, а не код.
Задача 1: Сколько реально параллельно
Уровень: Лёгкая
Что проверяет: понимание GOMAXPROCS и разницы между конкурентностью и параллелизмом
Условие: Ответь на вопросы и объясни:
runtime.GOMAXPROCS(2)
for i := 0; i < 10; i++ {
go heavyComputation()
}
- Сколько горутин запущено?
- Сколько из них выполняются параллельно в один момент времени?
- Что изменится если
GOMAXPROCS(1)?
Решение:
1. Запущено 10 горутин.
2. Параллельно выполняются максимум 2 — по числу P (GOMAXPROCS=2).
Остальные 8 ждут в локальных очередях P.
3. При GOMAXPROCS(1) — только 1 P, горутины выполняются
конкурентно но не параллельно. Планировщик переключает
их на одном OS-потоке. Для CPU-bound задач это в 2 раза
медленнее чем GOMAXPROCS(2).
Важно: конкурентность (много горутин) ≠ параллелизм
(одновременное выполнение). Параллелизм ограничен GOMAXPROCS.
Задача 2: Почему не зависает
Уровень: Средняя
Что проверяет: понимание планировщика при syscall, netpoller
Условие: Объясни почему этот код не блокирует сервер несмотря на то что каждый запрос делает сетевой вызов:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
resp, _ := http.Get("https://api.example.com/data") // сетевой вызов
defer resp.Body.Close()
io.Copy(w, resp.Body)
})
http.ListenAndServe(":8080", nil)
Решение:
Каждый входящий запрос обрабатывается в отдельной горутине.
При вызове http.Get — сетевой IO обрабатывается через netpoller
(epoll на Linux).
Когда горутина ждёт ответа от api.example.com:
1. Горутина паркуется — не блокирует свой M (OS-поток)
2. netpoller регистрирует дескриптор в epoll
3. M освобождается и берёт другую горутину из очереди
4. Когда данные пришли — netpoller возвращает горутину в очередь
Результат: 10 000 одновременных запросов к внешнему API
= 10 000 припаркованных горутин, но всего GOMAXPROCS активных потоков.
Сервер не блокируется.
Задача 3: Плотный цикл
Уровень: Сложная
Что проверяет: понимание preemption, разница до и после Go 1.14
Условие: Что произойдёт при запуске этого кода на Go 1.13 и на Go 1.14+? Объясни разницу.
runtime.GOMAXPROCS(1)
go func() {
for {
// плотный цикл без вызовов функций
_ = 1 + 1
}
}()
time.Sleep(time.Second)
fmt.Println("main продолжается")\n```
**Решение:**
Go 1.13 (кооперативное вытеснение): Горутина в плотном цикле без вызовов функций НИКОГДА не отдаёт управление планировщику. При GOMAXPROCS(1) есть только один P — он занят бесконечным циклом. time.Sleep в main никогда не сработает — deadlock или main никогда не продолжится.
Go 1.14+ (асинхронное вытеснение):
Планировщик посылает SIGURG каждые ~10ms.
Горутина прерывается в любой точке — даже внутри _ = 1+1.
Сохраняется состояние регистров, горутина кладётся в очередь.
main продолжается через секунду, выводит "main продолжается".
Вывод: до Go 1.14 плотные циклы без function calls могли монополизировать поток. После 1.14 — нет. runtime.Gosched() раньше был обязателен в таких циклах.
---