GMP Scheduler: глубокое погружение
Зачем знать на Middle 1. Понимание GMP — это разница между «у меня в коде утечка горутин» и «я знаю, как scheduler ставит мою горутину на CPU, когда она блокируется на syscall, и почему work-stealing спасает мне p99». Без GMP нельзя осмысленно читать
go tool trace, нельзя правильно настраиватьGOMAXPROCSв Kubernetes-подах с CPU limits, нельзя объяснить, почему один CPU-bound цикл без вызова функций может «съесть» один поток до Go 1.14 и не дать другим горутинам времени. На джуне достаточно было знать «горутины дешёвые». На мидле от вас ждут конкретики: G/M/P, local/global queue, work stealing, async preemption (SIGURG), netpoll, syscall handoff, sysmon.
Содержание
Заголовок раздела «Содержание»- Базовая концепция: M:N scheduling и компоненты G/M/P
- Под капотом: структуры, очереди, work stealing, preemption
- Gotchas: tight loops, LockOSThread, syscalls
- Production-практики: GOMAXPROCS в контейнерах, sysmon, диагностика
- Вопросы на собесе (25–30)
- Practice
- Источники
1. Базовая концепция: M:N scheduling и компоненты G/M/P
Заголовок раздела «1. Базовая концепция: M:N scheduling и компоненты G/M/P»1.1. Что такое M:N
Заголовок раздела «1.1. Что такое M:N»Go использует M:N модель: M горутин (user-space tasks) выполняются на N OS-потоках (которые в свою очередь распределяются ядром на K физических CPU). У большинства языков был выбор:
- 1:1 (kernel threads) — Java (до Project Loom), C/C++ с pthreads. Дёшево создать, но context-switch через ядро (~1–5 µs) и память на стек (~2–8 MB на поток).
- N:1 (green threads) — старый Ruby, Python без
asyncio. Один OS-поток обслуживает всё, нельзя задействовать многоядерность. - M:N (work stealing) — Go (GMP), Erlang/BEAM, Project Loom (виртуальные потоки), Rust Tokio. Дёшево создать (start 2KB stack, growable), мультиплексируем на OS-потоках, can use all cores.
Цена создания горутины — около 2 KB начального стека + структура g (~400 байт). Сравните с pthread (минимум 8 KB стек + ядерные структуры).
1.2. Терминология
Заголовок раздела «1.2. Терминология»G (goroutine) — единица исполнения уровня Go. Условно "поток" из user-кода.M (machine) — OS-поток (pthread на Linux/macOS, win32-thread на Windows).P (processor) — логический ресурс «право выполнять Go-код». Number of P = GOMAXPROCS.Ключевая инвариантность: чтобы M выполнял Go-код, ему нужен P. Без P поток может только спать, делать syscall или waiting. Это даёт ограничение параллелизма Go-кода: одновременно работающих горутин не больше, чем P.
1.3. ASCII-схема: связь G, M, P
Заголовок раздела «1.3. ASCII-схема: связь G, M, P» ┌──────────────────────────────────────┐ │ Global Run Queue │ (защищена sched.lock) │ [G][G][G][G][G][G][G][G]... │ └──────────────────────────────────────┘ ▲ ▲ │ overflow │ steal (1/61 ticks) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ P0 │ │ P1 │ │ P2 │ ← GOMAXPROCS=3 │ local rq │ │ local rq │ │ local rq │ │ [G][G][G] │ │ [G][G] │ │ [G][G][G][G]│ │ mcache │ │ mcache │ │ mcache │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ bind 1:1 (когда выполняем Go) │ │ │ │ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ M0 │ │ M1 │ │ M2 │ │ (OS thread) │ │ (OS thread) │ │ (OS thread) │ │ running G │ │ running G │ │ running G │ └─────────────┘ └─────────────┘ └─────────────┘
Дополнительные M (idle / parked / в syscall): ┌─────────────┐ ┌─────────────┐ │ M3 │ │ M4 │ ← M в syscall (без P), M parked │ (in syscall)│ │ (parked) │ └─────────────┘ └─────────────┘
netpoll (epoll/kqueue/IOCP) — отдельный механизм ┌────────────────────────────────────┐ │ FD → blocked G's │ │ когда FD ready → G в run queue │ └────────────────────────────────────┘Что здесь видно:
- P держит local run queue (массив фиксированной длины 256) и
mcache(mcache — кэш аллокатора, чтобы не лочиться на central heap). - Каждый M, выполняющий Go-код, ассоциирован с одним P (1:1 в этот момент).
- M в syscall отдаёт P другому M (handoff), чтобы остальные горутины могли работать.
- Когда у P опустошается local queue, он крадёт половину задач у случайного другого P (work stealing).
- Раз в 61 такт scheduler также проверяет global queue — чтобы избежать starvation.
2. Под капотом: структуры, очереди, work stealing, preemption
Заголовок раздела «2. Под капотом: структуры, очереди, work stealing, preemption»2.1. Структура g (runtime2.go)
Заголовок раздела «2.1. Структура g (runtime2.go)»// упрощённо, реальная структура в src/runtime/runtime2.gotype g struct { stack stack // нижняя и верхняя граница стека stackguard0 uintptr // нижняя граница + spill для stack growth m *m // текущий M, к которому привязана (nil если runnable) sched gobuf // сохранённое состояние регистров (для возобновления) atomicstatus uint32 // _Grunnable / _Grunning / _Gwaiting / ... goid int64 // уникальный ID (внутренний, наружу не дают) waitreason waitReason // причина блокировки (для трассировок) preempt bool // флаг "тебе нужно остановиться" preemptStop bool // async preemption триггер // + ~50 других полей: panic stack, defer stack, profiler, GC bits...}
type gobuf struct { sp uintptr // stack pointer pc uintptr // program counter g guintptr ctxt unsafe.Pointer ret uintptr lr uintptr // link register (ARM) bp uintptr // base pointer (frame pointer)}Когда горутина приостановлена (например, для work stealing или после syscall), её регистры (sp, pc, bp и пр.) сохранены в g.sched. При возобновлении scheduler делает gogo(&g.sched) — это assembly-функция, которая восстанавливает регистры и «приземляется» в нужное место кода.
2.2. Стеки горутин
Заголовок раздела «2.2. Стеки горутин»- Начальный стек — 2 KB (раньше было 8 KB; уменьшили в Go 1.4, перейдя на copying stacks).
- При исчерпании место для stack growth check в прологе функции триггерит
morestack→ выделяется новый, в 2 раза больший стек, всё содержимое копируется (потому горутины могут двигаться в памяти; не сохраняйте указатели на стек горутины снаружи). - Stack shrinking — при GC: если стек используется меньше, чем на четверть, может ужаться.
- Max stack — 1 GB (см.
runtime.SetMaxStack).
Старт горутины: stackguard0 │ ┌─────────────────┴─────────────────┐ │ 2KB stack (growable) │ └───────────────────────────────────┘ │ Пролог любой функции: CMP SP, stackguard0 JLS runtime.morestack_noctxt ... (продолжаем выполнение)2.3. Структура m
Заголовок раздела «2.3. Структура m»type m struct { g0 *g // системная goroutine для исполнения runtime-кода curg *g // текущая user goroutine (nil если ничего не делаем) p puintptr // привязанный P (nil если в syscall/parked) nextp puintptr // P, который нужно подобрать после wake-up oldp puintptr // P, который был перед syscall park note // примитив для парковки (futex/sema) mOS mOS // OS-зависимые поля (LWP id, тред-handle и т.д.) lockedg guintptr // если есть locked goroutine (LockOSThread) spinning bool // ищет работу (но ещё не нашёл) blocked bool // + GC marker fields, signal handling и т.п.}g0 — это системная goroutine для каждого M. У неё большой стек (~8 KB на Linux, 64 KB на Windows), на котором выполняется код самого runtime (scheduler, GC, syscall wrappers). User-goroutines живут на отдельных, маленьких стеках.
2.4. Структура p
Заголовок раздела «2.4. Структура p»type p struct { id int32 status uint32 // _Pidle / _Prunning / _Psyscall / ... m muintptr // M, который держит P в данный момент mcache *mcache // локальный кэш аллокатора pcache pageCache runqhead uint32 // голова кольцевого буфера runqtail uint32 // хвост runq [256]guintptr // КОЛЬЦЕВОЙ БУФЕР НА 256 ГОРУТИН runnext guintptr // приоритетная горутина (последняя добавленная) timers []*timer // + GC fields, schedtick, syscalltick и т.д.}runnext — это «приоритетный слот» для последней горутины, созданной/разблокированной этим P. Идея: если G породила G2 через go f(), скорее всего, G2 нужно выполнить немедленно после G (кэш горячий, родственные данные). Поэтому G2 кладётся в runnext, а старая runnext уходит в кольцевой буфер.
2.5. Локальная очередь, переполнение
Заголовок раздела «2.5. Локальная очередь, переполнение»runq[256] — это кольцевой буфер фиксированного размера. Если новая горутина не помещается:
- P берёт половину своей local queue (128 шт.) + 1 новую горутину.
- Линкует их в связный список.
- Кладёт в global run queue (одной операцией, под глобальный lock).
Зачем 256? Эмпирическое значение для баланса между:
- Маленькая очередь → частые попадания в global (lock contention).
- Большая очередь → плохой work-stealing, длинные tail-latency.
2.6. Алгоритм findrunnable (упрощённо)
Заголовок раздела «2.6. Алгоритм findrunnable (упрощённо)»Когда P освобождается (текущая горутина блокировалась, завершилась, или была вытеснена), scheduler ищет следующую работу:
findrunnable(): 1. Если schedtick % 61 == 0: взять 1 из global run queue (анти-starvation) 2. Если есть runnext — взять её. 3. Если local queue не пуста — взять head. 4. Если global run queue не пуста — взять ~maxBatch (min(len/gomaxprocs+1, len/2)). 5. Если есть готовые netpoll events — забрать их (non-blocking poll). 6. Иначе — work stealing: for tries in 1..4: выбрать случайный другой P украсть половину его очереди (runqsteal) если ничего не украли — следующая попытка 7. Если ничего не нашли: проверить global ещё раз (под локом) проверить netpoll (теперь — blocking) если всё ещё ничего — park M (отдать P, спать)2.7. Work stealing: runqsteal
Заголовок раздела «2.7. Work stealing: runqsteal»Алгоритм runqsteal(p_target): 1. Снимок runqhead и runqtail (атомарно). 2. n = (tail - head) / 2 — берём половину. 3. Для атомарности используется CAS на runqhead жертвы: CAS(runqhead_target, h, h + n) Если CAS не прошёл — другой ворюга опередил, повторить. 4. Скопировать G ссылки в свою local queue. 5. ATSO принять runnext только в крайнем случае (если не нашли ничего другого) — runnext "приватнее".Зачем красть половину, а не одну? Чтобы амортизировать стоимость stealing (атомарные CAS на чужом P) — если украл 1, через 100 ns снова придёшь воровать.
2.8. Состояния горутины
Заголовок раздела «2.8. Состояния горутины»_Gidle — только создана, ещё не инициализирована_Grunnable — в run queue, ждёт выделения CPU_Grunning — выполняется на M (имеет P, runtime.curg = эта)_Gsyscall — в syscall (M отдал P)_Gwaiting — заблокирована (chan, mutex, IO, GC, sleep...)_Gdead — завершилась, в пуле g для переиспользования_Gcopystack — стек копируется (промежуточно)_Gpreempted — async-preempted (промежуточно перед _Gwaiting)Переходы:
go f() ─────────────► _Grunnable ──► _Grunning ──► _Gdead ▲ │ │ ├──► _Gwaiting (chan, mutex, GC) │ │ │ │ │ │ ready/notify │ │ ▼ │ └──► _Grunnable │ │ syscall finish │ ▲ └──── _Gsyscall ◄──── _Grunning (entered syscall)2.9. Syscall handoff
Заголовок раздела «2.9. Syscall handoff»Когда горутина вызывает блокирующий syscall (read, write, accept, etc.):
G calls syscall: 1. M переходит в _Psyscall (для P). 2. M отрывается от P (но физически держит указатель, чтобы быстро вернуться). 3. Если syscall короткий — M возвращается, забирает свой P обратно. 4. Если sysmon видит, что M в syscall > 10 µs: handoffp(p) — P отдаётся idle M (или создаётся новый M). 5. После syscall M пробует подцепить любой свободный P. Если нет — кладёт G в global run queue и паркуется.Это объясняет, почему runtime.NumGoroutine() может быть огромным (миллион), но GOMAXPROCS = 8 — горутины в syscall не занимают P.
2.10. Netpoll integration
Заголовок раздела «2.10. Netpoll integration» Network IO (read/write на socket):
syscall.Read(fd, buf): 1. Сначала пробуем неблокирующий read. 2. Если EAGAIN/EWOULDBLOCK (данных нет): - вызывается netpollblock(fd, mode) - G помещается в _Gwaiting - fd регистрируется в epoll (Linux) / kqueue (BSD/macOS) / IOCP (Win) - M освобождается (P может работать с другой G). 3. Где-то в scheduler: findrunnable → netpoll(timeout) → готовые FD's для каждого fd: G ready → _Grunnable → run queue.Это не «асинхронный код» в смысле async/await — у вас в коде синхронный conn.Read(...), но Go runtime под капотом превращает блокировку в M:N parking. Поэтому Go умеет держать миллион одновременных соединений на 8 потоках.
2.11. Preemption: cooperative и async
Заголовок раздела «2.11. Preemption: cooperative и async»До Go 1.14 — cooperative preemption
Заголовок раздела «До Go 1.14 — cooperative preemption»Preempt-чек был встроен только в пролог функции:
function prologue (упрощённо): CMP SP, g.stackguard0 ; сравниваем stack pointer JLS morestack ; если меньше — растим стек ИЛИ preemptКогда нужно вытеснить горутину, scheduler ставит g.stackguard0 = stackPreempt (специальное значение 0xfffffade). Тогда следующий вызов функции уйдёт в morestack, который проверит флаг и переключит контекст.
Проблема: что, если горутина крутится в плотном цикле без вызовов функций?
// До Go 1.14 — эта горутина "вешала" свой P до завершения цикла:func busy() { for i := 0; i < 1e10; i++ { // нет вызовов функций → нет preempt check → нет преrupted }}GC ждал, пока все горутины достигнут safe point (= prologue). Один такой цикл мог задержать GC на секунды.
Go 1.14+ — async preemption
Заголовок раздела «Go 1.14+ — async preemption»Решение: scheduler шлёт SIGURG в поток, который держит “плохую” горутину. Signal handler:
- Проверяет, что регистры в безопасной точке (не в середине atomic operation).
- Сохраняет состояние в
g.sched. - Кладёт G обратно в run queue, освобождает P.
Теперь даже бесконечные циклы вытесняются. Цена — крошечный overhead на signal handler.
// В Go 1.14+ это уже корректно вытесняется:go func() { for { /* tight loop */ }}()runtime.GC() // больше не зависаетКогда async preempt НЕ работает:
- Внутри cgo-вызова (Go runtime не управляет потоком).
- В критических секциях, где runtime запретил preempt (
mp.preemptoff != ""). - В нативном коде через
LockOSThread+ assembly.
2.12. Sysmon — system monitor
Заголовок раздела «2.12. Sysmon — system monitor»Sysmon — это специальная горутина без P, запускается при старте программы (go runtime.sysmon()), работает на отдельном M, делает периодические проверки (raw, без runtime locking).
Что делает:
retake(now)— ищет:- M в syscall > 20 µs → handoff P → отдать другому M.
- G running > 10 ms → async preempt (SIGURG).
- netpoll — если давно никто не делал netpoll, дёрнем сами (на случай idle CPUs).
- gcStart trigger — если давно не было GC и есть аллокации, запустить GC.
- finalizer goroutines — обработать finalizers.
- Forced GC — если давно не было GC (по таймеру, 2 минуты).
Sysmon динамически меняет частоту своих циклов от 20 µs до 10 ms (forcePreemptNS, forcegcperiod).
2.13. Spinning threads
Заголовок раздела «2.13. Spinning threads»Когда M не нашёл работу, есть выбор:
- Park — отдать P, заснуть в
futex_wait(Linux). Wake-up черезfutex_wakeстоит ~1–5 µs. - Spin — крутиться в цикле findrunnable, надеясь, что скоро появится работа.
Go держит до GOMAXPROCS/2 spinning threads одновременно. Логика:
- Если есть spinning M и появилась новая G → spinning M уже готов её взять (без latency на wake-up).
- Cost — CPU на спиннинге.
Это улучшает latency burst-нагрузок: после периода тишины первая G не ждёт wake-up M.
2.14. GOMAXPROCS
Заголовок раздела «2.14. GOMAXPROCS»runtime.GOMAXPROCS(n int) int // вернёт старое значение, поставит новоеПо умолчанию = runtime.NumCPU(). Влияет на:
- Количество P — параллелизм Go-кода.
- Размер шардирования внутренних структур (например,
mcacheкопий). - GC параллельность (по дефолту GC использует 25% CPU = GOMAXPROCS / 4 worker goroutines).
Очень важно для контейнеров: runtime.NumCPU() возвращает количество видимых ядер хоста, а не CPU limit контейнера. Если у вас в k8s pod cpu: "2", но хост 64-ядерный — Go запустит 64 P и будет жёстко страдать от:
- Лишних context-switches.
- Throttling (CPU period exhausted).
- GC backpressure (24 worker’а на 2 ядра — ад).
Решения:
- Установить вручную:
runtime.GOMAXPROCS(2)вmain(). - uber/automaxprocs: библиотека, читает
/sys/fs/cgroup/cpu.max(cgroup v2) илиcpu.cfs_quota_us/cpu.cfs_period_us(v1) и ставит GOMAXPROCS = quota / period (округлено вверх). - GOMAXPROCS=N через env в Deployment.
В Go 1.25+ runtime сам читает cgroup limits (см. CL в Go upstream); пока — automaxprocs.
import ( _ "go.uber.org/automaxprocs")Просто импорт; в init() библиотеки она сделает всё.
2.15. LockOSThread / UnlockOSThread
Заголовок раздела «2.15. LockOSThread / UnlockOSThread»runtime.LockOSThread() // эта G теперь привязана к текущему M намертвоruntime.UnlockOSThread() // отвязатьКогда нужно:
- CGO с TLS (thread-local storage в C): например, OpenGL контекст,
signalfd,setns(Linux namespaces). - Сигналы: обработка
signal.Notifyс конкретным потоком. - Native libraries, которые ждут от вас один и тот же поток.
Эффекты:
- G не может быть «украдена» другим P через work-stealing.
- M не вернётся в pool после завершения G (если M был создан только под локированную G).
- Если эта G блокируется (chan, mutex) — весь её M ждёт.
Антипаттерн: просто так лочить горутину к потоку. Это убивает M:N.
2.16. Производительность переключений
Заголовок раздела «2.16. Производительность переключений»Эмпирические замеры (Linux, x86-64, 2024):
| Операция | Время |
|---|---|
| Goroutine creation (go f()) | 80–250 ns |
| Goroutine switch (chan recv → send) | 150–300 ns |
| OS thread context switch (kernel) | 1–5 µs |
| Mutex lock/unlock (uncontended) | 15–40 ns |
| Mutex lock/unlock (contended) | 500 ns – 50 µs |
| Channel send/recv (буферизованный, без блокировки) | 50–100 ns |
Сравните: thread switch в 20–30 раз дороже goroutine switch. Поэтому в Go нормально иметь 100k+ горутин.
2.17. goroutine ID — почему скрыт
Заголовок раздела «2.17. goroutine ID — почему скрыт»// НЕТ runtime.GoID() публичного API.Причины:
- Анти-паттерн. Goroutine-local storage (как ThreadLocal в Java) ведёт к скрытому состоянию и багам.
- Корректность. ID может переиспользоваться после
_Gdead → _Grunnable(но сейчас Go этого не делает, на самом деле id монотонно растут). - Идиоматика. Передавайте контекст через параметры (например,
context.Context).
Если очень нужно (для логов или дебага), есть хак через runtime.Stack:
func goID() int64 { b := make([]byte, 64) n := runtime.Stack(b, false) s := string(b[:n]) // "goroutine 123 [running]:\n..." // парсим число между "goroutine " и " [" ...}Не делайте этого в hot path — runtime.Stack дорогая.
2.18. Анализ scheduler через trace
Заголовок раздела «2.18. Анализ scheduler через trace»# В программе:import "runtime/trace"f, _ := os.Create("trace.out")trace.Start(f)defer trace.Stop()// ... работа ...
# Или в http server:import _ "net/http/pprof"# curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
# Анализ:go tool trace trace.out# Откроется в браузере, увидите:# - Goroutine analysis# - Network blocking profile# - Synchronization blocking profile# - Syscall blocking profile# - Scheduler latency profileЧто искать:
- Scheduler latency — время между _Grunnable и _Grunning. Если > 1 ms — где-то starvation.
- Goroutine states — много _Gwaiting на одной мутаксе → contention.
- Syscalls — длинные syscall’ы → возможно, утечка в CGO.
3. Gotchas
Заголовок раздела «3. Gotchas»3.1. Tight loops до Go 1.14
Заголовок раздела «3.1. Tight loops до Go 1.14»// До Go 1.14: горутина зависала, GC не мог стартоватьfunc badPre114() { for { // нет вызовов функций - нет preemption }}Сейчас не проблема, но если код пишется для Go 1.14+, можно встретить runtime.Gosched() как «исторический» костыль:
for i := 0; i < N; i++ { work() if i%1000 == 0 { runtime.Gosched() // больше не нужно }}С Go 1.14+ runtime.Gosched() редко нужен — async preempt сделает то же.
3.2. CGO блокирует M
Заголовок раздела «3.2. CGO блокирует M»// CGO call:import "C"C.some_long_running_function()// На время C-вызова M не может быть преrupted, P не отдаётся быстро.Если CGO часто — настройте GOMAXPROCS больше + следите за cgo_calls в runtime.MemStats.
3.3. Один большой syscall блокирует много горутин (через handoff)
Заголовок раздела «3.3. Один большой syscall блокирует много горутин (через handoff)»Long syscall (file write на slow disk) → M в syscall → sysmon handoff P → новый M создан. Если у вас 10000 файловых горутин — будет 10000 OS-потоков (по 8 KB каждый = 80 MB).
Решение: пул горутин (worker pool с фиксированным размером) для файлового IO, или использовать io_uring (Go 1.23+ имеет экспериментальную поддержку через golang.org/x/sys).
3.4. NUMA effects
Заголовок раздела «3.4. NUMA effects»На многосокетных серверах P/M не привязаны к NUMA-узлам. Go runtime не оптимизирован под NUMA. Если у вас 2-сокет 64-ядерный сервер, иногда лучше:
- Запустить 2 процесса по 32 ядра с CPU pinning.
- Или один процесс, но с
GODEBUG=schedtrace=1000посмотреть, нет ли проблем.
3.5. Lock-free структуры в пользу channel
Заголовок раздела «3.5. Lock-free структуры в пользу channel»Channel — не панацея. Send/recv ≈ 100 ns + потенциальный goroutine switch. Для high-throughput иногда лучше:
sync/atomicдля счётчиков.sync.Poolдля горячих объектов.- Lock-free queue (например,
cespare/xxhash-style ring buffer).
3.6. runtime.Gosched и оверюз
Заголовок раздела «3.6. runtime.Gosched и оверюз»// Не делайте этого:for { runtime.Gosched() // принудительно yield на каждой итерации work()}Это убивает производительность — на каждой итерации полный round-trip через scheduler. Async preempt сделает то же раз в 10 ms.
3.7. LockOSThread не освобождает M
Заголовок раздела «3.7. LockOSThread не освобождает M»go func() { runtime.LockOSThread() // ... но мы забыли UnlockOSThread // M, привязанный к этой G, не вернётся в пул!}()Если такие горутины часто создаются — утечка потоков (видна в runtime.NumThread()).
4. Production-практики
Заголовок раздела «4. Production-практики»4.1. GOMAXPROCS в Kubernetes
Заголовок раздела «4.1. GOMAXPROCS в Kubernetes»# Bad (Go считает все ядра хоста):resources: limits: cpu: "2" requests: cpu: "1"# Без automaxprocs Go runtime увидит 64 ядра хоста.
# Good — вариант 1: envenv: - name: GOMAXPROCS value: "2"
# Good — вариант 2: automaxprocs (auto-detect cgroup limits)import _ "go.uber.org/automaxprocs"Без automaxprocs симптомы:
- p99 latency 200ms вместо 20ms (throttling).
- CPU usage 100% при нагрузке 50% от номинала (context switch overhead).
kubectl top podпоказывает throttle > 0.
4.2. Диагностика scheduler через GODEBUG
Заголовок раздела «4.2. Диагностика scheduler через GODEBUG»# Каждую секунду печатать stats scheduler:GODEBUG=schedtrace=1000 ./myapp
# Пример вывода:# SCHED 1000ms: gomaxprocs=8 idleprocs=2 threads=15 spinningthreads=1# idlethreads=0 runqueue=12 [3 5 0 2 0 0 1 1]
# Расшифровка:# gomaxprocs — N логических процессоров (P)# idleprocs — P без работы# threads — общее число M# spinningthreads — M в spinning state# idlethreads — M в park (idle pool)# runqueue — длина global run queue# [3 5 0 2 ...] — длина local run queue каждого PЕсли runqueue стабильно > 50 и idleprocs > 0 — где-то баг (горутины не попадают в P).
Если runqueue короткая, но [10 10 0 0 ...] — асимметрия нагрузки.
# Подробнее, каждое scheduling событие:GODEBUG=schedtrace=1000,scheddetail=1 ./myapp4.3. Профилирование scheduler latency
Заголовок раздела «4.3. Профилирование scheduler latency»# Через pprof:curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.pprofgo tool pprof -http=:8080 cpu.pprof# Ищите runtime.findrunnable, runtime.gopark, runtime.schedule
# Через trace:curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.outgo tool trace trace.out# Smart link: "Scheduler latency profile"4.4. Контроль количества горутин
Заголовок раздела «4.4. Контроль количества горутин»Метрики, которые надо собирать (Prometheus):
// /metrics endpointgoroutines.Set(float64(runtime.NumGoroutine()))threads.Set(float64(runtime.NumThread()))gomaxprocs.Set(float64(runtime.GOMAXPROCS(0)))Если NumGoroutine неограниченно растёт — leak. Типичные источники:
go func() {}()в цикле без context.- Forgotten chan recv (одна сторона не закрывает).
- Forgotten Wait Group.
4.5. Worker pool pattern
Заголовок раздела «4.5. Worker pool pattern»Вместо go work() на каждый запрос:
type pool struct { jobs chan func()}
func newPool(n int) *pool { p := &pool{jobs: make(chan func(), 1000)} for i := 0; i < n; i++ { go p.worker() } return p}
func (p *pool) worker() { for job := range p.jobs { job() }}
func (p *pool) Submit(job func()) { p.jobs <- job}Контролируем максимум горутин, избегаем «100k горутин в syscall на диск».
4.6. sync.Pool vs new
Заголовок раздела «4.6. sync.Pool vs new»Если у вас в горячем коде:
for { buf := make([]byte, 4096) use(buf)}Это аллокация → GC. Замените:
var pool = sync.Pool{ New: func() any { return make([]byte, 4096) },}
for { buf := pool.Get().([]byte) use(buf) pool.Put(buf)}sync.Pool per-P: каждый P держит свой кэш, без contention. После каждого GC pool очищается — это «дешёвый» кэш, не «долгое хранилище».
4.7. Pinning горутин к одному P (не идиоматично, но иногда нужно)
Заголовок раздела «4.7. Pinning горутин к одному P (не идиоматично, но иногда нужно)»Для CPU-affinity нет публичного API. Workaround:
runtime.LockOSThread()// Внутри C-кода вызвать sched_setaffinity (Linux):// или syscall.Syscall6(syscall.SYS_SCHED_SETAFFINITY, ...)Используется редко — например, в HFT, для low-latency network IO.
5. Вопросы на собесе
Заголовок раздела «5. Вопросы на собесе»- Объясни модель M:N в Go. Что такое G, M, P?
- Чем отличается P от M?
- Сколько горутин одновременно реально выполняются на CPU при GOMAXPROCS=4? (Не более 4.)
- Что такое local run queue, какой размер, что происходит при переполнении? (256, половина в global.)
- Опиши алгоритм work stealing. У кого крадут? Сколько крадут? (Случайный P, половину очереди.)
- Что такое global run queue и когда из неё берут? (Раз в 61 такт + при опустошении local.)
- Что такое netpoll? Как Go обрабатывает блокирующие сетевые вызовы?
- В чём разница между cooperative и async preemption?
- Почему до Go 1.14 tight loop без вызовов функций мог зависнуть?
- Что такое SIGURG и зачем он используется?
- Опиши состояния горутины (_Grunnable, _Grunning, _Gwaiting, _Gsyscall, _Gdead).
- Что происходит, когда горутина делает блокирующий syscall? (M handoffs P.)
- Что такое sysmon и какие задачи у него?
- Зачем нужен g0 в каждом M? (Системный стек для runtime-кода.)
- Какой начальный размер стека горутины и как он растёт? (2 KB, удваивается, через
morestack.) - Где runtime/runtime2.go хранит state регистров горутины при swap? (
g.sched gobuf.) - Что такое runnext и зачем он? (Приоритетный слот, локальность кэша после
go f().) - Почему в Go нет публичного goroutine ID?
- Чему по умолчанию равно GOMAXPROCS? (
runtime.NumCPU().) - Какие проблемы с GOMAXPROCS в Kubernetes без automaxprocs?
- Что делает runtime.LockOSThread и когда нужно?
- Что такое spinning thread и зачем оно?
- Сколько обычно spinning threads держит runtime? (До GOMAXPROCS/2.)
- Как посмотреть scheduler trace в проде? (
GODEBUG=schedtrace=1000.) - Как обнаружить leak горутин? (
/debug/pprof/goroutine, метрикиruntime.NumGoroutine.) - Чем отличается context switch горутины от OS-потока (по времени)? (~150 ns vs ~1–5 µs.)
- Что произойдёт, если CGO-вызов длится 1 минуту? (M блокирован, sysmon отдаст P, новый M создастся.)
- Зачем нужен
runtime.Gosched()и стоит ли его использовать сейчас? - Как async preemption влияет на atomic-операции? (Signal проверяет safe point.)
- Можно ли иметь больше M, чем GOMAXPROCS? Когда и почему? (Да, в syscall, LockOSThread, при handoff.)
6. Practice
Заголовок раздела «6. Practice»6.1. Демонстрация preemption (Go 1.14+)
Заголовок раздела «6.1. Демонстрация preemption (Go 1.14+)»package main
import ( "fmt" "runtime" "time")
func main() { runtime.GOMAXPROCS(1) // принудительно 1 P, чтобы было заметно
go func() { for { // tight loop, без вызовов функций } }()
time.Sleep(100 * time.Millisecond) runtime.GC() // в Go 1.13 это бы зависло fmt.Println("GC прошёл, async preemption работает!")}6.2. Демонстрация work stealing
Заголовок раздела «6.2. Демонстрация work stealing»package main
import ( "fmt" "runtime" "sync")
func main() { runtime.GOMAXPROCS(4) var wg sync.WaitGroup
// Запускаем 1000 горутин — каждая выполнится на любом P for i := 0; i < 1000; i++ { wg.Add(1) go func(id int) { defer wg.Done() // Какая-то работа sum := 0 for j := 0; j < 1e6; j++ { sum += j } _ = sum }(i) } wg.Wait() fmt.Println("done; нагрузка распределилась через work stealing")}Запустите с GODEBUG=schedtrace=100 и понаблюдайте, как local queue растут/уменьшаются на разных P.
6.3. Leak detection
Заголовок раздела «6.3. Leak detection»package main
import ( "fmt" "net/http" _ "net/http/pprof" "runtime" "time")
func main() { go http.ListenAndServe(":6060", nil)
// Намеренный leak: for i := 0; i < 1000; i++ { go func() { ch := make(chan struct{}) <-ch // блокировка навсегда }() }
for { fmt.Printf("goroutines: %d\n", runtime.NumGoroutine()) time.Sleep(time.Second) } // curl http://localhost:6060/debug/pprof/goroutine?debug=1 // увидите 1000 горутин в chan recv}6.4. Syscall handoff в действии
Заголовок раздела «6.4. Syscall handoff в действии»package main
import ( "fmt" "io" "os" "runtime" "sync")
func main() { runtime.GOMAXPROCS(2) var wg sync.WaitGroup
f, _ := os.Open("/dev/zero") // bytes навечно defer f.Close()
for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() buf := make([]byte, 1<<20) // 1 MB for j := 0; j < 10; j++ { io.ReadFull(f, buf) // syscall.Read } }() }
// В параллели наблюдаем: go func() { for { fmt.Printf("threads=%d goroutines=%d\n", runtime.NumGoroutine(), pprofNumThread()) } }()
wg.Wait()}
func pprofNumThread() int { // через runtime/debug.Stack() или /proc/self/status: Threads: return 0 // упрощённо}6.5. Worker pool с context
Заголовок раздела «6.5. Worker pool с context»package main
import ( "context" "fmt" "sync")
type Pool struct { jobs chan func() wg sync.WaitGroup}
func New(ctx context.Context, workers int) *Pool { p := &Pool{jobs: make(chan func(), workers*10)} for i := 0; i < workers; i++ { p.wg.Add(1) go p.worker(ctx) } return p}
func (p *Pool) worker(ctx context.Context) { defer p.wg.Done() for { select { case <-ctx.Done(): return case job, ok := <-p.jobs: if !ok { return } job() } }}
func (p *Pool) Submit(job func()) { p.jobs <- job}
func (p *Pool) Close() { close(p.jobs) p.wg.Wait()}
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel()
pool := New(ctx, 4) defer pool.Close()
for i := 0; i < 20; i++ { i := i pool.Submit(func() { fmt.Println("job", i) }) }}6.6. Эксперимент с automaxprocs
Заголовок раздела «6.6. Эксперимент с automaxprocs»package main
import ( "fmt" "runtime"
_ "go.uber.org/automaxprocs")
func main() { fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) fmt.Println("NumCPU:", runtime.NumCPU())}Запустите в Docker с лимитом --cpus=2:
docker run --rm --cpus=2 myimage# GOMAXPROCS: 2# NumCPU: <ядра хоста>Без automaxprocs GOMAXPROCS = NumCPU.
7. Источники
Заголовок раздела «7. Источники»- Go source code:
src/runtime/proc.go,runtime2.go,mgcmark.go— https://github.com/golang/go/tree/master/src/runtime - Dmitry Vyukov — Scalable Go Scheduler Design Doc (2012, базовая статья) — https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw
- Async preemption proposal (Austin Clements, Go 1.14): https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md
- Ardan Labs — Scheduling in Go (трилогия Bill Kennedy):
- Part I: OS Scheduler — https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html
- Part II: Go Scheduler — https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
- Part III: Concurrency — https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part3.html
- JBD — Goroutine stack: https://rakyll.org/scheduler/
- Madhav Jivrajani — A Deep Dive Into the Golang Scheduler (GopherCon UK 2024) — https://www.youtube.com/results?search_query=go+scheduler+deep+dive
- Uber automaxprocs: https://github.com/uber-go/automaxprocs
- The Go Memory Model: https://go.dev/ref/mem
- runtime package docs: https://pkg.go.dev/runtime
- Diagnostics guide: https://go.dev/doc/diagnostics