Модуль: 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 ищет работу в таком порядке (упрощённо):

  1. локальный runq (и runnext);
  2. глобальный runq;
  3. netpoller (готовые I/O горутины);
  4. steal — крадёт половину горутин у случайного другого P (рандомизированный обход, чтобы избегать конвоев);
  5. если ничего нет — 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):

  1. M отвязывается от P логически (P переходит в _Psyscall), но физически M ещё держит P.
  2. Если syscall быстрый — после возврата (exitsyscall) M пытается снова взять тот же P.
  3. Если 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), _Psyscall vs _Pidle.
  • Влияние LockOSThread, cgo на пул M и maxmcount.
  • Spinning M: как рантайм балансирует между засыпанием M и быстрым подхватом новой работы (startm, wakep).
  • Изменения дефолта GOMAXPROCS в 1.25 (cgroup-aware) и последствия для latency-критичных сервисов.