Перейти к содержимому

Go Runtime Scheduler: исходники, async preemption, netpoll, sysmon

Этот документ — экспертный разбор Go scheduler на уровне исходников src/runtime/. Уровень Middle 3 / Senior+: вы уже знаете G/M/P из 03-middle-2, теперь — переходы состояний G, алгоритм findrunnable(), async preemption через SIGURG, netpoll per-OS, sysmon-цикл. Ожидается умение читать runtime sources на собесе Авито/Яндекс staff+.

  1. Краткое введение (для разогрева)
  2. Глубочайшее погружение
    • 2.1. Состояния горутины и переходы
    • 2.2. Полный жизненный цикл G
    • 2.3. Локальные очереди: runqsteal, runqgrab
    • 2.4. Global run queue
    • 2.5. findrunnable() — алгоритм
    • 2.6. Async preemption (1.14+)
    • 2.7. Cooperative preemption
    • 2.8. Syscall handling
    • 2.9. Network poller (epoll/kqueue/IOCP)
    • 2.10. Futex и park/unpark
    • 2.11. Sysmon
    • 2.12. LockOSThread
    • 2.13. Goexit, Stack, Goroutine ID
  3. Подводные камни
  4. Реальные production-кейсы
  5. Вопросы на собесе Middle 3
  6. Practice
  7. Источники

Go scheduler — это user-space M:N scheduler. Он мультиплексирует G (горутины) поверх M (OS threads), используя P (logical processors) как handle для CPU. Реализация — в src/runtime/proc.go (~5000 строк) + runtime2.go (структуры) + per-OS файлы (os_linux.go, os_darwin.go и т.д.).

На Middle 3 ожидается, что вы:

  • Знаете все статусы G (_Grunnable, _Grunning, _Gwaiting, _Gsyscall, _Gdead, _Gcopystack).
  • Понимаете, что P держит локальную очередь из 256 G + global run queue.
  • Знаете, как async preemption работает на Linux через SIGURG.
  • Понимаете netpoll и почему network IO не блокирует M.
  • Можете прочитать stack trace панического дампа и связать его с G state.

Ключевые файлы:

ФайлСодержание
runtime/runtime2.goСтруктуры G, M, P, sched (schedt)
runtime/proc.goScheduler core: schedule, findrunnable, …
runtime/preempt.goAsync preemption
runtime/lock_futex.go (linux)Mutex/sema через futex
runtime/netpoll.goGeneric netpoll
runtime/netpoll_epoll.goLinux epoll
runtime/netpoll_kqueue.gomacOS/BSD kqueue
runtime/netpoll_iocp.goWindows IOCP
runtime/asm_amd64.sgogo, gosave, mcall, systemstack, …
runtime/signal_unix.goSignal handler

В runtime2.go определены константы:

// Goroutine status (поле g.atomicstatus).
const (
_Gidle = 0 // newly allocated, not initialized
_Grunnable = 1 // on run queue, waiting to run
_Grunning = 2 // executing on an M
_Gsyscall = 3 // executing a syscall, owns no P
_Gwaiting = 4 // blocked: channel, mutex, gc, ...
_Gmoribund_unused = 5 // (зарезервирован, не используется)
_Gdead = 6 // unused (in gFree list) or freshly exited
_Genqueue_unused = 7
_Gcopystack = 8 // stack being copied (grow/shrink)
_Gpreempted = 9 // preempted (Go 1.14+), waiting to resume
_Gscan = 0x1000 // OR'ed with above during GC scan
)

Граф переходов:

newproc()
┌─────────┐
│ _Gidle │
└─────────┘
│ (init done)
┌──────────────┐ schedule() pick
│ _Grunnable │◄────────────────────────────┐
└──────────────┘ │
│ gogo() │
▼ │
┌──────────────┐ │
│ _Grunning │ │
└──────────────┘ │
│ │ │ │ │
│ │ │ │ Gosched/preempt │
│ │ │ └──────────────────┘
│ │ │
│ │ │ gopark() (chan/mu/select/IO)
│ │ ▼
│ │ ┌──────────────┐ goready()
│ │ │ _Gwaiting │ ────────────► (back to _Grunnable)
│ │ └──────────────┘
│ │
│ │ entersyscall()
│ ▼
│ ┌──────────────┐ exitsyscall()
│ │ _Gsyscall │ ──────────► (back to _Grunnable)
│ └──────────────┘
│ goexit1() (function returned)
┌──────────────┐
│ _Gdead │ → gFree list (recycled)
└──────────────┘
Async preemption (1.14+):
_Grunning → SIGURG → _Gpreempted → _Grunnable

Поле g.atomicstatus обновляется через casgstatus (compare-and-swap) — это важно: переходы атомарны, иначе GC scan увидит inconsistent state.

Создание: newproc(siz int32, fn *funcval) в proc.go:

func newproc(fn *funcval) {
gp := getg()
pc := getcallerpc()
systemstack(func() {
newg := newproc1(fn, gp, pc, false, waitReasonZero)
pp := getg().m.p.ptr()
runqput(pp, newg, true) // в local queue
if mainStarted { wakep() } // probabilistically wake an idle P
})
}

Что происходит:

  1. newproc1 берёт G из gFree-листа (или аллоцирует новую).
  2. Стек выделяется минимум 2KB (_StackMin), copy-on-grow.
  3. PC устанавливается на goexit + 1 (так что после return G попадает в goexit1).
  4. G кладётся в local run queue (если переполнен — половина уходит в global).

Запуск: в schedule()execute(gp, ...)gogo(&gp.sched) (asm). gogo восстанавливает регистры из g.sched (SP, PC, BP) и делает RET.

Блокирование: gopark(unlockf, lock, reason, traceReason, traceskip):

  • Меняет статус G в _Gwaiting.
  • Вызывает unlockf (например, ставит G в waitq канала).
  • Вызывает mcall(park_m) — переключается на g0 (системный стек) и идёт в schedule().

Пробуждение: goready(gp, traceskip):

  • Через mcall переключается на g0.
  • casgstatus(gp, _Gwaiting, _Grunnable).
  • runqput(pp, gp, true) — в local queue текущей P.
  • wakep() если есть spinning Ms.

Выход: функция G возвращается → её PC = goexit + 1goexit1()mcall(goexit0):

  • casgstatus(_Grunning → _Gdead).
  • Очищает поля G (gcfn, _panic, _defer).
  • Кладёт G в m.p.gFree (per-P pool); если переполнен → в sched.gFree.stack.

Каждый P имеет fixed-size ring buffer на 256 элементов:

type p struct {
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // priority slot (LIFO)
// ...
}

runnext — особый «one-slot LIFO». При runqput(g):

  • Если runnext пуст → положили туда.
  • Иначе → текущий runnext смещается в runq (FIFO), а g — в runnext.

Зачем? Producer-consumer locality: только что созданная горутина с большой вероятностью продолжит работу того же актора (handler → goroutine для handler). Pop из runnext — без атомиков.

runqsteal(p2, p1, stealRunNextG bool) в proc.go:

  • Атомарно «крадёт» половину очереди другого P.
  • Использует CAS на p1.runqhead, чтобы не race с самим P1, который может pop из своей head.
  • Возвращает украденную G; остальные копирует в p2.runq.
// Псевдокод runqsteal:
func runqsteal(p2 *p, p1 *p, stealRunNext bool) *g {
t := p2.runqtail
n := runqgrab(p1, &p2.runq, t, stealRunNext)
if n == 0 { return nil }
n--
gp := p2.runq[(t+n)%256].ptr() // последняя украденная — return
if n == 0 { return gp }
atomic.StoreRel(&p2.runqtail, t+n)
return gp
}

runqgrab(p, batch *[256]gptr, batchHead uint32, stealRunNext bool):

  • Читает runqhead, runqtail атомарно.
  • Считает n = (tail - head) / 2.
  • CAS-update head: head += n.
  • Если CAS не прошёл — retry.

⚠️ Это lock-free, но не wait-free: возможны retries при contention. На современных CPU при N≤32 cores почти всегда успех с первого раза.

sched.runq — глобальная FIFO очередь, защищённая sched.lock. Туда G попадают:

  • При переполнении локальной очереди (половина переезжает в global).
  • При создании G через runtime.GOMAXPROCS(0) (нет local P).
  • При network poll: woken G кладутся туда, если local заполнен.

Глобальная очередь читается батчами: globrunqget(p, max) забирает min(max, len(global)/GOMAXPROCS) элементов. Это снижает contention — каждый P берёт пропорциональную часть.

Каждые 61 тиков (заданный «магический» интервал, см. proc.go):

if pp.schedtick%61 == 0 && sched.runqsize > 0 {
// Раз в 61 итераций — обязательно проверь global queue,
// чтобы избежать starvation глобальных горутин.
gp = globrunqget(pp, 1)
}

Самая большая функция в scheduler: ~300 строк в proc.go. Приоритеты поиска G:

findrunnable():
1. GC mark worker? (если GC активен, и P назначен mark worker → берём)
2. Trace reader G? (если есть)
3. Local run queue — есть G? → pop, return.
4. Global run queue — есть G? → globrunqget, return.
5. Netpoll (non-blocking) — есть готовые? → return one, put rest in local.
6. Steal from other P:
- 4 итерации попыток (steal attempts).
- Случайный shuffle списка P (не последовательно).
- На последней итерации — steal даже runnext.
- Если украли — return.
7. Если есть GC idle work — выполнить.
8. Global queue (retry) — последний шанс.
9. Netpoll BLOCKING — если все Ms idle и нет GC, ждём в epoll_wait.
10. Если ничего нет — stopm() (M идёт sleep на семафоре).

Spinning Ms: часть M «крутится» (spin) — ищут работу без сна. Лимит — GOMAXPROCS/2. Зачем? Чтобы при появлении новой работы сразу её взять (без cost wakeup’а). Цена — CPU. Trade-off контролируется в proc.go:

const _NoSpinning = false
// spinning M — атомарный счётчик sched.nmspinning

При goready для G компилятор/runtime пытается wakep():

  • Если есть spinning M — он сам найдёт работу.
  • Иначе — startm(_p_, true): либо resume sleeping M, либо создать новую.

До 1.14 Go использовал только cooperative preemption (stack check в прологе функции). Проблема: tight loop без вызовов функций → G не может быть прервана → STW GC не может стартовать.

Решение (1.14, Austin Clements):

  • sysmon или GC посылают SIGURG конкретному M.
  • Signal handler sigtrampgodoSigPreempt(gp, ctxt).
  • Проверяется, что G в безопасной точке (есть funcdata для безопасной паузы).
  • Если safe — модифицируется mcontext: PC меняется на asyncPreempt, оригинальный PC сохраняется в стеке.
  • При возврате из signal handler M исполняет asyncPreempt (asm-функция в runtime/preempt_amd64.s).
  • asyncPreempt сохраняет ВСЕ регистры на стек (включая XMM), вызывает asyncPreempt2.
  • asyncPreempt2 ставит G в _Gpreempted, вызывает goyield/gopreempt_m.

SIGURG, потому что это редко используемый сигнал (out-of-band TCP data) — низкий риск конфликта с user code. Если в программе вы устанавливаете handler на SIGURG — это конфликтует с runtime, может привести к hang. Замена через os/signal.Notify(c, syscall.SIGURG) тоже опасна.

┌─────────────────────────────────────────────────────────┐
│ Async preemption flow: │
│ │
│ sysmon decides "G runs > 10ms, preempt!" │
│ │ │
│ ▼ │
│ preemptone(_p_) → signalM(m, sigPreempt=SIGURG) │
│ │ │
│ ▼ │
│ M receives SIGURG → sigtramp (asm) → sigtrampgo │
│ │ │
│ ▼ │
│ doSigPreempt: │
│ - check isAsyncSafePoint(gp, pc) │
│ - mcontext.PC = &asyncPreempt │
│ │ │
│ ▼ │
│ signal returns → CPU jumps to asyncPreempt │
│ │ │
│ ▼ │
│ asyncPreempt (asm): │
│ - PUSH all registers │
│ - CALL asyncPreempt2 │
│ - POP registers │
│ - RET (resumes original PC) │
│ │ │
│ ▼ │
│ asyncPreempt2: │
│ - casgstatus(_Grunning → _Gpreempted) │
│ - gopreempt_m(gp) → mcall(park_m) → schedule() │
└─────────────────────────────────────────────────────────┘

Safe-points — это места, где можно безопасно остановить G:

  • Function call boundaries (после CALL).
  • После loop body (на 1.21+ — insert resched checks pass вставляет).
  • Перед conservative-scan регионами.

Если PC в небезопасной зоне (например, в середине inline-asm) — preempt пропускается, retry позже.

Старый механизм, всё ещё работает. В прологе каждой функции (не //go:nosplit):

TEXT foo(SB),..., $48-0
MOVQ (TLS), CX // load g
CMPQ SP, 16(CX) // SP < stackguard?
JLS morestack // stack growth + preempt check
...

Если g.stackguard0 = stackPreempt (особое значение), JLS будет true даже при достаточном стеке → попадаем в morestacknewstack → проверяется preempt-флаг → если стоит, делаем preempt.

stackPreempt = 0xfffffffffffffade — magic value. preemptone() устанавливает его в g.stackguard0, далее в следующем prologue будет преэмпт.

runtime.Gosched() — добровольный yield: mcall(gosched_m) → ставит G в global runq (НЕ local!) → schedule().

entersyscall():

  1. Сохраняет PC, SP в g.sched.
  2. m.locks++ (запрет preempt пока в syscall transition).
  3. pp := m.p.ptr(); pp.m = 0; g.m.p = 0M отвязывается от P.
  4. casgstatus(_Grunning → _Gsyscall).
  5. pp.status = _Psyscall — P в специальном статусе.
  6. P остаётся прикреплён к M (но не используется для schedule).

Теперь M может выполнить блокирующий syscall (read, write, …). P свободен логически, но физически ещё держит данные. Если sysmon заметит, что syscall длится >10us (forcePreemptNS/handoff timing), он сделает handoff:

// sysmon ретейк (retake):
if s := pp.status; s == _Psyscall && (sysmontick - pp.syscalltick > N) {
handoffp(pp) // P отдаётся другому M
}

handoffp(pp)startm(pp, false): либо новая M создаётся, либо разбуживается idle M. Эта M начинает schedule() с этой P.

exitsyscall() — когда syscall завершился:

  1. Быстрый путь: попытаться вернуть исходную P (exitsyscallfast).
    • Если оригинальная P свободна → CAS pp.status (_Psyscall → _Pidle) → success → берём её.
  2. Медленный путь: P уже забрана другим M → ищем любую свободную P → если есть, занимаем.
  3. Если нет — mcall(exitsyscall0):
    • G ставится в global queue.
    • M идёт в stopm() (sleep).

⚠️ Это означает: goroutine после длинного syscall может потерять P и продолжить на другой M. Если вы зависите от LockOSThread — об этом надо помнить.

runtime/netpoll.go — generic interface, который имплементируется per-OS. Цель — non-blocking IO без потери P.

API:

  • netpollinit() — создать epoll/kqueue/iocp instance.
  • netpollopen(fd, pd) — зарегистрировать fd.
  • netpollclose(fd) — удалить.
  • netpoll(delay) — poll готовых fd, возвращает список G на пробуждение.
  • netpollBreak() — разбудить netpoll wait (для своевременного выхода).

Per-OS:

OSБэкендФайл
Linuxepoll (level-trig.)netpoll_epoll.go
macOS/BSDkqueuenetpoll_kqueue.go
WindowsIOCPnetpoll_iocp.go
Solarisevent portsnetpoll_solaris.go
AIXpollnetpoll_aix.go
WASM(нет — fake)netpoll_fake.go

Жизненный цикл сетевого IO в Go:

conn.Read(buf):
internal/poll.FD.Read(buf):
1. syscall.Read(fd, buf)
2. err = EAGAIN? → blocked
3. fd.pd.waitRead() → gopark(reason=netpollBlock)
▼ G паркуется, M идёт schedule next
4. (sysmon/findrunnable calls netpoll(0))
│ epoll_wait returns fd ready
5. netpollready(toRun, fd, mode) → gp кладётся в local/global queue
6. goready(gp) → resume Read → repeat from step 1

Edge-vs-level trigger: Go использует edge-triggered epoll (с 1.13). Зачем? — меньше syscalls (не нужно reregister после каждого event), но требуется аккуратность с partial reads (читать до EAGAIN). Go runtime гарантирует — пока FD готов, читать.

netpollBreak: при остановке program/timeout/STW нужно разбудить thread, висящий в epoll_wait. Делается через дополнительный pipe/eventfd, в который пишется 1 байт.

На Linux runtime использует futex (fast userspace mutex) для blocking primitives:

  • futexsleep(addr, val, ns) — atomic check *addr == val, если да — sleep.
  • futexwakeup(addr, count) — разбудить count waiters на addr.

runtime/lock_futex.go (Linux/FreeBSD/Dragonfly):

// mutex implementation (very simplified):
func lock2(l *mutex) {
for {
v := atomic.Xchg(&l.key, mutex_locked)
if v == mutex_unlocked { return }
// contention
atomic.Xchg(&l.key, mutex_sleeping)
futexsleep(&l.key, mutex_sleeping, -1)
}
}
func unlock2(l *mutex) {
v := atomic.Xchg(&l.key, mutex_unlocked)
if v == mutex_sleeping {
futexwakeup(&l.key, 1)
}
}

Важно: это runtime internal mutex (runtime.mutex), не sync.Mutex. sync.Mutex реализован поверх runtime.semaphore (semacquire/semrelease), которая в свою очередь использует sudog’и.

gopark/goready — это G-level park (НЕ M-level). gopark НЕ блокирует M, M продолжает schedule других G. Только когда findrunnable ничего не нашёл, M вызывает stopm()notesleep(&m.park) → futex sleep.

sysmon — единственная горутина, которая не имеет P и работает на собственной M. Запускается из main() runtime через newm(sysmon, nil, -1).

Цикл sysmon (proc.go, функция sysmon):

for {
// sleep с адаптивным delay (20us → 10ms, в зависимости от нагрузки)
usleep(delay)
// 1. netpoll если давно не вызывался
if lastpoll + pollLimit < now {
list := netpoll(0) // non-blocking
injectglist(&list)
}
// 2. Retake P от long-running syscalls
retake(now)
// 3. Force preemption: горутины, выполняющиеся > 10ms, преэмптируем
if forcegcperiod && lastgc + forcegcperiod < now {
// force GC
forcegchelper()
}
// 4. Scavenge: возврат памяти OS (HEAP_RELEASED)
scavengeOne(forced=true)
}

retake(now) — самая интересная часть:

  • Проходит по всем P.
  • Если pp.status == _Psyscall и syscall длится > forcePreemptNS (10ms by default) → handoffp(pp).
  • Если pp.status == _Prunning и G выполняется > 10ms → preemptone(pp) (SIGURG).

Why 10ms? Это компромисс между:

  • Слишком частые preempt’ы → накладные расходы на signal handler.
  • Слишком редкие → GC stuck, latency growth.

С 1.24 этот таймаут конфигурируется через GODEBUG=schedmaxcpu=

runtime.LockOSThread() гарантирует, что текущая G и текущий M связаны 1:1 до UnlockOSThread().

Зачем:

  • Cgo callbacks: некоторые C-API ожидают одного thread (OpenGL, GUI loops).
  • Signal handlers: для специфичных pthread_sigmask.
  • TLS-based libs: thread-local storage в C-libs.
  • Linux kernel: unshare(), setns(), setuid() — на 1 thread.

Поведение:

  • M отвязывается от P только если G блокируется на syscall.
  • Когда G выходит (или UnlockOSThread), M возвращается в пул.
  • Если G вызывает gopark (waiting на chan), M идёт schedule() — другие G могут выполниться, но НЕ те, что хотели lock thread сами.

⚠️ Если G с LockOSThread зависает в syscall → M «потерян» (не возвращается в пул). Pool of Ms ограничен (GOMAXTHREADS=10000 по умолчанию). Утечка thread’ов = OS resource leak.

runtime.LockOSThread — вложенный счётчик. 5 вызовов = 5 нужно UnlockOSThread.

  • runtime.Goexit() — terminate G, но запустить все deferred функции. НЕ panic. Возврат не происходит (функция «бесконечна»).
  • runtime.Stack(buf, all) — записать stack trace текущей (или всех) G в buf. all=true → STW! Не использовать в hot path.
  • Goroutine ID — сознательно скрыт. Причина: чтобы разработчики не привязывали состояние к ID (как pthread_self() в C). Можно достать через debug pkg + reflect, или хак с runtime.Stack (парсинг строки goroutine 42 [running]). В prod — НИКОГДА.
// Хак (только debug):
func gid() uint64 {
b := make([]byte, 64)
b = b[:runtime.Stack(b, false)]
b = bytes.TrimPrefix(b, []byte("goroutine "))
b = b[:bytes.IndexByte(b, ' ')]
n, _ := strconv.ParseUint(string(b), 10, 64)
return n
}

  1. Async preemption ломает signal.Notify(SIGURG) в user-коде. Если регистрируете SIGURG — конфликт с runtime, возможны hangs.

  2. runtime.LockOSThread + os.Exit в другой goroutine = thread остаётся, deferred не запустится. Используйте runtime.Goexit если хотите cleanup.

  3. Длинный syscall с CGO: M отвязывается от P, но если CGO-call зависает — M потерян навсегда. Defensive timeout важен.

  4. runtime.Gosched() кладёт G в GLOBAL queue, не local. После Gosched ваша G может «потерять» P-локальность (cache misses).

  5. runtime.GOMAXPROCS(0) возвращает текущее значение, не меняет. Чтобы получить количество ядер: runtime.NumCPU(). С 1.25 GOMAXPROCS читается из cgroup, и runtime.NumCPU() может вернуть ≠ от GOMAXPROCS.

  6. gopark без unlockf — bug. Если ничего не делает G readyable, она зависнет навсегда. В runtime все вызовы gopark идут с разумным callback.

  7. Spinning Ms жрут CPU — на маленьких VPS (1 core) Go scheduler может уйти в spin loop. Решение: GOMAXPROCS=1 + проверить.

  8. runtime.SetFinalizer запускает finalizer в отдельной G, и эта G имеет приоритет ниже обычных. В нагруженной системе finalizer может запускаться с задержками.

  9. Stack growth → копирование стека. Все указатели на стек обновляются (компилятор знает offsets). Если у вас в asm указатель на stack-local — сломается. Используйте g0 (системный стек) для критики.

  10. runtime.Stack(buf, true) = STW. Все G ставятся на паузу. В production использовать только в debug endpoint, не на каждом request.

  11. Goroutine «забывание» после select — если в select есть default, G не паркуется. Но если все case требуют wait — gopark. Иногда забывают, что простой for { select {...} } с default = busy-loop, ест 100% CPU.

  12. GC stop-the-world длится в Go 1.24 обычно <100µs. Но если у вас миллионы горутин — STW для scan stacks может расти. Уменьшайте количество горутин (worker pool вместо per-request goroutine).

  13. time.Sleep(1) ≠ 1ns sleep. Минимальная гранулярность timer — около 1µs на Linux. Sleep(1ns) фактически парк до 1µs. На Windows минимум 1ms.

  14. Long-running G без вызовов функций — на Go ≥ 1.14 преэмптируется async. До 1.14 — зависала. Если у вас старый код с tight CPU loop, убедитесь, что собран новым тулчейном.

  15. Каждая горутина = ~2KB начального стека. 1M горутин = 2GB. На контейнере с limit 4GB это уже много. Используйте worker pool.


  • Сервис делал CGO calls с LockOSThread.
  • При GC STW некоторые M были locked-but-blocking → GC ждал до 50ms на отдельные G.
  • Решение: убрали LockOSThread, перенесли CGO в worker pool с отдельной горутиной (без lock), коммуницировали через channels. STW <500µs.
  • Под нагрузкой количество OS threads росло до 5000.
  • Причина: некоторые gRPC clients зависали в net.Read (forgotten timeouts), netpoll регистрировал FD, но G в _Gwaiting навсегда. Threads наполнялись.
  • Fix: жёсткие deadlines на всех RPC, мониторинг runtime.NumGoroutine() через runtime/metrics.
  • На staging VM с 2 vCPU при низкой нагрузке CPU usage ~30% «пустой».
  • Профиль: 28% в runtime.findrunnable.spinning.
  • Решение: GODEBUG=schedtrace=1000 показал слишком много spinning. Установили GOMAXPROCS=1 для этого пода (низкая нагрузка → достаточно).
  • Кастомный код регистрировал SIGURG для обработки out-of-band TCP data.
  • В 1.14 после релиза — все signal’ы попадали в runtime preemption handler.
  • Решение: переписали на отдельный thread (Cgo) без Go signal handling, или использование signal.Notify поверх runtime (рисковый workaround).
  • Сервис делал DB-вызовы → blocking syscall (epoll_wait) длительностью 100ms+ под нагрузкой DB.
  • sysmon делал handoffp каждые 10ms → создавал новые M.
  • В пике — 200+ Ms. Линус ядра жалуется на CPU context switches.
  • Fix: connection pool с timeout 5ms, fallback на cached response.
  • Микросервис с per-request goroutine + per-connection goroutine.
  • Под нагрузкой 100k RPS — пик 800k+ горутин.
  • Стек 2KB × 800k = 1.6GB только на стеки.
  • Решение: worker pool (semaphore с 10k слотов). Память упала до 100MB.

5. Вопросы на собесе Middle 3 (экспертный уровень)

Заголовок раздела «5. Вопросы на собесе Middle 3 (экспертный уровень)»
  1. Назовите все состояния G (atomicstatus) и переходы между ними.

  2. Что делает casgstatus? Зачем атомарность?

  3. Опишите полный жизненный цикл горутины от go f() до её smerth (Gdead).

  4. Что такое runnext в P? Почему нужен LIFO слот?

  5. Как реализован runqsteal? Что такое runqgrab? CAS контеншн.

  6. Опишите алгоритм findrunnable(). Какие 10 шагов?

  7. Что такое spinning M? Сколько их максимум?

  8. Объясните wakep() — когда вызывается, что делает.

  9. Каждые 61 итераций scheduler делает что и зачем?

  10. Как реализовано async preemption в 1.14+? Какой сигнал?

  11. Почему именно SIGURG? Что будет, если user-код регистрирует SIGURG?

  12. Что такое async safe-point? Где они вставляются?

  13. Cooperative preemption через stackguard0 — как работает? Что такое stackPreempt magic value?

  14. runtime.Gosched() vs forced preemption — разница.

  15. entersyscall — что происходит с P? Что такое _Psyscall?

  16. exitsyscall fast/slow path — отличия.

  17. Когда sysmon делает handoffp? Почему?

  18. Netpoll generic interface: netpollinit, netpollopen, netpoll. Где имплементации?

  19. Почему Go использует edge-triggered epoll, а не level-triggered? Trade-off.

  20. Как работает netpollBreak? Зачем?

  21. Что такое futex? Где он используется в Go runtime?

  22. sync.Mutex vs runtime.mutex — разница.

  23. Алгоритм gopark/goready — детально.

  24. Sysmon: какие задачи (4-5 штук) выполняет?

  25. Когда sysmon принудительно вызывает GC?

  26. runtime.LockOSThread — use cases. Что произойдёт, если LockOSThread + блокирующий syscall + cgo callback?

  27. Как достать goroutine ID? Почему его скрыли в API?

  28. runtime.Goexit vs panic vs обычный return — что общего, что разного?

  29. Что делает runtime.Stack(buf, true)? Почему опасно в hot path?

  30. Опишите stack growth: 2KB initial → 2x copy. Что происходит с указателями?

  31. Почему runtime.SetFinalizer может работать с задержкой?

  32. Что такое GODEBUG=schedtrace=1000? Что показывает?

  33. GODEBUG=scheddetail=1 — какие дополнительные данные?

  34. Как реализован GC mark assist в scheduler контексте?

  35. Что нового в scheduler в Go 1.24/1.25? (Container-aware GOMAXPROCS, Swiss tables в map, etc.)


  1. Schedule trace. Запустите простую программу с 1000 горутин, каждая делает Sleep + work. Включите GODEBUG=schedtrace=1000 scheddetail=1. Расшифруйте, сколько runq в каждой P, сколько spinning M, runqsize в global.

  2. Async preempt experiment. Напишите функцию:

    func busy() { for i := 0; i < 1<<30; i++ {} }

    Запустите её в горутине + одну с runtime.GC() в цикле. На Go 1.13 — зависание (cooperative only). На 1.14+ — нормальный прогресс. Профилируйте.

  3. Handoffp воспроизвести. Сделайте программу, где goroutine делает блокирующий syscall (os.Open на FIFO без readers). Через 10ms sysmon должна сделать handoffp. Подтвердите по schedtrace.

  4. netpoll исследование. Используйте net.Listen + 1000 idle TCP-конекшнов. Посмотрите runtime.NumGoroutine и количество OS threads (pgrep -c thread). Ни одной thread на conn — благодаря netpoll.

  5. LockOSThread test. Создайте 100 горутин, каждая runtime.LockOSThread() + sleep. Посмотрите количество threads. Сравните без LockOSThread.

  6. Goroutine leak detector. Реализуйте middleware, который пишет goroutine count до/после handler. Если delta > 0 после ответа — leak. Используйте runtime.NumGoroutine.

  7. runqsteal observe. Запустите CPU-bound в одной горутине, потом запускайте новые goroutines с CPU work. Используйте perf/go tool trace чтобы увидеть steal events.

  8. Sysmon scavenge. Установите GOMEMLIMIT=128MiB, аллоцируйте 100MB, потом 0. Через ~5 секунд через runtime/metrics /memory/classes/heap/released:bytes должны увидеть возврат памяти ОС.


  1. Go runtime sourcessrc/runtime/proc.go, runtime2.go, preempt.go, netpoll*.go, lock_futex.go. Обязательное чтение.
  2. «The Go Scheduler» — Daniel Morsing, morsmachine.dk (классический пост 2013, всё ещё актуален в основе).
  3. «Scalable Go Scheduler Design Doc» — Dmitry Vyukov, Go design docs.
  4. «Go 1.14 Async Preemption» — Austin Clements, Go Blog (design doc для preempt).
  5. «How does the Go scheduler work» — Ardan Labs blog series (Bill Kennedy).
  6. «Goroutine Preemption — How and Why» — Roberto Clapis, GopherCon EU 2020.
  7. «Inside the Go Runtime» — Cherry Mui, GopherCon 2023.
  8. «Mid-stack inlining and async preemption» — Keith Randall, GopherCon talks.
  9. «Profiling Go Programs in Production» — Felix Geisendörfer, Datadog blog (про runtime/metrics и schedulerlatency).
  10. «Linux futex(2)» — man page; understanding futex для понимания lock_futex.
  11. «epoll(7), kqueue(2)» — man pages.
  12. «Go GMP: глубокое погружение» — серия статей на rakyll.org (JBD).
  13. «netpoll внутренности» — Roberto Clapis посты в Medium.
  14. «Sysmon в Go» — статьи и talks на GoTime podcast.
  15. «Container-aware GOMAXPROCS» — Go 1.25 release notes + design doc.