Модуль: Concurrency · Уровень: Senior+
TL;DR#
Планировщик Go строится на трёх сущностях: G (горутина), M (поток ОС), P (логический процессор/контекст планирования). P держит локальную очередь runnable-горутин, число P = GOMAXPROCS. Используются work stealing, кооперативная и (с 1.14) асинхронная вытесняющая преемпция, handoff P при блокирующих syscall и интегрированный netpoller для сетевого I/O.
Теория#
G, M, P#
- G (
runtime.g) — горутина: стек, контекст (gobuf), статус. - M (
runtime.m) — машина = поток ОС. Реально исполняет код. Чтобы исполнять Go-код, M должен владеть P. - P (
runtime.p) — процессор/контекст: локальный runqueue (256 слотов, кольцевой буфер +runnext), кэши аллокатора (mcache), таймеры. Число P =GOMAXPROCS. P — это «право исполнять Go-код».
Инвариант: выполнять Go-код может только связка M+P. Свободные P лежат в sched.pidle; свободные M — в sched.midle.
┌─ P0 [runq: G,G,G][runnext]──── M0 (поток ОС) ── исполняет G
GOMAX= ├─ P1 [runq: G,G ][runnext]──── M1
4 ├─ P2 ...
└─ P3 ...
global runq: [G,G,...] netpoller: epoll/kqueueОчереди#
- runnext — слот «следующая» на P, оптимизация локальности (только что разбуженная горутина исполнится почти сразу).
- локальный runq P — без блокировок для своего M (LIFO/FIFO нюансы).
- глобальный runq — под общим локом
sched.lock; используется при переполнении локального и периодически проверяется (каждый 61-й тик планирования, чтобы избежать голодания глобальной очереди).
Work stealing#
Когда у M+P пустеет локальная очередь, findrunnable ищет работу в таком порядке (упрощённо):
- локальный runq (и
runnext); - глобальный runq;
- netpoller (готовые I/O горутины);
- steal — крадёт половину горутин у случайного другого P (рандомизированный обход, чтобы избегать конвоев);
- если ничего нет — M паркуется (
stopm).
// runtime.schedule (упрощённо):
// gp := runqget(p) // локально
// if gp == nil { gp, _ = findrunnable() } // глобал, netpoll, steal
// execute(gp)Преемпция (preemption)#
- Кооперативная: точки в прологе функций.
sysmonили GC выставляетg.stackguard0 = stackPreempt; при следующем вызове функции горутина уступает. Проблема: тесный цикл без вызовов функций (for {}) не преемптился — до 1.14 мог зависать GC/STW. - Асинхронная (Go 1.14+):
sysmonзамечает, что горутина бежит дольше ~10 мс, и черезpreemptMпосылает потоку сигнал (SIGURGна Unix). Обработчик сигнала останавливает горутину в безопасной точке (нужны асинхронные safe points и точные регистр-/стек-мапы). Это позволило вытеснять дажеfor {}.
sysmon#
Фоновый поток-монитор без P, работает в цикле с растущим backoff. Делает: запуск отложенной преемпции, форсирование GC, возврат памяти ОС, retake P у горутин в долгих syscall, опрос netpoller если его давно не опрашивали.
Syscalls и handoff P#
При входе в блокирующий syscall (entersyscall):
- M отвязывается от P логически (P переходит в
_Psyscall), но физически M ещё держит P. - Если syscall быстрый — после возврата (
exitsyscall) M пытается снова взять тот же P. - Если syscall долгий —
sysmonчерез handoff отбирает P (handoffp) и отдаёт другому M (или будит/создаёт новый), чтобы P не простаивал. Когда M вернётся из syscall без P, его горутина уходит в глобальную очередь, а M паркуется.
Это ключ: блокирующий syscall занимает поток ОС, но не блокирует P — параллелизм Go-кода сохраняется. Поэтому сетевой I/O делают неблокирующим (netpoller), а блокирующие вызовы (например, файловый I/O, cgo) могут плодить потоки.
Netpoller#
Сетевые сокеты переводятся в неблокирующий режим и регистрируются в платформенном механизме: epoll (Linux), kqueue (BSD/macOS), IOCP (Windows). Когда горутина делает conn.Read и данных нет, она паркуется (_Gwaiting), а поток освобождается для другой работы. При готовности сокета netpoller (netpoll) возвращает список готовых горутин, которые становятся runnable. Так тысячи соединений обслуживаются немногими потоками без thread-per-connection.
GOMAXPROCS#
Число P = максимум одновременно исполняемых Go-горутин. По умолчанию = числу логических CPU.
- Влияет на параллелизм, не на конкурентность.
- В контейнерах долго была проблема: рантайм видел все CPU хоста, а cgroup-квота ограничивала → throttling. Использовали
automaxprocs. С Go 1.25 рантайм учитывает cgroup CPU-лимиты при выборе значения по умолчанию. - Менять рантайм:
runtime.GOMAXPROCS(n).
import "runtime"
old := runtime.GOMAXPROCS(0) // 0 = только прочитать текущее
_ = oldПодводные камни / gotchas#
for {}без точек преемпции до 1.14 вешал GC; сейчас спасает async preemption, но всё равно сжигает ядро.- cgo и блокирующие syscalls плодят потоки ОС (по одному на залипший вызов). Число M ограничено
sched.maxmcount(по умолч. 10000) — упор в него = крах. - GOMAXPROCS в контейнере до 1.25 без
automaxprocs→ CPU throttling и рост латентности. runtime.LockOSThreadпривязывает горутину к конкретному M (нужно для UI-тредов, OpenGL, некоторых syscalls) — этот M нельзя переиспользовать.- Ложное ожидание FIFO: порядок исполнения горутин не гарантирован;
runnextи стилинг ломают наивные предположения о порядке. - Глобальная очередь проверяется не каждый раз — при экстремальной нагрузке возможна неравномерность, но 61-й тик это сглаживает.
Вопросы на собеседовании#
В: Зачем нужна сущность P, ведь есть G и M? О: P отделяет «право исполнять Go-код» и контекст планирования (локальный runqueue, mcache) от потока ОС. Это даёт лок-фри доступ к локальной очереди, ограничивает параллелизм (= GOMAXPROCS) и позволяет передавать контекст между потоками при блокировках (handoff), не теряя накопленные кэши.
В: Как работает work stealing?
О: Когда P опустошил локальную очередь, его M в findrunnable проверяет глобальную очередь, netpoller, а затем крадёт половину горутин у случайно выбранного другого P. Рандомизация обхода предотвращает конвои и контеншн.
В: Чем кооперативная преемпция отличается от асинхронной?
О: Кооперативная срабатывает в прологе функций по флагу stackPreempt — не работает в тесных циклах без вызовов. Асинхронная (1.14+) использует сигнал SIGURG: sysmon видит долго бегущую горутину и прерывает её в асинхронной safe point, что позволило вытеснять даже for {}.
В: Что происходит с P при блокирующем системном вызове?
О: Горутина и M уходят в syscall, P помечается _Psyscall. Если вызов долгий, sysmon делает handoff: отбирает P и отдаёт другому/новому M, чтобы P не простаивал. По возврату M пытается взять P обратно; если не вышло — горутина в глобальную очередь, M паркуется.
В: Как Go обслуживает много сетевых соединений малым числом потоков? О: Через netpoller на epoll/kqueue/IOCP. Сокеты неблокирующие; при отсутствии данных горутина паркуется, поток свободен. netpoller возвращает готовые горутины в runnable при событиях готовности. Нет thread-per-connection.
В: Что делает sysmon? О: Фоновый поток без P: форсирует преемпцию и GC, делает retake P из долгих syscall, опрашивает netpoller при простое, возвращает память ОС. Работает с экспоненциальным backoff.
В: Как GOMAXPROCS соотносится с числом ядер и контейнерами? О: Это число P = максимум параллельно исполняемых горутин, по умолчанию = логическим CPU. В контейнере до Go 1.25 рантайм игнорировал cgroup-квоту, что вызывало throttling; решали через automaxprocs, а с 1.25 рантайм читает cgroup-лимиты сам.
В: Что такое runnext и зачем он?
О: Слот приоритетного запуска на P для только что разбуженной горутины. Улучшает локальность кэша и латентность типичного паттерна «разбудил — почти сразу исполнил» (например, после отправки в канал).
На что копают на senior+#
- Точный порядок
findrunnableи почему netpoll опрашивается и там, и в sysmon. - Async safe points: какие инструкции safe, роль точных регистр-мап.
- Handoff vs retake: кто инициирует (
entersyscall/sysmon),_Psyscallvs_Pidle. - Влияние LockOSThread, cgo на пул M и
maxmcount. - Spinning M: как рантайм балансирует между засыпанием M и быстрым подхватом новой работы (
startm,wakep). - Изменения дефолта GOMAXPROCS в 1.25 (cgroup-aware) и последствия для latency-критичных сервисов.