Планировщик 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.

Связь между ними:

text
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 — то есть максимальное число горутин, исполняющихся параллельно:

go
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 это поведение исправлено в стандартной библиотеке.

go
import _ "go.uber.org/automaxprocs" // автоматически для Go < 1.25

Глобальная очередь и локальные очереди

У каждого P есть локальная очередь горутин (до 256 элементов). Кроме этого существует одна глобальная очередь для всего рантайма:

text
Global Queue: [G] [G] [G] [G] ... P0 Local: [G] [G] [G] P1 Local: [G] [G] P2 Local: []

Когда горутина создаётся через go func(), планировщик сначала пытается положить её в локальную очередь текущего P. Если локальная очередь переполнена — половина горутин переносится в глобальную очередь.

M берёт горутины для исполнения в следующем порядке:

  1. Из локальной очереди своего P
  2. Из глобальной очереди (раз в ~61 такт, чтобы глобальные горутины не голодали)
  3. Через work stealing у других P

Work Stealing

Если локальная очередь P пуста — M не простаивает, а крадёт горутины у других P:

text
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 — никакие другие горутины не могли бы исполняться. Планировщик решает это так:

text
До syscall: Во время syscall: P ── M0 ── G (syscall) M0 ── G (syscall) ← P отвязан │ └── очередь горутин P ── M1 (новый или из пула) │ └── очередь горутин (продолжают работу)
  1. Перед syscall P отвязывается от M0
  2. M1 (свободный поток из пула или новый) подхватывает P
  3. Остальные горутины продолжают исполняться на M1
  4. Когда syscall завершается — M0 пытается получить P обратно. Если P свободен — забирает. Если нет — G кладётся в глобальную очередь, M0 уходит в пул

Именно поэтому Go может эффективно обрабатывать тысячи конкурентных IO-операций: каждая горутина блокируется только на своём syscall, не мешая остальным.

Сетевой IO — особый случай

Сетевой IO в Go обрабатывается иначе через netpoller — асинхронный механизм на основе epoll (Linux), kqueue (macOS) или IOCP (Windows):

text
Горутина делает net.Read() │ ├── данные готовы → читаем сразу, горутина не блокируется │ └── данных нет → горутина паркуется (не блокирует M!) netpoller ждёт данных через epoll когда данные пришли → горутина возвращается в очередь

Сетевая горутина паркуется без блокировки M — поэтому тысячи горутин с открытыми TCP-соединениями не создают тысячи OS-потоков. M освобождается и исполняет другие горутины.


Preemption — вытеснение горутин

Ранние версии Go (до 1.14) использовали кооперативное вытеснение: горутина могла быть вытеснена только в точках, где она явно передавала управление — при вызове функций, операциях с каналами, syscall. Горутина в плотном цикле без вызовов функций могла монополизировать M:

go
// До Go 1.14: этот цикл держал M и не давал другим горутинам работать go func() { for { // плотный цикл без function calls — планировщик не может вытеснить i++ } }()

С Go 1.14 введено асинхронное вытеснение (asynchronous preemption): планировщик посылает горутине сигнал SIGURG (на Unix), который прерывает исполнение в любой точке кода — даже в середине плотного цикла. Горутина сохраняет состояние и помещается обратно в очередь:

go
// Go 1.14+: этот цикл корректно вытесняется планировщиком go func() { for { i++ // SIGURG может прийти в любой момент } }()

Вытеснение происходит примерно каждые 10 мс — это квант времени Go-планировщика.


spinning потоки

Когда очереди горутин пусты, M не засыпает немедленно. Несколько M переходят в spinning режим — активно опрашивают очереди в ожидании новых горутин:

text
M0: исполняет G M1: spinning ← активно проверяет очереди M2: sleeping ← припаркован, ждёт сигнала

Spinning позволяет мгновенно подхватить новую горутину без latency на пробуждение потока (которое занимает микросекунды). Количество spinning потоков ограничено — не более GOMAXPROCS/2, чтобы не жечь CPU впустую.


runtime.Gosched и ручное управление

В редких случаях горутина может явно уступить управление:

go
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 это редко нужно — асинхронное вытеснение справляется само.

go
// Другие полезные функции runtime.NumGoroutine() // текущее количество горутин runtime.NumCPU() // число логических CPU runtime.GOOS // операционная система (константа)

Как планировщик влияет на код

Понимание планировщика объясняет несколько неочевидных вещей:

Порядок исполнения горутин не гарантирован. Даже если горутина создана раньше — она может исполниться позже. Это зависит от состояния очередей и work stealing:

go
for i := 0; i < 5; i++ { go fmt.Println(i) // порядок вывода непредсказуем }

runtime.LockOSThread() — привязка к потоку. Некоторые C-библиотеки (OpenGL, GUI) требуют вызовов из одного и того же OS-потока. LockOSThread привязывает текущую горутину к текущему M навсегда (пока не вызовет UnlockOSThread):

go
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 и разницы между конкурентностью и параллелизмом

Условие: Ответь на вопросы и объясни:

go
runtime.GOMAXPROCS(2) for i := 0; i < 10; i++ { go heavyComputation() }
  1. Сколько горутин запущено?
  2. Сколько из них выполняются параллельно в один момент времени?
  3. Что изменится если GOMAXPROCS(1)?

Решение:

text
1. Запущено 10 горутин. 2. Параллельно выполняются максимум 2 — по числу P (GOMAXPROCS=2). Остальные 8 ждут в локальных очередях P. 3. При GOMAXPROCS(1) — только 1 P, горутины выполняются конкурентно но не параллельно. Планировщик переключает их на одном OS-потоке. Для CPU-bound задач это в 2 раза медленнее чем GOMAXPROCS(2). Важно: конкурентность (много горутин) ≠ параллелизм (одновременное выполнение). Параллелизм ограничен GOMAXPROCS.

Задача 2: Почему не зависает

Уровень: Средняя

Что проверяет: понимание планировщика при syscall, netpoller

Условие: Объясни почему этот код не блокирует сервер несмотря на то что каждый запрос делает сетевой вызов:

go
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)

Решение:

text
Каждый входящий запрос обрабатывается в отдельной горутине. При вызове 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+? Объясни разницу.

go
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() раньше был обязателен в таких циклах.

text
---