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

GMP Scheduler: глубокое погружение

Зачем знать на Middle 1. Понимание GMP — это разница между «у меня в коде утечка горутин» и «я знаю, как scheduler ставит мою горутину на CPU, когда она блокируется на syscall, и почему work-stealing спасает мне p99». Без GMP нельзя осмысленно читать go tool trace, нельзя правильно настраивать GOMAXPROCS в Kubernetes-подах с CPU limits, нельзя объяснить, почему один CPU-bound цикл без вызова функций может «съесть» один поток до Go 1.14 и не дать другим горутинам времени. На джуне достаточно было знать «горутины дешёвые». На мидле от вас ждут конкретики: G/M/P, local/global queue, work stealing, async preemption (SIGURG), netpoll, syscall handoff, sysmon.


  1. Базовая концепция: M:N scheduling и компоненты G/M/P
  2. Под капотом: структуры, очереди, work stealing, preemption
  3. Gotchas: tight loops, LockOSThread, syscalls
  4. Production-практики: GOMAXPROCS в контейнерах, sysmon, диагностика
  5. Вопросы на собесе (25–30)
  6. Practice
  7. Источники

1. Базовая концепция: M:N scheduling и компоненты G/M/P

Заголовок раздела «1. Базовая концепция: M:N scheduling и компоненты G/M/P»

Go использует M:N модель: M горутин (user-space tasks) выполняются на N OS-потоках (которые в свою очередь распределяются ядром на K физических CPU). У большинства языков был выбор:

  • 1:1 (kernel threads) — Java (до Project Loom), C/C++ с pthreads. Дёшево создать, но context-switch через ядро (~1–5 µs) и память на стек (~2–8 MB на поток).
  • N:1 (green threads) — старый Ruby, Python без asyncio. Один OS-поток обслуживает всё, нельзя задействовать многоядерность.
  • M:N (work stealing) — Go (GMP), Erlang/BEAM, Project Loom (виртуальные потоки), Rust Tokio. Дёшево создать (start 2KB stack, growable), мультиплексируем на OS-потоках, can use all cores.

Цена создания горутины — около 2 KB начального стека + структура g (~400 байт). Сравните с pthread (минимум 8 KB стек + ядерные структуры).

G (goroutine) — единица исполнения уровня Go. Условно "поток" из user-кода.
M (machine) — OS-поток (pthread на Linux/macOS, win32-thread на Windows).
P (processor) — логический ресурс «право выполнять Go-код». Number of P = GOMAXPROCS.

Ключевая инвариантность: чтобы M выполнял Go-код, ему нужен P. Без P поток может только спать, делать syscall или waiting. Это даёт ограничение параллелизма Go-кода: одновременно работающих горутин не больше, чем P.

┌──────────────────────────────────────┐
│ Global Run Queue │ (защищена sched.lock)
│ [G][G][G][G][G][G][G][G]... │
└──────────────────────────────────────┘
▲ ▲
│ overflow │ steal (1/61 ticks)
│ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ P0 │ │ P1 │ │ P2 │ ← GOMAXPROCS=3
│ local rq │ │ local rq │ │ local rq │
│ [G][G][G] │ │ [G][G] │ │ [G][G][G][G]│
│ mcache │ │ mcache │ │ mcache │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ bind 1:1 (когда выполняем Go) │
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ M0 │ │ M1 │ │ M2 │
│ (OS thread) │ │ (OS thread) │ │ (OS thread) │
│ running G │ │ running G │ │ running G │
└─────────────┘ └─────────────┘ └─────────────┘
Дополнительные M (idle / parked / в syscall):
┌─────────────┐ ┌─────────────┐
│ M3 │ │ M4 │ ← M в syscall (без P), M parked
│ (in syscall)│ │ (parked) │
└─────────────┘ └─────────────┘
netpoll (epoll/kqueue/IOCP) — отдельный механизм
┌────────────────────────────────────┐
│ FD → blocked G's │
│ когда FD ready → G в run queue │
└────────────────────────────────────┘

Что здесь видно:

  • P держит local run queue (массив фиксированной длины 256) и mcache (mcache — кэш аллокатора, чтобы не лочиться на central heap).
  • Каждый M, выполняющий Go-код, ассоциирован с одним P (1:1 в этот момент).
  • M в syscall отдаёт P другому M (handoff), чтобы остальные горутины могли работать.
  • Когда у P опустошается local queue, он крадёт половину задач у случайного другого P (work stealing).
  • Раз в 61 такт scheduler также проверяет global queue — чтобы избежать starvation.

2. Под капотом: структуры, очереди, work stealing, preemption

Заголовок раздела «2. Под капотом: структуры, очереди, work stealing, preemption»
// упрощённо, реальная структура в src/runtime/runtime2.go
type g struct {
stack stack // нижняя и верхняя граница стека
stackguard0 uintptr // нижняя граница + spill для stack growth
m *m // текущий M, к которому привязана (nil если runnable)
sched gobuf // сохранённое состояние регистров (для возобновления)
atomicstatus uint32 // _Grunnable / _Grunning / _Gwaiting / ...
goid int64 // уникальный ID (внутренний, наружу не дают)
waitreason waitReason // причина блокировки (для трассировок)
preempt bool // флаг "тебе нужно остановиться"
preemptStop bool // async preemption триггер
// + ~50 других полей: panic stack, defer stack, profiler, GC bits...
}
type gobuf struct {
sp uintptr // stack pointer
pc uintptr // program counter
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr // link register (ARM)
bp uintptr // base pointer (frame pointer)
}

Когда горутина приостановлена (например, для work stealing или после syscall), её регистры (sp, pc, bp и пр.) сохранены в g.sched. При возобновлении scheduler делает gogo(&g.sched) — это assembly-функция, которая восстанавливает регистры и «приземляется» в нужное место кода.

  • Начальный стек — 2 KB (раньше было 8 KB; уменьшили в Go 1.4, перейдя на copying stacks).
  • При исчерпании место для stack growth check в прологе функции триггерит morestack → выделяется новый, в 2 раза больший стек, всё содержимое копируется (потому горутины могут двигаться в памяти; не сохраняйте указатели на стек горутины снаружи).
  • Stack shrinking — при GC: если стек используется меньше, чем на четверть, может ужаться.
  • Max stack — 1 GB (см. runtime.SetMaxStack).
Старт горутины:
stackguard0
┌─────────────────┴─────────────────┐
│ 2KB stack (growable) │
└───────────────────────────────────┘
Пролог любой функции:
CMP SP, stackguard0
JLS runtime.morestack_noctxt
... (продолжаем выполнение)
type m struct {
g0 *g // системная goroutine для исполнения runtime-кода
curg *g // текущая user goroutine (nil если ничего не делаем)
p puintptr // привязанный P (nil если в syscall/parked)
nextp puintptr // P, который нужно подобрать после wake-up
oldp puintptr // P, который был перед syscall
park note // примитив для парковки (futex/sema)
mOS mOS // OS-зависимые поля (LWP id, тред-handle и т.д.)
lockedg guintptr // если есть locked goroutine (LockOSThread)
spinning bool // ищет работу (но ещё не нашёл)
blocked bool
// + GC marker fields, signal handling и т.п.
}

g0 — это системная goroutine для каждого M. У неё большой стек (~8 KB на Linux, 64 KB на Windows), на котором выполняется код самого runtime (scheduler, GC, syscall wrappers). User-goroutines живут на отдельных, маленьких стеках.

type p struct {
id int32
status uint32 // _Pidle / _Prunning / _Psyscall / ...
m muintptr // M, который держит P в данный момент
mcache *mcache // локальный кэш аллокатора
pcache pageCache
runqhead uint32 // голова кольцевого буфера
runqtail uint32 // хвост
runq [256]guintptr // КОЛЬЦЕВОЙ БУФЕР НА 256 ГОРУТИН
runnext guintptr // приоритетная горутина (последняя добавленная)
timers []*timer
// + GC fields, schedtick, syscalltick и т.д.
}

runnext — это «приоритетный слот» для последней горутины, созданной/разблокированной этим P. Идея: если G породила G2 через go f(), скорее всего, G2 нужно выполнить немедленно после G (кэш горячий, родственные данные). Поэтому G2 кладётся в runnext, а старая runnext уходит в кольцевой буфер.

runq[256] — это кольцевой буфер фиксированного размера. Если новая горутина не помещается:

  1. P берёт половину своей local queue (128 шт.) + 1 новую горутину.
  2. Линкует их в связный список.
  3. Кладёт в global run queue (одной операцией, под глобальный lock).

Зачем 256? Эмпирическое значение для баланса между:

  • Маленькая очередь → частые попадания в global (lock contention).
  • Большая очередь → плохой work-stealing, длинные tail-latency.

Когда P освобождается (текущая горутина блокировалась, завершилась, или была вытеснена), scheduler ищет следующую работу:

findrunnable():
1. Если schedtick % 61 == 0:
взять 1 из global run queue (анти-starvation)
2. Если есть runnext — взять её.
3. Если local queue не пуста — взять head.
4. Если global run queue не пуста — взять ~maxBatch (min(len/gomaxprocs+1, len/2)).
5. Если есть готовые netpoll events — забрать их (non-blocking poll).
6. Иначе — work stealing:
for tries in 1..4:
выбрать случайный другой P
украсть половину его очереди (runqsteal)
если ничего не украли — следующая попытка
7. Если ничего не нашли:
проверить global ещё раз (под локом)
проверить netpoll (теперь — blocking)
если всё ещё ничего — park M (отдать P, спать)
Алгоритм runqsteal(p_target):
1. Снимок runqhead и runqtail (атомарно).
2. n = (tail - head) / 2 — берём половину.
3. Для атомарности используется CAS на runqhead жертвы:
CAS(runqhead_target, h, h + n)
Если CAS не прошёл — другой ворюга опередил, повторить.
4. Скопировать G ссылки в свою local queue.
5. ATSO принять runnext только в крайнем случае
(если не нашли ничего другого) — runnext "приватнее".

Зачем красть половину, а не одну? Чтобы амортизировать стоимость stealing (атомарные CAS на чужом P) — если украл 1, через 100 ns снова придёшь воровать.

_Gidle — только создана, ещё не инициализирована
_Grunnable — в run queue, ждёт выделения CPU
_Grunning — выполняется на M (имеет P, runtime.curg = эта)
_Gsyscall — в syscall (M отдал P)
_Gwaiting — заблокирована (chan, mutex, IO, GC, sleep...)
_Gdead — завершилась, в пуле g для переиспользования
_Gcopystack — стек копируется (промежуточно)
_Gpreempted — async-preempted (промежуточно перед _Gwaiting)

Переходы:

go f()
─────────────► _Grunnable ──► _Grunning ──► _Gdead
▲ │
│ ├──► _Gwaiting (chan, mutex, GC)
│ │ │
│ │ │ ready/notify
│ │ ▼
│ └──► _Grunnable
│ syscall finish
│ ▲
└──── _Gsyscall ◄──── _Grunning (entered syscall)

Когда горутина вызывает блокирующий syscall (read, write, accept, etc.):

G calls syscall:
1. M переходит в _Psyscall (для P).
2. M отрывается от P (но физически держит указатель,
чтобы быстро вернуться).
3. Если syscall короткий — M возвращается, забирает свой P обратно.
4. Если sysmon видит, что M в syscall > 10 µs:
handoffp(p) — P отдаётся idle M (или создаётся новый M).
5. После syscall M пробует подцепить любой свободный P.
Если нет — кладёт G в global run queue и паркуется.

Это объясняет, почему runtime.NumGoroutine() может быть огромным (миллион), но GOMAXPROCS = 8 — горутины в syscall не занимают P.

Network IO (read/write на socket):
syscall.Read(fd, buf):
1. Сначала пробуем неблокирующий read.
2. Если EAGAIN/EWOULDBLOCK (данных нет):
- вызывается netpollblock(fd, mode)
- G помещается в _Gwaiting
- fd регистрируется в epoll (Linux) / kqueue (BSD/macOS) / IOCP (Win)
- M освобождается (P может работать с другой G).
3. Где-то в scheduler:
findrunnable → netpoll(timeout) → готовые FD's
для каждого fd: G ready → _Grunnable → run queue.

Это не «асинхронный код» в смысле async/await — у вас в коде синхронный conn.Read(...), но Go runtime под капотом превращает блокировку в M:N parking. Поэтому Go умеет держать миллион одновременных соединений на 8 потоках.

Preempt-чек был встроен только в пролог функции:

function prologue (упрощённо):
CMP SP, g.stackguard0 ; сравниваем stack pointer
JLS morestack ; если меньше — растим стек ИЛИ preempt

Когда нужно вытеснить горутину, scheduler ставит g.stackguard0 = stackPreempt (специальное значение 0xfffffade). Тогда следующий вызов функции уйдёт в morestack, который проверит флаг и переключит контекст.

Проблема: что, если горутина крутится в плотном цикле без вызовов функций?

// До Go 1.14 — эта горутина "вешала" свой P до завершения цикла:
func busy() {
for i := 0; i < 1e10; i++ {
// нет вызовов функций → нет preempt check → нет преrupted
}
}

GC ждал, пока все горутины достигнут safe point (= prologue). Один такой цикл мог задержать GC на секунды.

Решение: scheduler шлёт SIGURG в поток, который держит “плохую” горутину. Signal handler:

  1. Проверяет, что регистры в безопасной точке (не в середине atomic operation).
  2. Сохраняет состояние в g.sched.
  3. Кладёт G обратно в run queue, освобождает P.

Теперь даже бесконечные циклы вытесняются. Цена — крошечный overhead на signal handler.

// В Go 1.14+ это уже корректно вытесняется:
go func() {
for { /* tight loop */ }
}()
runtime.GC() // больше не зависает

Когда async preempt НЕ работает:

  • Внутри cgo-вызова (Go runtime не управляет потоком).
  • В критических секциях, где runtime запретил preempt (mp.preemptoff != "").
  • В нативном коде через LockOSThread + assembly.

Sysmon — это специальная горутина без P, запускается при старте программы (go runtime.sysmon()), работает на отдельном M, делает периодические проверки (raw, без runtime locking).

Что делает:

  1. retake(now) — ищет:
    • M в syscall > 20 µs → handoff P → отдать другому M.
    • G running > 10 ms → async preempt (SIGURG).
  2. netpoll — если давно никто не делал netpoll, дёрнем сами (на случай idle CPUs).
  3. gcStart trigger — если давно не было GC и есть аллокации, запустить GC.
  4. finalizer goroutines — обработать finalizers.
  5. Forced GC — если давно не было GC (по таймеру, 2 минуты).

Sysmon динамически меняет частоту своих циклов от 20 µs до 10 ms (forcePreemptNS, forcegcperiod).

Когда M не нашёл работу, есть выбор:

  • Park — отдать P, заснуть в futex_wait (Linux). Wake-up через futex_wake стоит ~1–5 µs.
  • Spin — крутиться в цикле findrunnable, надеясь, что скоро появится работа.

Go держит до GOMAXPROCS/2 spinning threads одновременно. Логика:

  • Если есть spinning M и появилась новая G → spinning M уже готов её взять (без latency на wake-up).
  • Cost — CPU на спиннинге.

Это улучшает latency burst-нагрузок: после периода тишины первая G не ждёт wake-up M.

runtime.GOMAXPROCS(n int) int // вернёт старое значение, поставит новое

По умолчанию = runtime.NumCPU(). Влияет на:

  • Количество P — параллелизм Go-кода.
  • Размер шардирования внутренних структур (например, mcache копий).
  • GC параллельность (по дефолту GC использует 25% CPU = GOMAXPROCS / 4 worker goroutines).

Очень важно для контейнеров: runtime.NumCPU() возвращает количество видимых ядер хоста, а не CPU limit контейнера. Если у вас в k8s pod cpu: "2", но хост 64-ядерный — Go запустит 64 P и будет жёстко страдать от:

  • Лишних context-switches.
  • Throttling (CPU period exhausted).
  • GC backpressure (24 worker’а на 2 ядра — ад).

Решения:

  1. Установить вручную: runtime.GOMAXPROCS(2) в main().
  2. uber/automaxprocs: библиотека, читает /sys/fs/cgroup/cpu.max (cgroup v2) или cpu.cfs_quota_us/cpu.cfs_period_us (v1) и ставит GOMAXPROCS = quota / period (округлено вверх).
  3. GOMAXPROCS=N через env в Deployment.

В Go 1.25+ runtime сам читает cgroup limits (см. CL в Go upstream); пока — automaxprocs.

import (
_ "go.uber.org/automaxprocs"
)

Просто импорт; в init() библиотеки она сделает всё.

runtime.LockOSThread() // эта G теперь привязана к текущему M намертво
runtime.UnlockOSThread() // отвязать

Когда нужно:

  • CGO с TLS (thread-local storage в C): например, OpenGL контекст, signalfd, setns (Linux namespaces).
  • Сигналы: обработка signal.Notify с конкретным потоком.
  • Native libraries, которые ждут от вас один и тот же поток.

Эффекты:

  • G не может быть «украдена» другим P через work-stealing.
  • M не вернётся в pool после завершения G (если M был создан только под локированную G).
  • Если эта G блокируется (chan, mutex) — весь её M ждёт.

Антипаттерн: просто так лочить горутину к потоку. Это убивает M:N.

Эмпирические замеры (Linux, x86-64, 2024):

ОперацияВремя
Goroutine creation (go f())80–250 ns
Goroutine switch (chan recv → send)150–300 ns
OS thread context switch (kernel)1–5 µs
Mutex lock/unlock (uncontended)15–40 ns
Mutex lock/unlock (contended)500 ns – 50 µs
Channel send/recv (буферизованный, без блокировки)50–100 ns

Сравните: thread switch в 20–30 раз дороже goroutine switch. Поэтому в Go нормально иметь 100k+ горутин.

// НЕТ runtime.GoID() публичного API.

Причины:

  1. Анти-паттерн. Goroutine-local storage (как ThreadLocal в Java) ведёт к скрытому состоянию и багам.
  2. Корректность. ID может переиспользоваться после _Gdead → _Grunnable (но сейчас Go этого не делает, на самом деле id монотонно растут).
  3. Идиоматика. Передавайте контекст через параметры (например, context.Context).

Если очень нужно (для логов или дебага), есть хак через runtime.Stack:

func goID() int64 {
b := make([]byte, 64)
n := runtime.Stack(b, false)
s := string(b[:n])
// "goroutine 123 [running]:\n..."
// парсим число между "goroutine " и " ["
...
}

Не делайте этого в hot path — runtime.Stack дорогая.

Окно терминала
# В программе:
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... работа ...
# Или в http server:
import _ "net/http/pprof"
# curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
# Анализ:
go tool trace trace.out
# Откроется в браузере, увидите:
# - Goroutine analysis
# - Network blocking profile
# - Synchronization blocking profile
# - Syscall blocking profile
# - Scheduler latency profile

Что искать:

  • Scheduler latency — время между _Grunnable и _Grunning. Если > 1 ms — где-то starvation.
  • Goroutine states — много _Gwaiting на одной мутаксе → contention.
  • Syscalls — длинные syscall’ы → возможно, утечка в CGO.

// До Go 1.14: горутина зависала, GC не мог стартовать
func badPre114() {
for {
// нет вызовов функций - нет preemption
}
}

Сейчас не проблема, но если код пишется для Go 1.14+, можно встретить runtime.Gosched() как «исторический» костыль:

for i := 0; i < N; i++ {
work()
if i%1000 == 0 {
runtime.Gosched() // больше не нужно
}
}

С Go 1.14+ runtime.Gosched() редко нужен — async preempt сделает то же.

// CGO call:
import "C"
C.some_long_running_function()
// На время C-вызова M не может быть преrupted, P не отдаётся быстро.

Если CGO часто — настройте GOMAXPROCS больше + следите за cgo_calls в runtime.MemStats.

3.3. Один большой syscall блокирует много горутин (через handoff)

Заголовок раздела «3.3. Один большой syscall блокирует много горутин (через handoff)»

Long syscall (file write на slow disk) → M в syscall → sysmon handoff P → новый M создан. Если у вас 10000 файловых горутин — будет 10000 OS-потоков (по 8 KB каждый = 80 MB).

Решение: пул горутин (worker pool с фиксированным размером) для файлового IO, или использовать io_uring (Go 1.23+ имеет экспериментальную поддержку через golang.org/x/sys).

На многосокетных серверах P/M не привязаны к NUMA-узлам. Go runtime не оптимизирован под NUMA. Если у вас 2-сокет 64-ядерный сервер, иногда лучше:

  • Запустить 2 процесса по 32 ядра с CPU pinning.
  • Или один процесс, но с GODEBUG=schedtrace=1000 посмотреть, нет ли проблем.

Channel — не панацея. Send/recv ≈ 100 ns + потенциальный goroutine switch. Для high-throughput иногда лучше:

  • sync/atomic для счётчиков.
  • sync.Pool для горячих объектов.
  • Lock-free queue (например, cespare/xxhash-style ring buffer).
// Не делайте этого:
for {
runtime.Gosched() // принудительно yield на каждой итерации
work()
}

Это убивает производительность — на каждой итерации полный round-trip через scheduler. Async preempt сделает то же раз в 10 ms.

go func() {
runtime.LockOSThread()
// ... но мы забыли UnlockOSThread
// M, привязанный к этой G, не вернётся в пул!
}()

Если такие горутины часто создаются — утечка потоков (видна в runtime.NumThread()).


# Bad (Go считает все ядра хоста):
resources:
limits:
cpu: "2"
requests:
cpu: "1"
# Без automaxprocs Go runtime увидит 64 ядра хоста.
# Good — вариант 1: env
env:
- name: GOMAXPROCS
value: "2"
# Good — вариант 2: automaxprocs (auto-detect cgroup limits)
import _ "go.uber.org/automaxprocs"

Без automaxprocs симптомы:

  • p99 latency 200ms вместо 20ms (throttling).
  • CPU usage 100% при нагрузке 50% от номинала (context switch overhead).
  • kubectl top pod показывает throttle > 0.
Окно терминала
# Каждую секунду печатать stats scheduler:
GODEBUG=schedtrace=1000 ./myapp
# Пример вывода:
# SCHED 1000ms: gomaxprocs=8 idleprocs=2 threads=15 spinningthreads=1
# idlethreads=0 runqueue=12 [3 5 0 2 0 0 1 1]
# Расшифровка:
# gomaxprocs — N логических процессоров (P)
# idleprocs — P без работы
# threads — общее число M
# spinningthreads — M в spinning state
# idlethreads — M в park (idle pool)
# runqueue — длина global run queue
# [3 5 0 2 ...] — длина local run queue каждого P

Если runqueue стабильно > 50 и idleprocs > 0 — где-то баг (горутины не попадают в P).

Если runqueue короткая, но [10 10 0 0 ...] — асимметрия нагрузки.

Окно терминала
# Подробнее, каждое scheduling событие:
GODEBUG=schedtrace=1000,scheddetail=1 ./myapp
Окно терминала
# Через pprof:
curl http://localhost:6060/debug/pprof/profile?seconds=30 > cpu.pprof
go tool pprof -http=:8080 cpu.pprof
# Ищите runtime.findrunnable, runtime.gopark, runtime.schedule
# Через trace:
curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
go tool trace trace.out
# Smart link: "Scheduler latency profile"

Метрики, которые надо собирать (Prometheus):

// /metrics endpoint
goroutines.Set(float64(runtime.NumGoroutine()))
threads.Set(float64(runtime.NumThread()))
gomaxprocs.Set(float64(runtime.GOMAXPROCS(0)))

Если NumGoroutine неограниченно растёт — leak. Типичные источники:

  • go func() {}() в цикле без context.
  • Forgotten chan recv (одна сторона не закрывает).
  • Forgotten Wait Group.

Вместо go work() на каждый запрос:

type pool struct {
jobs chan func()
}
func newPool(n int) *pool {
p := &pool{jobs: make(chan func(), 1000)}
for i := 0; i < n; i++ {
go p.worker()
}
return p
}
func (p *pool) worker() {
for job := range p.jobs {
job()
}
}
func (p *pool) Submit(job func()) {
p.jobs <- job
}

Контролируем максимум горутин, избегаем «100k горутин в syscall на диск».

Если у вас в горячем коде:

for {
buf := make([]byte, 4096)
use(buf)
}

Это аллокация → GC. Замените:

var pool = sync.Pool{
New: func() any { return make([]byte, 4096) },
}
for {
buf := pool.Get().([]byte)
use(buf)
pool.Put(buf)
}

sync.Pool per-P: каждый P держит свой кэш, без contention. После каждого GC pool очищается — это «дешёвый» кэш, не «долгое хранилище».

4.7. Pinning горутин к одному P (не идиоматично, но иногда нужно)

Заголовок раздела «4.7. Pinning горутин к одному P (не идиоматично, но иногда нужно)»

Для CPU-affinity нет публичного API. Workaround:

runtime.LockOSThread()
// Внутри C-кода вызвать sched_setaffinity (Linux):
// или syscall.Syscall6(syscall.SYS_SCHED_SETAFFINITY, ...)

Используется редко — например, в HFT, для low-latency network IO.


  1. Объясни модель M:N в Go. Что такое G, M, P?
  2. Чем отличается P от M?
  3. Сколько горутин одновременно реально выполняются на CPU при GOMAXPROCS=4? (Не более 4.)
  4. Что такое local run queue, какой размер, что происходит при переполнении? (256, половина в global.)
  5. Опиши алгоритм work stealing. У кого крадут? Сколько крадут? (Случайный P, половину очереди.)
  6. Что такое global run queue и когда из неё берут? (Раз в 61 такт + при опустошении local.)
  7. Что такое netpoll? Как Go обрабатывает блокирующие сетевые вызовы?
  8. В чём разница между cooperative и async preemption?
  9. Почему до Go 1.14 tight loop без вызовов функций мог зависнуть?
  10. Что такое SIGURG и зачем он используется?
  11. Опиши состояния горутины (_Grunnable, _Grunning, _Gwaiting, _Gsyscall, _Gdead).
  12. Что происходит, когда горутина делает блокирующий syscall? (M handoffs P.)
  13. Что такое sysmon и какие задачи у него?
  14. Зачем нужен g0 в каждом M? (Системный стек для runtime-кода.)
  15. Какой начальный размер стека горутины и как он растёт? (2 KB, удваивается, через morestack.)
  16. Где runtime/runtime2.go хранит state регистров горутины при swap? (g.sched gobuf.)
  17. Что такое runnext и зачем он? (Приоритетный слот, локальность кэша после go f().)
  18. Почему в Go нет публичного goroutine ID?
  19. Чему по умолчанию равно GOMAXPROCS? (runtime.NumCPU().)
  20. Какие проблемы с GOMAXPROCS в Kubernetes без automaxprocs?
  21. Что делает runtime.LockOSThread и когда нужно?
  22. Что такое spinning thread и зачем оно?
  23. Сколько обычно spinning threads держит runtime? (До GOMAXPROCS/2.)
  24. Как посмотреть scheduler trace в проде? (GODEBUG=schedtrace=1000.)
  25. Как обнаружить leak горутин? (/debug/pprof/goroutine, метрики runtime.NumGoroutine.)
  26. Чем отличается context switch горутины от OS-потока (по времени)? (~150 ns vs ~1–5 µs.)
  27. Что произойдёт, если CGO-вызов длится 1 минуту? (M блокирован, sysmon отдаст P, новый M создастся.)
  28. Зачем нужен runtime.Gosched() и стоит ли его использовать сейчас?
  29. Как async preemption влияет на atomic-операции? (Signal проверяет safe point.)
  30. Можно ли иметь больше M, чем GOMAXPROCS? Когда и почему? (Да, в syscall, LockOSThread, при handoff.)

package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1) // принудительно 1 P, чтобы было заметно
go func() {
for {
// tight loop, без вызовов функций
}
}()
time.Sleep(100 * time.Millisecond)
runtime.GC() // в Go 1.13 это бы зависло
fmt.Println("GC прошёл, async preemption работает!")
}
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(4)
var wg sync.WaitGroup
// Запускаем 1000 горутин — каждая выполнится на любом P
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Какая-то работа
sum := 0
for j := 0; j < 1e6; j++ {
sum += j
}
_ = sum
}(i)
}
wg.Wait()
fmt.Println("done; нагрузка распределилась через work stealing")
}

Запустите с GODEBUG=schedtrace=100 и понаблюдайте, как local queue растут/уменьшаются на разных P.

package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"runtime"
"time"
)
func main() {
go http.ListenAndServe(":6060", nil)
// Намеренный leak:
for i := 0; i < 1000; i++ {
go func() {
ch := make(chan struct{})
<-ch // блокировка навсегда
}()
}
for {
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
time.Sleep(time.Second)
}
// curl http://localhost:6060/debug/pprof/goroutine?debug=1
// увидите 1000 горутин в chan recv
}
package main
import (
"fmt"
"io"
"os"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(2)
var wg sync.WaitGroup
f, _ := os.Open("/dev/zero") // bytes навечно
defer f.Close()
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
buf := make([]byte, 1<<20) // 1 MB
for j := 0; j < 10; j++ {
io.ReadFull(f, buf) // syscall.Read
}
}()
}
// В параллели наблюдаем:
go func() {
for {
fmt.Printf("threads=%d goroutines=%d\n",
runtime.NumGoroutine(), pprofNumThread())
}
}()
wg.Wait()
}
func pprofNumThread() int {
// через runtime/debug.Stack() или /proc/self/status: Threads:
return 0 // упрощённо
}
package main
import (
"context"
"fmt"
"sync"
)
type Pool struct {
jobs chan func()
wg sync.WaitGroup
}
func New(ctx context.Context, workers int) *Pool {
p := &Pool{jobs: make(chan func(), workers*10)}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.worker(ctx)
}
return p
}
func (p *Pool) worker(ctx context.Context) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-p.jobs:
if !ok {
return
}
job()
}
}
}
func (p *Pool) Submit(job func()) {
p.jobs <- job
}
func (p *Pool) Close() {
close(p.jobs)
p.wg.Wait()
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := New(ctx, 4)
defer pool.Close()
for i := 0; i < 20; i++ {
i := i
pool.Submit(func() { fmt.Println("job", i) })
}
}
package main
import (
"fmt"
"runtime"
_ "go.uber.org/automaxprocs"
)
func main() {
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("NumCPU:", runtime.NumCPU())
}

Запустите в Docker с лимитом --cpus=2:

Окно терминала
docker run --rm --cpus=2 myimage
# GOMAXPROCS: 2
# NumCPU: <ядра хоста>

Без automaxprocs GOMAXPROCS = NumCPU.


  1. Go source code: src/runtime/proc.go, runtime2.go, mgcmark.gohttps://github.com/golang/go/tree/master/src/runtime
  2. Dmitry Vyukov — Scalable Go Scheduler Design Doc (2012, базовая статья) — https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw
  3. Async preemption proposal (Austin Clements, Go 1.14): https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md
  4. Ardan Labs — Scheduling in Go (трилогия Bill Kennedy):
  5. JBD — Goroutine stack: https://rakyll.org/scheduler/
  6. Madhav Jivrajani — A Deep Dive Into the Golang Scheduler (GopherCon UK 2024) — https://www.youtube.com/results?search_query=go+scheduler+deep+dive
  7. Uber automaxprocs: https://github.com/uber-go/automaxprocs
  8. The Go Memory Model: https://go.dev/ref/mem
  9. runtime package docs: https://pkg.go.dev/runtime
  10. Diagnostics guide: https://go.dev/doc/diagnostics