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+.
Содержание
Заголовок раздела «Содержание»- Краткое введение (для разогрева)
- Глубочайшее погружение
- 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
- Подводные камни
- Реальные production-кейсы
- Вопросы на собесе Middle 3
- Practice
- Источники
1. Краткое введение (для разогрева)
Заголовок раздела «1. Краткое введение (для разогрева)»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.go | Scheduler core: schedule, findrunnable, … |
runtime/preempt.go | Async preemption |
runtime/lock_futex.go (linux) | Mutex/sema через futex |
runtime/netpoll.go | Generic netpoll |
runtime/netpoll_epoll.go | Linux epoll |
runtime/netpoll_kqueue.go | macOS/BSD kqueue |
runtime/netpoll_iocp.go | Windows IOCP |
runtime/asm_amd64.s | gogo, gosave, mcall, systemstack, … |
runtime/signal_unix.go | Signal handler |
2. Глубочайшее погружение
Заголовок раздела «2. Глубочайшее погружение»2.1. Состояния горутины и переходы
Заголовок раздела «2.1. Состояния горутины и переходы»В 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.
2.2. Полный жизненный цикл G
Заголовок раздела «2.2. Полный жизненный цикл G»Создание: 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 })}Что происходит:
newproc1берёт G из gFree-листа (или аллоцирует новую).- Стек выделяется минимум 2KB (
_StackMin), copy-on-grow. - PC устанавливается на
goexit + 1(так что после return G попадает вgoexit1). - 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 + 1 → goexit1() → mcall(goexit0):
casgstatus(_Grunning → _Gdead).- Очищает поля G (gcfn, _panic, _defer).
- Кладёт G в
m.p.gFree(per-P pool); если переполнен → вsched.gFree.stack.
2.3. Локальные очереди: runqsteal, runqgrab
Заголовок раздела «2.3. Локальные очереди: runqsteal, runqgrab»Каждый 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 почти всегда успех с первого раза.
2.4. Global run queue
Заголовок раздела «2.4. Global run queue»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)}2.5. findrunnable() — алгоритм
Заголовок раздела «2.5. findrunnable() — алгоритм»Самая большая функция в 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, либо создать новую.
2.6. Async preemption (Go 1.14+)
Заголовок раздела «2.6. Async preemption (Go 1.14+)»До 1.14 Go использовал только cooperative preemption (stack check в прологе функции). Проблема: tight loop без вызовов функций → G не может быть прервана → STW GC не может стартовать.
Решение (1.14, Austin Clements):
sysmonили GC посылают SIGURG конкретному M.- Signal handler
sigtrampgo→doSigPreempt(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 checkspass вставляет). - Перед conservative-scan регионами.
Если PC в небезопасной зоне (например, в середине inline-asm) — preempt пропускается, retry позже.
2.7. Cooperative preemption
Заголовок раздела «2.7. Cooperative preemption»Старый механизм, всё ещё работает. В прологе каждой функции (не //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 даже при достаточном стеке → попадаем в morestack → newstack → проверяется preempt-флаг → если стоит, делаем preempt.
stackPreempt = 0xfffffffffffffade — magic value. preemptone() устанавливает его в g.stackguard0, далее в следующем prologue будет преэмпт.
runtime.Gosched() — добровольный yield: mcall(gosched_m) → ставит G в global runq (НЕ local!) → schedule().
2.8. Syscall handling
Заголовок раздела «2.8. Syscall handling»entersyscall():
- Сохраняет PC, SP в
g.sched. m.locks++(запрет preempt пока в syscall transition).pp := m.p.ptr();pp.m = 0;g.m.p = 0— M отвязывается от P.casgstatus(_Grunning → _Gsyscall).pp.status = _Psyscall— P в специальном статусе.- 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 завершился:
- Быстрый путь: попытаться вернуть исходную P (
exitsyscallfast).- Если оригинальная P свободна → CAS pp.status (_Psyscall → _Pidle) → success → берём её.
- Медленный путь: P уже забрана другим M → ищем любую свободную P → если есть, занимаем.
- Если нет —
mcall(exitsyscall0):- G ставится в global queue.
- M идёт в
stopm()(sleep).
⚠️ Это означает: goroutine после длинного syscall может потерять P и продолжить на другой M. Если вы зависите от LockOSThread — об этом надо помнить.
2.9. Network poller (netpoll)
Заголовок раздела «2.9. Network poller (netpoll)»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 | Бэкенд | Файл |
|---|---|---|
| Linux | epoll (level-trig.) | netpoll_epoll.go |
| macOS/BSD | kqueue | netpoll_kqueue.go |
| Windows | IOCP | netpoll_iocp.go |
| Solaris | event ports | netpoll_solaris.go |
| AIX | poll | netpoll_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 1Edge-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 байт.
2.10. Futex и park/unpark
Заголовок раздела «2.10. Futex и park/unpark»На 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.
2.11. Sysmon goroutine
Заголовок раздела «2.11. Sysmon goroutine»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=…
2.12. LockOSThread
Заголовок раздела «2.12. LockOSThread»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.
2.13. Goexit, Stack, Goroutine ID
Заголовок раздела «2.13. Goexit, Stack, Goroutine ID»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}3. Подводные камни
Заголовок раздела «3. Подводные камни»-
Async preemption ломает
signal.Notify(SIGURG)в user-коде. Если регистрируете SIGURG — конфликт с runtime, возможны hangs. -
runtime.LockOSThread+os.Exitв другой goroutine = thread остаётся, deferred не запустится. Используйтеruntime.Goexitесли хотите cleanup. -
Длинный syscall с CGO: M отвязывается от P, но если CGO-call зависает — M потерян навсегда. Defensive timeout важен.
-
runtime.Gosched()кладёт G в GLOBAL queue, не local. После Gosched ваша G может «потерять» P-локальность (cache misses). -
runtime.GOMAXPROCS(0)возвращает текущее значение, не меняет. Чтобы получить количество ядер:runtime.NumCPU(). С 1.25 GOMAXPROCS читается из cgroup, иruntime.NumCPU()может вернуть ≠ от GOMAXPROCS. -
goparkбезunlockf— bug. Если ничего не делает G readyable, она зависнет навсегда. В runtime все вызовы gopark идут с разумным callback. -
Spinning Ms жрут CPU — на маленьких VPS (1 core) Go scheduler может уйти в spin loop. Решение:
GOMAXPROCS=1+ проверить. -
runtime.SetFinalizerзапускает finalizer в отдельной G, и эта G имеет приоритет ниже обычных. В нагруженной системе finalizer может запускаться с задержками. -
Stack growth → копирование стека. Все указатели на стек обновляются (компилятор знает offsets). Если у вас в asm указатель на stack-local — сломается. Используйте
g0(системный стек) для критики. -
runtime.Stack(buf, true)= STW. Все G ставятся на паузу. В production использовать только в debug endpoint, не на каждом request. -
Goroutine «забывание» после select — если в select есть default, G не паркуется. Но если все case требуют wait — gopark. Иногда забывают, что простой
for { select {...} }с default = busy-loop, ест 100% CPU. -
GC stop-the-world длится в Go 1.24 обычно <100µs. Но если у вас миллионы горутин — STW для scan stacks может расти. Уменьшайте количество горутин (worker pool вместо per-request goroutine).
-
time.Sleep(1)≠ 1ns sleep. Минимальная гранулярность timer — около 1µs на Linux. Sleep(1ns) фактически парк до 1µs. На Windows минимум 1ms. -
Long-running G без вызовов функций — на Go ≥ 1.14 преэмптируется async. До 1.14 — зависала. Если у вас старый код с tight CPU loop, убедитесь, что собран новым тулчейном.
-
Каждая горутина = ~2KB начального стека. 1M горутин = 2GB. На контейнере с limit 4GB это уже много. Используйте worker pool.
4. Реальные production-кейсы
Заголовок раздела «4. Реальные production-кейсы»4.1. Тинькофф: latency spikes из-за GC + LockOSThread
Заголовок раздела «4.1. Тинькофф: latency spikes из-за GC + LockOSThread»- Сервис делал CGO calls с
LockOSThread. - При GC STW некоторые M были locked-but-blocking → GC ждал до 50ms на отдельные G.
- Решение: убрали LockOSThread, перенесли CGO в worker pool с отдельной горутиной (без lock), коммуницировали через channels. STW <500µs.
4.2. Авито: gRPC + thread leak
Заголовок раздела «4.2. Авито: gRPC + thread leak»- Под нагрузкой количество OS threads росло до 5000.
- Причина: некоторые gRPC clients зависали в
net.Read(forgotten timeouts), netpoll регистрировал FD, но G в_Gwaitingнавсегда. Threads наполнялись. - Fix: жёсткие deadlines на всех RPC, мониторинг
runtime.NumGoroutine()черезruntime/metrics.
4.3. Яндекс: spinning M на маленькой VM
Заголовок раздела «4.3. Яндекс: spinning M на маленькой VM»- На staging VM с 2 vCPU при низкой нагрузке CPU usage ~30% «пустой».
- Профиль: 28% в
runtime.findrunnable.spinning. - Решение:
GODEBUG=schedtrace=1000показал слишком много spinning. УстановилиGOMAXPROCS=1для этого пода (низкая нагрузка → достаточно).
4.4. Cloudflare: SIGURG conflict
Заголовок раздела «4.4. Cloudflare: SIGURG conflict»- Кастомный код регистрировал SIGURG для обработки out-of-band TCP data.
- В 1.14 после релиза — все signal’ы попадали в runtime preemption handler.
- Решение: переписали на отдельный thread (Cgo) без Go signal handling, или использование
signal.Notifyповерх runtime (рисковый workaround).
4.5. Сбер: handoffp под нагрузкой
Заголовок раздела «4.5. Сбер: handoffp под нагрузкой»- Сервис делал DB-вызовы → blocking syscall (epoll_wait) длительностью 100ms+ под нагрузкой DB.
- sysmon делал handoffp каждые 10ms → создавал новые M.
- В пике — 200+ Ms. Линус ядра жалуется на CPU context switches.
- Fix: connection pool с timeout 5ms, fallback на cached response.
4.6. Uber: миллион горутин на edge
Заголовок раздела «4.6. Uber: миллион горутин на edge»- Микросервис с per-request goroutine + per-connection goroutine.
- Под нагрузкой 100k RPS — пик 800k+ горутин.
- Стек 2KB × 800k = 1.6GB только на стеки.
- Решение: worker pool (semaphore с 10k слотов). Память упала до 100MB.
5. Вопросы на собесе Middle 3 (экспертный уровень)
Заголовок раздела «5. Вопросы на собесе Middle 3 (экспертный уровень)»-
Назовите все состояния G (
atomicstatus) и переходы между ними. -
Что делает
casgstatus? Зачем атомарность? -
Опишите полный жизненный цикл горутины от
go f()до её smerth (Gdead). -
Что такое
runnextв P? Почему нужен LIFO слот? -
Как реализован
runqsteal? Что такоеrunqgrab? CAS контеншн. -
Опишите алгоритм
findrunnable(). Какие 10 шагов? -
Что такое spinning M? Сколько их максимум?
-
Объясните
wakep()— когда вызывается, что делает. -
Каждые 61 итераций scheduler делает что и зачем?
-
Как реализовано async preemption в 1.14+? Какой сигнал?
-
Почему именно SIGURG? Что будет, если user-код регистрирует SIGURG?
-
Что такое async safe-point? Где они вставляются?
-
Cooperative preemption через stackguard0 — как работает? Что такое
stackPreemptmagic value? -
runtime.Gosched()vs forced preemption — разница. -
entersyscall— что происходит с P? Что такое_Psyscall? -
exitsyscallfast/slow path — отличия. -
Когда sysmon делает
handoffp? Почему? -
Netpoll generic interface:
netpollinit,netpollopen,netpoll. Где имплементации? -
Почему Go использует edge-triggered epoll, а не level-triggered? Trade-off.
-
Как работает
netpollBreak? Зачем? -
Что такое futex? Где он используется в Go runtime?
-
sync.Mutexvsruntime.mutex— разница. -
Алгоритм
gopark/goready— детально. -
Sysmon: какие задачи (4-5 штук) выполняет?
-
Когда sysmon принудительно вызывает GC?
-
runtime.LockOSThread— use cases. Что произойдёт, если LockOSThread + блокирующий syscall + cgo callback? -
Как достать goroutine ID? Почему его скрыли в API?
-
runtime.Goexitvspanicvs обычный return — что общего, что разного? -
Что делает
runtime.Stack(buf, true)? Почему опасно в hot path? -
Опишите stack growth: 2KB initial → 2x copy. Что происходит с указателями?
-
Почему
runtime.SetFinalizerможет работать с задержкой? -
Что такое
GODEBUG=schedtrace=1000? Что показывает? -
GODEBUG=scheddetail=1— какие дополнительные данные? -
Как реализован GC mark assist в scheduler контексте?
-
Что нового в scheduler в Go 1.24/1.25? (Container-aware GOMAXPROCS, Swiss tables в map, etc.)
6. Practice
Заголовок раздела «6. Practice»-
Schedule trace. Запустите простую программу с 1000 горутин, каждая делает Sleep + work. Включите
GODEBUG=schedtrace=1000 scheddetail=1. Расшифруйте, сколько runq в каждой P, сколько spinning M, runqsize в global. -
Async preempt experiment. Напишите функцию:
func busy() { for i := 0; i < 1<<30; i++ {} }Запустите её в горутине + одну с
runtime.GC()в цикле. На Go 1.13 — зависание (cooperative only). На 1.14+ — нормальный прогресс. Профилируйте. -
Handoffp воспроизвести. Сделайте программу, где goroutine делает блокирующий syscall (
os.Openна FIFO без readers). Через 10ms sysmon должна сделать handoffp. Подтвердите поschedtrace. -
netpoll исследование. Используйте
net.Listen+ 1000 idle TCP-конекшнов. Посмотритеruntime.NumGoroutineи количество OS threads (pgrep -c thread). Ни одной thread на conn — благодаря netpoll. -
LockOSThread test. Создайте 100 горутин, каждая
runtime.LockOSThread()+ sleep. Посмотрите количество threads. Сравните без LockOSThread. -
Goroutine leak detector. Реализуйте middleware, который пишет goroutine count до/после handler. Если delta > 0 после ответа — leak. Используйте
runtime.NumGoroutine. -
runqsteal observe. Запустите CPU-bound в одной горутине, потом запускайте новые goroutines с CPU work. Используйте perf/
go tool traceчтобы увидеть steal events. -
Sysmon scavenge. Установите GOMEMLIMIT=128MiB, аллоцируйте 100MB, потом 0. Через ~5 секунд через
runtime/metrics /memory/classes/heap/released:bytesдолжны увидеть возврат памяти ОС.
7. Источники
Заголовок раздела «7. Источники»- Go runtime sources —
src/runtime/proc.go,runtime2.go,preempt.go,netpoll*.go,lock_futex.go. Обязательное чтение. - «The Go Scheduler» — Daniel Morsing, morsmachine.dk (классический пост 2013, всё ещё актуален в основе).
- «Scalable Go Scheduler Design Doc» — Dmitry Vyukov, Go design docs.
- «Go 1.14 Async Preemption» — Austin Clements, Go Blog (design doc для preempt).
- «How does the Go scheduler work» — Ardan Labs blog series (Bill Kennedy).
- «Goroutine Preemption — How and Why» — Roberto Clapis, GopherCon EU 2020.
- «Inside the Go Runtime» — Cherry Mui, GopherCon 2023.
- «Mid-stack inlining and async preemption» — Keith Randall, GopherCon talks.
- «Profiling Go Programs in Production» — Felix Geisendörfer, Datadog blog (про runtime/metrics и schedulerlatency).
- «Linux futex(2)» — man page; understanding futex для понимания lock_futex.
- «epoll(7), kqueue(2)» — man pages.
- «Go GMP: глубокое погружение» — серия статей на rakyll.org (JBD).
- «netpoll внутренности» — Roberto Clapis посты в Medium.
- «Sysmon в Go» — статьи и talks на GoTime podcast.
- «Container-aware GOMAXPROCS» — Go 1.25 release notes + design doc.