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

Go Scheduler: GMP полностью под капотом

Это материал для опытных разработчиков. Здесь мы лезем в runtime/proc.go, runtime/runtime2.go, runtime/asm_amd64.s и разбираем, как Go scheduler реально работает на уровне структур и алгоритмов. На собеседованиях Middle 2 в Авито, Яндекс, Тинькофф и Озон вопросы по scheduler — это первое сито. Не “что такое горутина”, а “что лежит в g.atomicstatus, какие переходы между состояниями возможны, как происходит async preemption на сигнале SIGURG”. Если плаваешь — дальше про GC и аллокатор даже не спросят.

  1. Базовая концепция GMP (для разогрева)
  2. Глубокое погружение: структуры G, M, P и алгоритмы планирования
  3. Подводные камни scheduler
  4. Производительность и реальные кейсы
  5. Вопросы на собесе Middle 2 (30+)
  6. Practice — задачи продвинутого уровня
  7. Источники

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 работает на уровне сигналов. Поехали.


Реальная структура (упрощено, с ключевыми полями):

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.
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.
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-коде.
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 — транзитное состояние, когда стек копируется (растёт или ужимается). В это время другие потоки не могут менять стек.

Псевдокод 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 начинает расти.

Структура (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 не висели вечно.

Когда 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-операций.

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.

До 1.14 preemption была только кооперативной — горутина уходила, только когда вызывала функцию (через morestack-проверку). Это было плохо: горутина с for { x++ } могла повесить GC и других.

С 1.14 ввели async preemption через сигналы:

  1. Sysmon (см. ниже) видит, что G работает > 10ms на одной CPU.
  2. Sysmon вызывает preemptone(P).
  3. preemptone посылает SIGURG в поток M, исполняющий эту G.
  4. Signal handler runtime.sighandler проверяет: можно ли сейчас вытеснить?
    • Проверка asyncSafePoint: компилятор расставил safe-points (примерно каждый базовый блок без вызовов).
    • Если G сейчас в “неподходящем” месте (например, держит lock или внутри runtime-кода) — preempt отменяется, пробуем позже.
  5. Если можно — handler инжектит вызов asyncPreempt в контекст G.
  6. asyncPreemptruntime/preempt_amd64.s) сохраняет регистры, делает mcall(preemptPark), переводит G в _Gpreempted, возвращается в scheduler.

Почему именно SIGURG? Этот сигнал почти не используется в реальной жизни (TCP urgent data). Меньше шансов конфликта с приложением.

Каждая 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 и нужно.

Когда горутина уходит в системный вызов (например, read из файла):

//go:nosplit
func 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 path exitsyscall0, в котором будет искать другой P или паркнётся.

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():

  1. Системный вызов — non-blocking. Сразу возвращает EAGAIN.
  2. Go регистрирует fd в netpoll (epoll_ctl с EPOLLIN | EPOLLET).
  3. Горутина переходит в _Gwaiting с waitreason = waitReasonIOWait.
  4. В findrunnable netpoller проверяется (без блокировки) — есть ли готовые fd?
  5. Готовые fd → их pdg goroutines поднимаются в _Grunnable и кладутся в очередь.

При полной idle (нечего делать никому) — netpoll вызывается блокирующе, чтобы поспать до прихода сетевого события.

Состояние “spinning” — это пограничный режим: M не имеет работы, но активно ищет, а не спит. Зачем? Чтобы не платить за parking/unparking, когда нагрузка может прийти в любой момент.

Правила:

  • Не больше GOMAXPROCS/2 spinning M одновременно.
  • Spinning M проверяет local runqs всех P через steal, проверяет global runq, проверяет netpoll.
  • Если ничего нет за несколько раундов → переходит в stopm() (parked).

Когда мы готовим работу (например, runqput), мы вызываем wakep(). Wakep смотрит: есть ли уже spinning M? Если есть — он сам найдёт. Если нет — стартуем нового spinning M (или будем кого-то из parked).

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.

// 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 — утечка потоков.

В отличие от Linux scheduler (CFS), Go scheduler не знает про NUMA, L3-cache topology, CCX-границы. Все P для него равноценны. Это упрощает реализацию, но на больших NUMA-серверах (2+ socket, AMD EPYC) межсокетные обращения через runqsteal могут стоить в разы дороже, чем локальные.

Что с этим делать:

  • В Kubernetes используем cpuset и pinning workloads на NUMA node.
  • Иногда полезно вручную ограничить GOMAXPROCS числом ядер на одном сокете.

Проблема: 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 сам определит).

Выводит раз в 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.

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.


go func() {
for {
x++
}
}()

До Go 1.14 это была “горутина-зомби”, вешающая GC (STW не мог собрать все safepoints). С 1.14+ async preempt лечит это, но всё равно есть случаи, когда preempt отказывается (например, держим lock).

runtime.Gosched() кооперативно ставит текущую G в global runq и вызывает schedule(). Это добровольное “уступание”. Полезно, если знаешь, что у тебя tight loop, и хочешь дать шанс другим. Но это не magic preemption.

GOMAXPROCS=1 означает один P, но M-ов может быть много (один для user-кода, другие для syscall, sysmon, GC). Если у тебя LockOSThread(), ты можешь “съесть” единственный P, и остальные горутины повиснут.

При nmspinning > 0 сценарий: одна-две M в busy-loop сканируют все P. Это видно в profiles как runtime.findrunnable → высокое CPU. Не баг — фича. Но если RPS низкое и spinning постоянно тратит CPU без полезной работы, на embedded и energy-constrained системах это раздражает.

При STW (полная остановка для mark termination или для коллбэка через stopTheWorld) sysmon тоже останавливается. Поэтому если в STW произойдёт syscall, который должен бы вернуться → P для него не подхватится. Но STW по дизайну короткий (десятки µs), так что это не проблема.

go func() {
runtime.LockOSThread()
doWork()
// забыли UnlockOSThread!
}()

После завершения goroutine — поток ОС уничтожается, а не возвращается в пул. Если такие горутины создаются часто — утечка потоков.

go func() — это не free. Под капотом:

  • Аллокация G из gfree pool (быстрая) или из heap (медленная).
  • Аллокация 2 KB стека.
  • Инициализация sched gobuf.
  • runqput.

Стоимость ~100-300 ns на amd64. Если у тебя 1M горутин/сек только на go-stmt — это серьёзная нагрузка.

Решение: pool of workers + chan jobs. Это переиспользует горутины.

select {
case x := <-ch:
...
case <-time.After(1 * time.Second):
...
}

При каждом проходе создаётся timer. Под капотом — runtime.timeSleep, который добавляет элемент в timers heap текущего P. Если такой select в цикле — timers heap пухнет. Используй time.NewTimer и переиспользуй (с Reset).

Если ты не делаешь SetReadDeadline на conn, и читаешь — горутина уйдёт в netpoll и спит бесконечно. Если такой висит, например, в gRPC handler без deadline — G уйдёт в _Gwaiting, P освободится. Но G останется в памяти, утечёт.

Без automaxprocs: в k8s pod-е с cpu: "500m" Go видит 64 ядра, делает 64 P, и упирается в throttling. Симптом: P99 latency скачет в 5-10x.

Нет, runtime.Goexit отрабатывает все defer’ы текущей G и потом уничтожает её. Но: если defer внутри recover’ит panic — panic исчезает. С Goexit recover невозможен.

Если main горутина закончилась, но какие-то locked G ещё живы — runtime ждёт их? Нет, os.Exit (или return из main) не ждёт ничего. Но runtime.LockedOSThread G в “отцепленных” воркер-горутинах могут получить SIGSEGV при срабатывании финализаторов.

При async preempt SIGURG сигнал летит в M. Если M сейчас в cgo (в C-коде), сигнал может прийти когда C-код ничего не ожидает. Go runtime защищается: если M в cgo, async preempt этого M не делается.

Симптом: GODEBUG=schedtrace=1000 показывает spinningthreads=4, и CPU usage 100%. Возможные причины:

  • Очень много мелких G, которые быстро создаются и завершаются (микро-тасковая нагрузка).
  • Бесконечно фолбэк с netpoll/steal.

Лечение: профилируй с pprof.Profile("threadcreate") и runtime/trace.

NumGoroutine считает всё, что не _Gdead. Включает _Gwaiting на mutex, на chan, на netpoll. Если у тебя их 100K — это не страшно, если они в waiting. Страшно, если в _Grunnable.


Кейс (Авито 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 вернулась.

Кейс (продукт на 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), всплесков не стало.

Кейс (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 секунд.

Кейс (gRPC сервис): рост памяти на 50 MB/час. Профили говорили “горутины растут”. Через go tool pprof http://localhost:6060/debug/pprof/goroutine нашли 5K горутин в chan recv на одном и том же канале. Канал был внутри httpserver, но из-за неверной отмены контекста не закрывался. Каждый запрос плодил утёкшую G.

Решение: добавили defer close(ch) в обработчик.

Кейс (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.

Кейс: fuzzing-test делал for { fuzz() } без вызова Yield. До Go 1.14 это вешало GC. С 1.14 async preempt спасает, но: профилировка показала, что 10% времени уходит на handling SIGURG. Решение: вставить явный runtime.Gosched() каждые N итераций — overhead меньше.

Кейс (продакшен в k8s): до automaxprocs — в pod-е с limit=2 cores Go видел 64 ядра, GOMAXPROCS=64. CFS throttling на 60% времени. После automaxprocs: GOMAXPROCS=2. CPU steal 0%, p99 -45%.


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?

  1. Локальная очередь P.
  2. Каждые 61 schedule() — глобальная очередь.
  3. Netpoll (non-blocking).
  4. Steal у других P (4 раунда).
  5. Глобальная очередь (повторно с lock).
  6. Блокирующий netpoll.
  7. 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() под капотом?

  1. Аллокация G из gfree пула P (или из heap).
  2. Аллокация стека 2 KB.
  3. Инициализация g.sched (PC = runtime.goexit, SP = верх стека).
  4. runqput(p, g, true) — кладём в runnext.
  5. Если есть 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 найдёт его при следующем сканировании.


Реализуй worker pool с фиксированным числом воркеров (например, runtime.NumCPU()). Воркеры получают job через chan, обрабатывают, отправляют результат. Поверх — graceful shutdown через context.

Ключевые точки: не создавать G на каждый job, переиспользовать воркеры, корректно завершать (close(jobs), wait worker).

Напиши программу:

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.

Напиши код, который через cgo вызывает функцию, которая записывает в pthread_setspecific (TLS). Покажи, что без LockOSThread TLS-значение может пропасть, если G переезжает на другой M.

Запусти программу с GODEBUG=schedtrace=1000,scheddetail=1. Создай 1000 горутин, которые каждые 100 ms делают syscall (time.Sleep). Наблюдай за тем, как меняются P-counts и threads. Объясни, почему threads растёт выше GOMAXPROCS.

Запусти бенчмарк: 10K горутин, каждая выполняет CPU-intensive работу (например, считает SHA-256 от 1 MB данных). Сравни с GOMAXPROCS=1, GOMAXPROCS=numCPU. Объясни, почему scale почти линейный (если задача чисто CPU-bound).

Запусти Go-программу с runtime.NumCPU() = 8, но через cgroups (или taskset) ограничь CPU-квоту до 100ms/sec (= 0.1 CPU). Наблюдай рост latency. Подключи automaxprocs — наблюдай улучшение.

Используй runtime/trace:

trace.Start(f)
defer trace.Stop()
// рабочая нагрузка

Посмотри trace в go tool trace. Найди:

  • Spinning intervals.
  • STW gaps.
  • syscall handoffs.

Напиши программу, которая создаёт G с висячими <-ch без отмены. Прогон через go tool pprof http://localhost:6060/debug/pprof/goroutine. Покажи, как стектрейсы локализуют утечку.


  1. src/runtime/proc.go — основной файл scheduler (~7000 строк). Читать schedule(), findRunnable(), entersyscall(), exitsyscall(), sysmon().
  2. src/runtime/runtime2.go — структуры G, M, P, schedt.
  3. src/runtime/asm_amd64.sruntime·gogo, runtime·mcall, runtime·systemstack, runtime·morestack.
  4. Dmitry Vyukov, “Scalable Go Scheduler Design Doc” (2012) — оригинальный документ про work-stealing scheduler.
  5. Russ Cox, “Go Preemption Talk” (GopherCon 2020) — про async preemption.
  6. Austin Clements, “Go GC Pacer & Scheduler Integration” (GopherCon EU 2018).
  7. “Go’s work-stealing scheduler” — Jaana Dogan, Medium, 2017.
  8. “Scheduling in Go” — William Kennedy, Ardan Labs blog (3-part series).
  9. “What’s new in Go scheduler” — Go 1.14 release notes (async preempt), Go 1.21 (PGO), Go 1.25 (GOMAXPROCS=auto).
  10. “Дайджест scheduler” — Habr, статьи Avito и Ozon Engineering Blog (2024-2026).
  11. JBD’s “Goroutines and the OS scheduler” — talk на GopherConf.
  12. “go-scheduler-design-doc” в репозитории golang/go (doc/articles/).