Go Scheduler: GMP полностью под капотом
Это материал для опытных разработчиков. Здесь мы лезем в
runtime/proc.go,runtime/runtime2.go,runtime/asm_amd64.sи разбираем, как Go scheduler реально работает на уровне структур и алгоритмов. На собеседованиях Middle 2 в Авито, Яндекс, Тинькофф и Озон вопросы по scheduler — это первое сито. Не “что такое горутина”, а “что лежит вg.atomicstatus, какие переходы между состояниями возможны, как происходит async preemption на сигнале SIGURG”. Если плаваешь — дальше про GC и аллокатор даже не спросят.
Содержание
Заголовок раздела «Содержание»- Базовая концепция GMP (для разогрева)
- Глубокое погружение: структуры G, M, P и алгоритмы планирования
- Подводные камни scheduler
- Производительность и реальные кейсы
- Вопросы на собесе Middle 2 (30+)
- Practice — задачи продвинутого уровня
- Источники
1. Базовая концепция (разогрев)
Заголовок раздела «1. Базовая концепция (разогрев)»GMP — это три сущности, на которых стоит весь scheduler:
- G (goroutine) — единица работы. Это не поток ОС, а лёгкая структура с собственным стеком (от 2 KB) и PC/SP.
- M (machine) — поток ОС (pthread на Linux). На M исполняется Go-код.
- P (processor) — логический процессор, “разрешение” исполнять Go-код. Количество P =
GOMAXPROCS. У каждого P — локальная очередь горутин.
Базовая инвариант: чтобы M исполнял Go-код, ему нужен P. Если P нет (например, P переотдали при syscall) — M не может крутить goroutines.
G ───┐G ───┼── local runq P ──┐G ───┘ │ ▼ M (поток ОС) ── исполняет GЭтой картинки достаточно, чтобы начать разговор. Но на Middle 2 от тебя ждут глубины: какие поля у G, как именно происходит steal, что делает sysmon, как async preemption работает на уровне сигналов. Поехали.
2. Глубокое погружение
Заголовок раздела «2. Глубокое погружение»2.1. Структура G (runtime/runtime2.go)
Заголовок раздела «2.1. Структура G (runtime/runtime2.go)»Реальная структура (упрощено, с ключевыми полями):
type g struct { // Stack: верхняя и нижняя границы стека горутины. stack stack // offset known to runtime/cgo stackguard0 uintptr // checked by morestack; для preemption ставят 0xfffffade stackguard1 uintptr // для C-стека
_panic *_panic // самый верхний panic _defer *_defer // самый верхний defer (если open-coded не сработал)
m *m // текущий M, если G выполняется (nil если в очереди) sched gobuf // сохранённый контекст (SP, PC, BP) для switchG
atomicstatus atomic.Uint32 // _Gidle / _Grunnable / _Grunning / _Gsyscall / ... goid uint64 schedlink guintptr // следующий G в очереди (intrusive linked list)
preempt bool // флаг для cooperative preemption preemptStop bool // транзит в _Gpreempted preemptShrink bool // shrinkstack при preempt
asyncSafePoint bool // безопасная точка для async preempt
timer *timer // timer для time.Sleep, runtime.timeSleep waitreason waitReason // причина _Gwaiting (chan recv, chan send, ...)
lockedm muintptr // если runtime.LockOSThread parentGoid uint64 // 1.23+ для goroutine tracing}Что особенно важно понимать:
atomicstatusменяется атомарно черезcasgstatus. Каждая транзиция проверяется. Например,_Grunning → _Gpreempted— это один из вариантов async preempt.sched gobuf— это сохранённое состояние регистров. Когда G ставится на паузу,sched.sp,sched.pc,sched.bpзапоминают, где она была. При резюме — восстанавливаются через ассемблер (runtime.gogo).schedlink— intrusive linked list для глобальной очереди. У локальной очереди P — массив, а не linked list (см. ниже).stackguard0— обычный guard для overflow. Но если scheduler хочет вытеснить горутину кооперативно, он ставитstackguard0 = 0xfffffade(stackPreempt). На следующем вызове функции (когда вставлен прологmorestackилиruntime.morestack_noctxt) сработает проверка, и горутина уйдёт вruntime.goschedguarded.
2.2. Структура M (runtime/runtime2.go)
Заголовок раздела «2.2. Структура M (runtime/runtime2.go)»type m struct { g0 *g // системная goroutine (с большим стеком, для runtime-кода) curg *g // текущая user goroutine (nil если M в idle) p puintptr // текущий P (nil если M без P) nextp puintptr // P, который M возьмёт после wakeup oldp puintptr // P до entersyscall
id int64 procid uint64 // OS thread id spinning bool // M ищет работу blocked bool // блокирован на notesleep park note // примитив "wait/notify" (futex на Linux)
locks int32 // счётчик preempt disable softint atomic.Int32// signal handling state
schedlink muintptr // ссылка в списке свободных M
lockedg guintptr // обратная связь к LockOSThread
thread uintptr // указатель на OS thread структуру}Несколько ключевых моментов:
g0— это особая goroutine для самого M. У неё большой стек (~8 KB на Linux), на ней работает runtime-код (scheduler, GC mark assist, syscall return). Когда ты в обычном Go-коде — ты наcurg, когда внутриmcall(fn)— переключаешься наg0. Все эти переходы — через ассемблер (runtime·mcall,runtime·systemstack).spinning— флаг “M активно ищет работу, не паркую его”. Их количество ограниченоGOMAXPROCS/2, чтобы не тратить CPU на бесконечный busy-wait.park— futex (на Linux). Когда M идёт спать (stopm), оно блокируется здесь. Wake — черезnotewakeup.
2.3. Структура P (runtime/runtime2.go)
Заголовок раздела «2.3. Структура P (runtime/runtime2.go)»type p struct { id int32 status uint32 // _Pidle / _Prunning / _Psyscall / _Pgcstop / _Pdead schedtick uint32 // инкрементится каждый schedule() syscalltick uint32 // инкрементится при exitsyscall
m muintptr // обратная связь к M (если P активна)
mcache *mcache // per-P allocator cache (см. файл про аллокатор) pcache pageCache // per-P page cache для крупных аллокаций
// Local run queue — массив фиксированного размера 256, lock-free через atomic runqhead uint32 // CAS-голова runqtail uint32 // CAS-хвост runq [256]guintptr runnext guintptr // приоритетный слот для "только что созданной G"
// GC sweep gcAssistTime int64 gcFractionalMarkTime int64 deferpool []*_defer // pool _defer структур deferpoolbuf [32]*_defer
timers []*timer // P-локальные timers (Go 1.14+) timersLock mutex
preempt bool}Ключевые детали:
- Local runq — массив 256. Не linked list. Доступ к одному концу (push/pop) — lock-free, через CAS на
runqhead/runqtail. Стилам приходится держать в голове, что хвост — это где P работает, голова — откуда воруют. runnext— это особый “приоритетный” слот. Когдаgo foo()создаёт новую G, она кладётся вrunnext, а не в очередь. Идея: “только что созданная G скорее всего связана с горячими данными в кэше M, дай ей шанс выполниться немедленно”. Еслиrunnextуже занят — старая там G сдвигается в локальную очередь.mcacheприцеплен к P, а не к M. Когда M теряет P (syscall), он теряет и доступ к mcache. Это даёт zero-contention при аллокации в нормальном Go-коде.
2.4. ASCII-схема жизненного цикла G
Заголовок раздела «2.4. ASCII-схема жизненного цикла G» go foo() │ ▼ ┌──────────┐ │ _Gidle │ (только что создана, ещё не инициализирована) └────┬─────┘ │ инициализация sched gobuf ▼ ┌──────────┐ ┌───────────────┐ ┌────────────│_Grunnable├──schedule()───►│ _Grunning │ │ └────┬─────┘ └───┬─────┬─────┘ │ ▲ │ │ │ ready() │ goschedguarded │ │ entersyscall │ через chan/sel/ │ preempt из sysmon │ ▼ │ mutex/timer │ │ ┌─────────────┐ │ │ │ │ _Gsyscall │ │ │ │ └──────┬──────┘ │ │ │ │ exitsyscall │ │ │ ▼ │ │ │ (попытка взять P) │ │ │ │ │ ▼ park() │ ┌────┴─────┐ ┌─────────────┐ └────────────┤ _Gwaiting├──◄────────────┤ (любой) │ └──────────┘ (chan/mutex/ └─────────────┘ netpoll/sleep) │ │ goexit ▼ ┌──────────┐ │ _Gdead │ └────┬─────┘ │ переиспользование (gfree) ▼ (попадает в пул свободных G)_Gpreempted — отдельное состояние, в которое горутина попадает при async preemption. Из него она возвращается в _Grunnable.
_Gcopystack — транзитное состояние, когда стек копируется (растёт или ужимается). В это время другие потоки не могут менять стек.
2.5. Local run queue: lock-free алгоритм
Заголовок раздела «2.5. Local run queue: lock-free алгоритм»Псевдокод runqput (положить G в локальную очередь P):
func runqput(pp *p, gp *g, next bool) { if next { // Положить в runnext, прежний жилец — в очередь. oldnext := pp.runnext for !pp.runnext.cas(oldnext, gp) { oldnext = pp.runnext } if oldnext == 0 { return } gp = oldnext.ptr() }
retry: h := atomic.LoadAcq(&pp.runqhead) // нагрузочный acquire-load t := pp.runqtail if t-h < uint32(len(pp.runq)) { pp.runq[t%uint32(len(pp.runq))].set(gp) atomic.StoreRel(&pp.runqtail, t+1) // release-store return } if runqputslow(pp, gp, h, t) { return } goto retry}Что важно:
runqheadиrunqtail— это счётчики, не индексы. Реальный индекс получается через% 256. Это позволяет различать “пустая” и “полная” очередь (когда head == tail vs head+256 == tail).- Acquire/release порядок памяти нужен, потому что чужой P читает наш
runqheadпри стиле. - Если очередь переполнена —
runqputslowотправляет половину локальной очереди + текущий G в глобальную очередь под глобальный lock. Это и есть момент, когдаsched.runqначинает расти.
2.6. Global run queue
Заголовок раздела «2.6. Global run queue»Структура (runtime/runtime2.go):
type schedt struct { ... lock mutex runq gQueue // intrusive linked list через g.schedlink runqsize int32 // длина очереди
midle muintptr // список свободных M nmidle int32
pidle puintptr // список свободных P npidle atomic.Int32
nmspinning atomic.Int32 // сколько M в spinning ...}Доступ — под sched.lock (это global mutex scheduler’а). Поэтому глобальная очередь — это медленный путь, и его стараются обходить:
runqputslowбьёт по нему когда местная переполнена.globrunqgetдостаёт оттуда раз в 61 schedule() — даже если local не пуста. Это сделано, чтобы голодающие в global runq G не висели вечно.
2.7. Work stealing: runqsteal
Заголовок раздела «2.7. Work stealing: runqsteal»Когда P не находит работу локально, она пытается украсть половину чужой очереди:
func findRunnable() *g { ... // Шаг steal: random P, потом по кругу. for i := 0; i < 4; i++ { for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() { p2 := allp[enum.position()] if p2 == thisP { continue } if gp := runqsteal(thisP, p2, stealRunNextG); gp != nil { return gp } } } ...}runqsteal:
func runqsteal(pp, p2 *p, stealRunNextG bool) *g { t := pp.runqtail n := runqgrab(p2, &pp.runq, t, stealRunNextG) if n == 0 { return nil } n-- gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr() atomic.StoreRel(&pp.runqtail, t+n) return gp}runqgrab через CAS на чужой runqhead достаёт половину очереди. Не одну G! Это важная оптимизация: меньше вероятность повторных steal-операций.
2.8. findrunnable: приоритет поиска
Заголовок раздела «2.8. findrunnable: приоритет поиска»1. Local runq → если есть G, берём.2. Global runq → проверка раз в 61 schedule() (даже если есть local).3. netpoll → не блокируемся, забираем готовые горутины из epoll/kqueue.4. Steal (4 раунда) → у других P.5. Global runq (повторно с lock).6. netpoll (блокирующий) → если совсем нечего делать, спим до сигнала из epoll.7. stopm() → паркуем M на note.2.9. Async preemption (Go 1.14+)
Заголовок раздела «2.9. Async preemption (Go 1.14+)»До 1.14 preemption была только кооперативной — горутина уходила, только когда вызывала функцию (через morestack-проверку). Это было плохо: горутина с for { x++ } могла повесить GC и других.
С 1.14 ввели async preemption через сигналы:
- Sysmon (см. ниже) видит, что G работает > 10ms на одной CPU.
- Sysmon вызывает
preemptone(P). preemptoneпосылает SIGURG в поток M, исполняющий эту G.- Signal handler
runtime.sighandlerпроверяет: можно ли сейчас вытеснить?- Проверка
asyncSafePoint: компилятор расставил safe-points (примерно каждый базовый блок без вызовов). - Если G сейчас в “неподходящем” месте (например, держит lock или внутри runtime-кода) — preempt отменяется, пробуем позже.
- Проверка
- Если можно — handler инжектит вызов
asyncPreemptв контекст G. asyncPreempt(вruntime/preempt_amd64.s) сохраняет регистры, делаетmcall(preemptPark), переводит G в_Gpreempted, возвращается в scheduler.
Почему именно SIGURG? Этот сигнал почти не используется в реальной жизни (TCP urgent data). Меньше шансов конфликта с приложением.
2.10. Cooperative preemption: morestack hook
Заголовок раздела «2.10. Cooperative preemption: morestack hook»Каждая Go-функция начинается с пролога (для функций с stack-эпизод более 96 байт):
TEXT main.foo(SB), $128-0 CMPQ SP, 16(g) // сравни SP с stackguard0 JLS morestack // если SP <= stackguard, прыжок в morestack ...Если scheduler хочет вытеснить, он ставит g.stackguard0 = stackPreempt (0xfffffade). Тогда любая функция, входящая в пролог, увидит “SP <= guard”, прыгнёт в morestack, а оттуда уже в goschedguarded. И вот горутина уходит в _Grunnable.
⚠️ Tight loop без вызовов функций не имеет морфостек-пролога → не вытесняется кооперативно. Поэтому async preemption и нужно.
2.11. Syscall handoff
Заголовок раздела «2.11. Syscall handoff»Когда горутина уходит в системный вызов (например, read из файла):
//go:nosplitfunc entersyscall() { save(getcallerpc(), getcallersp()) gp := getg() gp.m.locks++ gp.m.syscalltick = gp.m.p.ptr().syscalltick gp.m.p.ptr().syscalltick++ casgstatus(gp, _Grunning, _Gsyscall)
pp := gp.m.p.ptr() pp.m = 0 gp.m.oldp.set(pp) gp.m.p = 0 atomic.Store(&pp.status, _Psyscall)
gp.m.locks--}Ключевое: M отвязывается от P, но P не освобождается полностью. P остаётся в состоянии _Psyscall. Дальше два сценария:
- Если syscall быстрый: вернулся
exitsyscall, M пытается схватить тот жеoldp. Если P ещё в_Psyscall(никто не перехватил) — берём, восстанавливаемся в_Grunning. - Если syscall медленный: sysmon (раз в 10ms сканирует) видит, что
pp.syscalltickне менялся, и переотдаёт P другому M черезhandoffp(pp). P становится_Pidle, sysmon будит spinning M или создаёт нового. Когда наш медленный syscall вернётся — M пойдёт в slow pathexitsyscall0, в котором будет искать другой P или паркнётся.
2.12. netpoll: интеграция с epoll/kqueue/IOCP
Заголовок раздела «2.12. netpoll: интеграция с epoll/kqueue/IOCP»Go умеет асинхронно ждать сетевых событий, не блокируя M. Реализация:
- Linux: epoll (
netpoll_epoll.go) - Darwin/BSD: kqueue (
netpoll_kqueue.go) - Windows: IOCP (
netpoll_windows.go) - AIX/Solaris: pollset, polling-thread
Поток: когда горутина делает conn.Read():
- Системный вызов — non-blocking. Сразу возвращает
EAGAIN. - Go регистрирует fd в netpoll (epoll_ctl с EPOLLIN | EPOLLET).
- Горутина переходит в
_Gwaitingсwaitreason = waitReasonIOWait. - В
findrunnablenetpoller проверяется (без блокировки) — есть ли готовые fd? - Готовые fd → их pdg goroutines поднимаются в
_Grunnableи кладутся в очередь.
При полной idle (нечего делать никому) — netpoll вызывается блокирующе, чтобы поспать до прихода сетевого события.
2.13. Spinning M heuristic
Заголовок раздела «2.13. Spinning M heuristic»Состояние “spinning” — это пограничный режим: M не имеет работы, но активно ищет, а не спит. Зачем? Чтобы не платить за parking/unparking, когда нагрузка может прийти в любой момент.
Правила:
- Не больше
GOMAXPROCS/2spinning M одновременно. - Spinning M проверяет local runqs всех P через steal, проверяет global runq, проверяет netpoll.
- Если ничего нет за несколько раундов → переходит в
stopm()(parked).
Когда мы готовим работу (например, runqput), мы вызываем wakep(). Wakep смотрит: есть ли уже spinning M? Если есть — он сам найдёт. Если нет — стартуем нового spinning M (или будем кого-то из parked).
2.14. Sysmon goroutine
Заголовок раздела «2.14. Sysmon goroutine»Sysmon — это особая M без P. Он не исполняет user-код. Запускается из runtime.main через newm(sysmon, ...).
Что делает sysmon (runtime.sysmon):
func sysmon() { for { // 1. Адаптивный сон: 20µs → 10ms. delay := computeDelay() usleep(delay)
// 2. Проверка netpoll (если давно не проверяли). if last_poll + 10ms < now { netpoll(0) }
// 3. Retake P, которые залипли в syscall > 10ms. retake(now)
// 4. Preempt G, которые работают > 10ms. for _, pp := range allp { if pp.schedtick == oldtick && now > start + 10ms { preemptone(pp) } }
// 5. Триггер forced GC, если не было GC > 2 мин. if forcegcperiod < now - lastgc { forcegchelper() }
// 6. Scavenge освобождённой памяти в ОС. scavenger.wake() }}⚠️ Sysmon не имеет P. Поэтому он не может выполнять обычный Go-код, который аллоцирует через mcache. Все его действия — runtime-only.
2.15. runtime.LockOSThread / UnlockOSThread
Заголовок раздела «2.15. runtime.LockOSThread / UnlockOSThread»// LockOSThread: pins текущую G к текущему M.// Эта G не сможет выполняться на другом M.// Если M терминируется (через UnlockOSThread/goexit), M уходит в _Mdead.runtime.LockOSThread()defer runtime.UnlockOSThread()Зачем:
- CGO callback’и: некоторые C-библиотеки используют TLS (thread-local storage). Если G перепрыгнет на другой M — TLS будет чужой.
- OS-специфические thread state: namespaces (Linux setns), affinity (sched_setaffinity), некоторые GUI-фреймворки (Cocoa требует main thread).
- Profiling: чтобы измерения шли с одного потока.
⚠️ Если LockOSThread не сбалансирован UnlockOSThread → при завершении G весь M уничтожается. Несбалансированный runtime.LockOSThread в long-running серверe — утечка потоков.
2.16. Topology-aware scheduling — Go его не делает
Заголовок раздела «2.16. Topology-aware scheduling — Go его не делает»В отличие от Linux scheduler (CFS), Go scheduler не знает про NUMA, L3-cache topology, CCX-границы. Все P для него равноценны. Это упрощает реализацию, но на больших NUMA-серверах (2+ socket, AMD EPYC) межсокетные обращения через runqsteal могут стоить в разы дороже, чем локальные.
Что с этим делать:
- В Kubernetes используем
cpusetи pinning workloads на NUMA node. - Иногда полезно вручную ограничить
GOMAXPROCSчислом ядер на одном сокете.
2.17. automaxprocs (Uber) и контейнеры
Заголовок раздела «2.17. automaxprocs (Uber) и контейнеры»Проблема: Go читает GOMAXPROCS через runtime.NumCPU() → sched_getaffinity. На k8s pod-е с CFS-квотой 1000m (= 1 CPU) sched_getaffinity всё равно покажет все CPU на хосте! Получается, что Go создаёт 64 P, но CFS квотирует CPU-time = 1 CPU. Итог: бешеная конкуренция за CFS-bucket, throttling, ужасная latency.
Решение — пакет go.uber.org/automaxprocs. Он читает /sys/fs/cgroup/cpu/cpu.cfs_quota_us и cpu.cfs_period_us, считает реальный лимит, выставляет runtime.GOMAXPROCS().
С Go 1.25 это поведение по умолчанию в runtime: scheduler автоматически уважает CFS-лимиты (опция GOMAXPROCS=auto или просто без выставления — runtime сам определит).
2.18. GODEBUG=schedtrace=1000
Заголовок раздела «2.18. GODEBUG=schedtrace=1000»Выводит раз в 1000 мс строку вида:
SCHED 5034ms: gomaxprocs=8 idleprocs=2 threads=12 spinningthreads=1 needspinning=0 idlethreads=3 runqueue=4 [3 0 2 0 0 1 0 5]Расшифровка:
gomaxprocs=8— текущее GOMAXPROCS.idleprocs=2— сколько P в_Pidle.threads=12— всего создано M (включая sleeping).spinningthreads=1— сколько M в spinning.idlethreads=3— сколько M paused вstopm.runqueue=4— длина глобальной очереди.[3 0 2 0 0 1 0 5]— длины локальных очередей каждого P.
При GODEBUG=schedtrace=1000,scheddetail=1 добавляются детали по каждому P, M и G.
2.19. runtime.Stack
Заголовок раздела «2.19. runtime.Stack»buf := make([]byte, 1<<20)n := runtime.Stack(buf, true) // true = все горутиныos.Stderr.Write(buf[:n])Полезно для:
- Дампа горутин в SIGQUIT-handler (по умолчанию SIGQUIT уже триггерит дамп через
runtime.gopanic). - Отладочного middleware HTTP: дёргаешь
/debug/stack→ видишь, чем заняты все горутины. - Production debug, когда нужно понять, где висим (например, deadlock).
⚠️ runtime.Stack(buf, true) пауза-STW аналог: пока всё печатается, goroutines останавливаются. Не вызывай это в hot path.
3. Подводные камни scheduler
Заголовок раздела «3. Подводные камни scheduler»3.1. ⚠️ Tight loop без preemption
Заголовок раздела «3.1. ⚠️ Tight loop без preemption»go func() { for { x++ }}()До Go 1.14 это была “горутина-зомби”, вешающая GC (STW не мог собрать все safepoints). С 1.14+ async preempt лечит это, но всё равно есть случаи, когда preempt отказывается (например, держим lock).
3.2. ⚠️ runtime.Gosched ≠ preemption
Заголовок раздела «3.2. ⚠️ runtime.Gosched ≠ preemption»runtime.Gosched() кооперативно ставит текущую G в global runq и вызывает schedule(). Это добровольное “уступание”. Полезно, если знаешь, что у тебя tight loop, и хочешь дать шанс другим. Но это не magic preemption.
3.3. ⚠️ GOMAXPROCS=1 ≠ один поток
Заголовок раздела «3.3. ⚠️ GOMAXPROCS=1 ≠ один поток»GOMAXPROCS=1 означает один P, но M-ов может быть много (один для user-кода, другие для syscall, sysmon, GC). Если у тебя LockOSThread(), ты можешь “съесть” единственный P, и остальные горутины повиснут.
3.4. ⚠️ Spinning M съедает CPU
Заголовок раздела «3.4. ⚠️ Spinning M съедает CPU»При nmspinning > 0 сценарий: одна-две M в busy-loop сканируют все P. Это видно в profiles как runtime.findrunnable → высокое CPU. Не баг — фича. Но если RPS низкое и spinning постоянно тратит CPU без полезной работы, на embedded и energy-constrained системах это раздражает.
3.5. ⚠️ Sysmon при STW
Заголовок раздела «3.5. ⚠️ Sysmon при STW»При STW (полная остановка для mark termination или для коллбэка через stopTheWorld) sysmon тоже останавливается. Поэтому если в STW произойдёт syscall, который должен бы вернуться → P для него не подхватится. Но STW по дизайну короткий (десятки µs), так что это не проблема.
3.6. ⚠️ runtime.LockOSThread без UnlockOSThread
Заголовок раздела «3.6. ⚠️ runtime.LockOSThread без UnlockOSThread»go func() { runtime.LockOSThread() doWork() // забыли UnlockOSThread!}()После завершения goroutine — поток ОС уничтожается, а не возвращается в пул. Если такие горутины создаются часто — утечка потоков.
3.7. ⚠️ Создание G в hot path
Заголовок раздела «3.7. ⚠️ Создание G в hot path»go func() — это не free. Под капотом:
- Аллокация G из gfree pool (быстрая) или из heap (медленная).
- Аллокация 2 KB стека.
- Инициализация sched gobuf.
runqput.
Стоимость ~100-300 ns на amd64. Если у тебя 1M горутин/сек только на go-stmt — это серьёзная нагрузка.
Решение: pool of workers + chan jobs. Это переиспользует горутины.
3.8. ⚠️ Channel send/recv blocks с timer
Заголовок раздела «3.8. ⚠️ Channel send/recv blocks с timer»select {case x := <-ch: ...case <-time.After(1 * time.Second): ...}При каждом проходе создаётся timer. Под капотом — runtime.timeSleep, который добавляет элемент в timers heap текущего P. Если такой select в цикле — timers heap пухнет. Используй time.NewTimer и переиспользуй (с Reset).
3.9. ⚠️ Сетевой код блокирует P не сетью
Заголовок раздела «3.9. ⚠️ Сетевой код блокирует P не сетью»Если ты не делаешь SetReadDeadline на conn, и читаешь — горутина уйдёт в netpoll и спит бесконечно. Если такой висит, например, в gRPC handler без deadline — G уйдёт в _Gwaiting, P освободится. Но G останется в памяти, утечёт.
3.10. ⚠️ CFS throttling vs GOMAXPROCS
Заголовок раздела «3.10. ⚠️ CFS throttling vs GOMAXPROCS»Без automaxprocs: в k8s pod-е с cpu: "500m" Go видит 64 ядра, делает 64 P, и упирается в throttling. Симптом: P99 latency скачет в 5-10x.
3.11. ⚠️ runtime.Goexit пропускает defer?
Заголовок раздела «3.11. ⚠️ runtime.Goexit пропускает defer?»Нет, runtime.Goexit отрабатывает все defer’ы текущей G и потом уничтожает её. Но: если defer внутри recover’ит panic — panic исчезает. С Goexit recover невозможен.
3.12. ⚠️ G с LockOSThread блокирует exit процесса
Заголовок раздела «3.12. ⚠️ G с LockOSThread блокирует exit процесса»Если main горутина закончилась, но какие-то locked G ещё живы — runtime ждёт их? Нет, os.Exit (или return из main) не ждёт ничего. Но runtime.LockedOSThread G в “отцепленных” воркер-горутинах могут получить SIGSEGV при срабатывании финализаторов.
3.13. ⚠️ Asynchronous preempt vs Cgo
Заголовок раздела «3.13. ⚠️ Asynchronous preempt vs Cgo»При async preempt SIGURG сигнал летит в M. Если M сейчас в cgo (в C-коде), сигнал может прийти когда C-код ничего не ожидает. Go runtime защищается: если M в cgo, async preempt этого M не делается.
3.14. ⚠️ nm spinning слишком высок
Заголовок раздела «3.14. ⚠️ nm spinning слишком высок»Симптом: GODEBUG=schedtrace=1000 показывает spinningthreads=4, и CPU usage 100%. Возможные причины:
- Очень много мелких G, которые быстро создаются и завершаются (микро-тасковая нагрузка).
- Бесконечно фолбэк с netpoll/steal.
Лечение: профилируй с pprof.Profile("threadcreate") и runtime/trace.
3.15. ⚠️ runtime.NumGoroutine() ≠ “активных”
Заголовок раздела «3.15. ⚠️ runtime.NumGoroutine() ≠ “активных”»NumGoroutine считает всё, что не _Gdead. Включает _Gwaiting на mutex, на chan, на netpoll. Если у тебя их 100K — это не страшно, если они в waiting. Страшно, если в _Grunnable.
4. Производительность и реальные кейсы
Заголовок раздела «4. Производительность и реальные кейсы»4.1. Thundering herd при spinning
Заголовок раздела «4.1. Thundering herd при spinning»Кейс (Авито 2024): на сервисе с ~10K RPS заметили скачок latency в P99 после deploy. Профилировка показала, что runtime.findrunnable ест 8% CPU. Причина: после deploy включился новый кэширующий слой, который очень быстро отвечает (5 µs). Каждый запрос → горутина → быстрое завершение → новая. Spinning M не успевают паркнуться, очередь creation rate >> reuse rate.
Решение: worker pool с пуллом из 64 горутин, jobs через chan. Создание горутин упало в 1000 раз, P99 latency вернулась.
4.2. Sysmon paused при STW
Заголовок раздела «4.2. Sysmon paused при STW»Кейс (продукт на 50 GB heap): GC STW pause при mark start = 50 µs (норма), но раз в час видели всплески по 200 µs. Расследование показало: в этот момент sysmon как раз должен был retake P, но STW его остановил, и retake отложился. Дополнительные 150 µs — это латентность нескольких syscall’ов, для которых P не вовремя переотдан.
Решение: уменьшили heap до 30 GB (через GOGC tuning), всплесков не стало.
4.3. runtime.LockOSThread для GUI
Заголовок раздела «4.3. runtime.LockOSThread для GUI»Кейс (Fyne UI app): библиотека Fyne под GTK/Cocoa требует, чтобы вся GUI работа была на main thread. В Go это делается через runtime.LockOSThread() в main + остальные горутины общаются через chan.
Подводный камень: при runtime.GC() в main горутине, GC mark assist может убить latency UI. Решение: явный runtime.GC() в фоне раз в N секунд.
4.4. Goroutine leak detection
Заголовок раздела «4.4. Goroutine leak detection»Кейс (gRPC сервис): рост памяти на 50 MB/час. Профили говорили “горутины растут”. Через go tool pprof http://localhost:6060/debug/pprof/goroutine нашли 5K горутин в chan recv на одном и том же канале. Канал был внутри httpserver, но из-за неверной отмены контекста не закрывался. Каждый запрос плодил утёкшую G.
Решение: добавили defer close(ch) в обработчик.
4.5. Высокий GOMAXPROCS на bare-metal
Заголовок раздела «4.5. Высокий GOMAXPROCS на bare-metal»Кейс (NUMA 2 socket, 48 ядер каждый = 96): на сервере с GOMAXPROCS=96 наблюдали nonlinear scaling — RPS 80K, ожидали 200K. Причина: steal через socket boundary = ~300 ns на чтение чужой runqhead через QPI.
Решение: запустить два инстанса по 48 P, каждый на своём сокете через taskset --cpu-list 0-47. RPS вырос до 180K.
4.6. Tight-loop fuzzer
Заголовок раздела «4.6. Tight-loop fuzzer»Кейс: fuzzing-test делал for { fuzz() } без вызова Yield. До Go 1.14 это вешало GC. С 1.14 async preempt спасает, но: профилировка показала, что 10% времени уходит на handling SIGURG. Решение: вставить явный runtime.Gosched() каждые N итераций — overhead меньше.
4.7. automaxprocs спас deploy
Заголовок раздела «4.7. automaxprocs спас deploy»Кейс (продакшен в k8s): до automaxprocs — в pod-е с limit=2 cores Go видел 64 ядра, GOMAXPROCS=64. CFS throttling на 60% времени. После automaxprocs: GOMAXPROCS=2. CPU steal 0%, p99 -45%.
5. Вопросы на собесе Middle 2
Заголовок раздела «5. Вопросы на собесе Middle 2»Q1. Что такое G, M, P? Зачем разделение на M и P?
G — goroutine (структура с PC, SP, стеком). M — поток ОС (pthread). P — логический процессор (контекст для исполнения Go-кода: mcache, local runq, deferpool).
Разделение M и P даёт ключевую гибкость: при syscall M блокируется в ядре, но P может быть переотдан другому M. Без P было бы как в обычном thread-pool: один поток в syscall = один потерянный CPU.
Q2. Какие состояния может иметь G? Перечисли все.
_Gidle— только что создана, не инициализирована._Grunnable— в очереди, ждёт исполнения._Grunning— исполняется на M._Gsyscall— в системном вызове, M её исполняет, P отвязан._Gwaiting— заблокирована (chan, mutex, netpoll, sleep)._Gpreempted— async-preempted, ждёт повторной постановки._Gdead— завершилась, в пуле gfree._Gcopystack— стек копируется (рост/уменьшение)._Gscan(флаг к статусу) — GC сканирует стек.
Q3. Что такое local run queue и почему её размер 256?
Это массив длиной 256 в структуре P. Lock-free доступ через CAS на runqhead/runqtail. Размер 256 — компромисс: достаточно, чтобы редко переполняться (тогда идём в global), но не слишком много, чтобы не растягивать кэш-линии и steal не забирал слишком много за раз.
Q4. Что такое runnext и зачем он?
Это особый одиночный слот в P, в который попадает только что созданная G (через go foo()). Идея: cache locality — данные, с которыми работает родительская G, ещё горячие в L1/L2 кэше M, давай дадим дочерней G шанс исполниться немедленно.
При schedule() сначала проверяется runnext, потом локальная очередь.
Q5. Как работает runqsteal?
P без работы выбирает случайный другой P, через CAS на чужой runqhead забирает половину его локальной очереди. Половина — компромисс: одна — слишком частые steal-операции; всё — лишаем владельца работы.
Q6. Опиши findrunnable: в каком порядке ищем G?
- Локальная очередь P.
- Каждые 61 schedule() — глобальная очередь.
- Netpoll (non-blocking).
- Steal у других P (4 раунда).
- Глобальная очередь (повторно с lock).
- Блокирующий netpoll.
stopm()— паркуемся.
Q7. Что такое async preemption и зачем оно?
С Go 1.14 scheduler может вытеснить G без её согласия через SIGURG-сигнал. Зачем: tight loops без вызовов функций не могли быть вытеснены кооперативно, они вешали GC.
Q8. Почему именно SIGURG для preempt?
SIGURG почти не используется в реальной жизни (TCP urgent data давно мёртвая фича). Минимум конфликтов с приложением.
Q9. Где async preempt не сработает?
- Когда G держит lock (locked > 0).
- Внутри runtime-кода.
- В неподходящих местах (без asyncSafePoint).
- Внутри cgo вызова (C-код).
Q10. Что такое cooperative preemption?
Каждая функция начинается с пролога, который проверяет SP <= stackguard0. Если scheduler хочет вытеснить, он ставит stackguard0 = 0xfffffade. На следующем вызове функции пролог сработает, и G уйдёт в schedule.
Q11. Что делает entersyscall?
Сохраняет контекст, переводит G в _Gsyscall, отвязывает M от P (P переходит в _Psyscall). M остаётся исполнять syscall в ядре. P может быть подхвачен другим M, если syscall длинный.
Q12. Кто подхватывает P при медленном syscall?
Sysmon. Раз в ~20µs он сканирует все P. Если P в _Psyscall и не менялся syscalltick > 10ms — sysmon делает handoffp(P), переводит P в _Pidle, будит spinning M (или создаёт нового).
Q13. Что такое spinning M? Сколько их максимум?
Spinning M — это M, который не имеет работы, но активно ищет (вместо парковки). Максимум — GOMAXPROCS/2.
Зачем: парковка через futex стоит ~µs. При высокой нагрузке (работа постоянно появляется) spinning избегает этой стоимости.
Q14. Что делает sysmon?
Specialized M без P:
- Триггерит netpoll, если давно не проверяли.
- Retake P из медленных syscall.
- Preempt G, работающие > 10ms.
- Forced GC, если > 2 мин не было.
- Scavenge неиспользуемой памяти в ОС.
Q15. Зачем нужен runtime.LockOSThread?
Пиннит G к M. Используется в:
- Cgo callback’ах (TLS C-библиотек).
- OS-специфическом state (namespaces, affinity).
- GUI (Cocoa main thread requirement).
Q16. Что произойдёт, если в G после LockOSThread сделать UnlockOSThread в defer, но потом панику, которая поднимется наружу?
Defer отработает, UnlockOSThread сбросит lock. Дальше panic размотает стек и (если не recover) — fatal error.
Если же UnlockOSThread не было вызвано до завершения G — M, к которому G была прибита, уничтожается (не возвращается в пул). Утечка потока ОС.
Q17. Что показывает GODEBUG=schedtrace=1000?
Раз в секунду: gomaxprocs, idleprocs, threads, spinning, idle threads, длина global runq и длины local runq каждой P.
С scheddetail=1 — детали по каждой G/M/P.
Q18. Как Go ведёт себя в k8s контейнере с CPU-квотой?
По умолчанию (до Go 1.25 без automaxprocs) — Go видит все CPU хоста через sched_getaffinity, делает GOMAXPROCS = numCPU. CFS квотирует CPU-time → throttling, плохая latency.
С automaxprocs или Go 1.25+ — runtime читает cgroup лимиты, выставляет правильный GOMAXPROCS.
Q19. Что такое g0?
Системная goroutine для M. У неё большой стек (~8 KB). На ней исполняется runtime-код: scheduler, GC mark assist, syscall return. Переключение curg ↔ g0 — через ассемблер (mcall, systemstack).
Q20. Что произойдёт при go func() под капотом?
- Аллокация G из gfree пула P (или из heap).
- Аллокация стека 2 KB.
- Инициализация
g.sched(PC =runtime.goexit, SP = верх стека). runqput(p, g, true)— кладём в runnext.- Если есть spinning M, оно увидит на следующей итерации.
Стоимость ~100-300 ns.
Q21. Чем отличается local runq от global?
Local — массив 256, lock-free через CAS. Global — intrusive linked list под sched.lock mutex. Local быстрее, но в неё не помещаются все G — переполнение уходит в global.
Q22. Когда global runq проверяется?
Каждые 61 schedule() (даже если есть local). Это anti-starvation для G в global. Также проверяется в findrunnable, если steal не помог.
Q23. Что такое netpoll и как он работает?
Это интеграция Go с epoll/kqueue/IOCP. Когда сетевая операция возвращает EAGAIN, fd регистрируется в netpoll, G уходит в _Gwaiting. В findrunnable netpoll проверяется (non-blocking), готовые fd → G ставятся в runnable.
При полном idle netpoll вызывается блокирующе.
Q24. Что произойдёт при runtime.GOMAXPROCS(2) на работающем процессе с GOMAXPROCS=8?
Runtime сделает stopTheWorld, выставит P[2..7] в _Pdead, перенесёт их local runqs в global, рестартует мир с 2 P. G из дед-P будут подхвачены через global runq.
Q25. Чем отличается runtime.Gosched() от async preempt?
Gosched() — добровольное “уступание”: G кладётся в global runq, scheduler выбирает следующую. Async preempt — принудительное вытеснение через SIGURG.
Q26. Что произойдёт, если в горутине вызвать runtime.Goexit()?
Выполняются все defer’ы текущей G, потом G помечается _Gdead и возвращается в gfree. Recover в defer’ах не остановит Goexit. Если Goexit вызван в main goroutine — программа завершается (как os.Exit(0)).
Q27. Может ли горутина “переехать” с одного M на другой?
Да. Если G не залочена LockOSThread — она может уйти в _Grunnable и быть подхвачена другим M через любой P. Это происходит регулярно при preempt, syscall, chan-операциях.
Q28. Как scheduler знает, что G можно preempt безопасно?
Компилятор расставляет safe-points (примерно каждый базовый блок). В этих точках известны живые регистры и stack map для GC. При async preempt signal handler проверяет, что PC в asyncSafePoint, и только тогда инжектит preempt.
Q29. Что такое gfree pool?
Per-P pool завершённых G (в _Gdead). При go foo() сначала пытаемся взять из этого pool — экономим аллокацию структуры G и стека (если он не выбраковали). При переполнении уходим в sched.gfree.
Q30. Почему spinning ограничен GOMAXPROCS/2?
Spinning тратит CPU. Если бы все M спиннили — все CPU были бы загружены busy-loop’ом. GOMAXPROCS/2 — компромисс: половина M активно ищет работу, половина паркуется. На практике этого достаточно.
Q31. Что такое STW и сколько обычно длится в Go 1.22+?
Stop-the-world — пауза, когда все G остановлены. В Go 1.22+ есть две STW в GC: mark start (~10-50 µs) и mark termination (~10-100 µs). Sweep — concurrent. Также STW при runtime.GC(), runtime.GOMAXPROCS(), при regular stack dump.
Q32. Может ли sysmon вытеснить себя?
Нет, sysmon — особая M без P, она не идёт в runqs. Sysmon не препятствует preempt других M (она наоборот их триггерит).
Q33. Какие проблемы создаёт NUMA для Go scheduler?
Go не знает топологию. Steal через socket boundary стоит дороже (QPI/UPI). На больших серверах часто запускают несколько Go-инстансов с pinning к сокетам.
Q34. Почему runtime.NumGoroutine() показывает 10K, а CPU usage 5%?
Большинство G в _Gwaiting — ждут на chan, mutex, netpoll. Только G в _Grunning или _Grunnable потребляют CPU. 10K waiting G — нормально, если они ждут события.
Q35. Опиши, что делает entersyscall_sysmon?
Это путь, через который sysmon уведомляется о входе в syscall (помечает syscalltick). Сам entersyscall — быстрый, без syscall. Если syscall затянется, sysmon найдёт его при следующем сканировании.
6. Practice — задачи продвинутого уровня
Заголовок раздела «6. Practice — задачи продвинутого уровня»Задача 1. Минимальный воркер-пул
Заголовок раздела «Задача 1. Минимальный воркер-пул»Реализуй worker pool с фиксированным числом воркеров (например, runtime.NumCPU()). Воркеры получают job через chan, обрабатывают, отправляют результат. Поверх — graceful shutdown через context.
Ключевые точки: не создавать G на каждый job, переиспользовать воркеры, корректно завершать (close(jobs), wait worker).
Задача 2. Tight loop без preemption
Заголовок раздела «Задача 2. Tight loop без preemption»Напиши программу:
func main() { runtime.GOMAXPROCS(1) go func() { for {} }() time.Sleep(2 * time.Second) fmt.Println("done")}Объясни, почему программа печатает “done” в Go 1.14+ и не печатает в Go 1.13. Дай развёрнутый разбор async preemption.
Задача 3. LockOSThread + cgo
Заголовок раздела «Задача 3. LockOSThread + cgo»Напиши код, который через cgo вызывает функцию, которая записывает в pthread_setspecific (TLS). Покажи, что без LockOSThread TLS-значение может пропасть, если G переезжает на другой M.
Задача 4. Sysmon-исследование
Заголовок раздела «Задача 4. Sysmon-исследование»Запусти программу с GODEBUG=schedtrace=1000,scheddetail=1. Создай 1000 горутин, которые каждые 100 ms делают syscall (time.Sleep). Наблюдай за тем, как меняются P-counts и threads. Объясни, почему threads растёт выше GOMAXPROCS.
Задача 5. Worker pool с steal-эффектом
Заголовок раздела «Задача 5. Worker pool с steal-эффектом»Запусти бенчмарк: 10K горутин, каждая выполняет CPU-intensive работу (например, считает SHA-256 от 1 MB данных). Сравни с GOMAXPROCS=1, GOMAXPROCS=numCPU. Объясни, почему scale почти линейный (если задача чисто CPU-bound).
Задача 6. CFS throttling симуляция
Заголовок раздела «Задача 6. CFS throttling симуляция»Запусти Go-программу с runtime.NumCPU() = 8, но через cgroups (или taskset) ограничь CPU-квоту до 100ms/sec (= 0.1 CPU). Наблюдай рост latency. Подключи automaxprocs — наблюдай улучшение.
Задача 7. Профилирование scheduler
Заголовок раздела «Задача 7. Профилирование scheduler»Используй runtime/trace:
trace.Start(f)defer trace.Stop()// рабочая нагрузкаПосмотри trace в go tool trace. Найди:
- Spinning intervals.
- STW gaps.
- syscall handoffs.
Задача 8. Goroutine leak detection
Заголовок раздела «Задача 8. Goroutine leak detection»Напиши программу, которая создаёт G с висячими <-ch без отмены. Прогон через go tool pprof http://localhost:6060/debug/pprof/goroutine. Покажи, как стектрейсы локализуют утечку.
7. Источники
Заголовок раздела «7. Источники»src/runtime/proc.go— основной файл scheduler (~7000 строк). Читатьschedule(),findRunnable(),entersyscall(),exitsyscall(),sysmon().src/runtime/runtime2.go— структуры G, M, P, schedt.src/runtime/asm_amd64.s—runtime·gogo,runtime·mcall,runtime·systemstack,runtime·morestack.- Dmitry Vyukov, “Scalable Go Scheduler Design Doc” (2012) — оригинальный документ про work-stealing scheduler.
- Russ Cox, “Go Preemption Talk” (GopherCon 2020) — про async preemption.
- Austin Clements, “Go GC Pacer & Scheduler Integration” (GopherCon EU 2018).
- “Go’s work-stealing scheduler” — Jaana Dogan, Medium, 2017.
- “Scheduling in Go” — William Kennedy, Ardan Labs blog (3-part series).
- “What’s new in Go scheduler” — Go 1.14 release notes (async preempt), Go 1.21 (PGO), Go 1.25 (GOMAXPROCS=auto).
- “Дайджест scheduler” — Habr, статьи Avito и Ozon Engineering Blog (2024-2026).
- JBD’s “Goroutines and the OS scheduler” — talk на GopherConf.
- “go-scheduler-design-doc” в репозитории golang/go (
doc/articles/).