GMP Scheduler
Планировщик Go — это сердце runtime, превращающее тысячи горутин в эффективную параллельную работу на ограниченном числе OS threads. В этой статье разберём внутренности GMP-модели, work stealing, preemption и диагностику планировщика.
TL;DR
| Характеристика | Значение |
|---|---|
| G (Goroutine) | Легковесный поток, начальный стек ~2KB |
| M (Machine) | OS thread, выполняет код горутин |
| P (Processor) | Логический процессор, GOMAXPROCS штук |
| Local Run Queue | До 256 G на P, lock-free circular buffer |
| Global Run Queue | Overflow + orphaned G, linked list с mutex |
| Work Stealing | P крадёт ровно 50% очереди у других |
| Preemption | Async с Go 1.14 (signal-based, SIGURG) |
| sysmon | Системный монитор, 20μs→10ms adaptive sleep |
G-M-P модель
Зачем трёхуровневая модель?
Наивный подход 1:1 (goroutine = OS thread) не работает:
- Создание thread ~1MB стека + syscall overhead
- Context switch через kernel — дорого
- Миллион горутин = миллион threads = OOM
Go использует M:N модель с дополнительным уровнем абстракции:
┌─────────────────────────────────────────────────────────────┐
│ User Space │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ G │ │ G │ │ G │ │ G │ │ G │ │ G │ ... │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │ │ │
│ ┌──┴───────┴───────┴──┐ ┌──┴───────┴───────┴──┐ │
│ │ P0 │ │ P1 │ ... │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ Local Queue │ │ │ │ Local Queue │ │ │
│ │ └───────────────┘ │ │ └───────────────┘ │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ │
│ │ │ │
│ ┌──────────┴──────────┐ ┌──────────┴──────────┐ │
│ │ M0 │ │ M1 │ ... │
│ └──────────┬──────────┘ └──────────┬──────────┘ │
├─────────────┼───────────────────────┼───────────────────────┤
│ │ Kernel Space │ │
│ ┌──────────┴──────────┐ ┌──────────┴──────────┐ │
│ │ OS Thread 0 │ │ OS Thread 1 │ ... │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘P (Processor) — ключевое звено:
- Ограничивает параллелизм до GOMAXPROCS
- Содержит локальные ресурсы (mcache, LRQ)
- Позволяет M переключаться между P при syscalls
G-M-P Model
Структуры runtime
runtime.g — горутина
// runtime/runtime2.go
type g struct {
stack stack // stack.lo и stack.hi — границы стека
stackguard0 uintptr // для проверки stack overflow в прологе
m *m // текущий M, выполняющий эту G (или nil)
sched gobuf // контекст: SP, PC, BP для переключения
atomicstatus atomic.Uint32 // _Grunnable, _Grunning, _Gwaiting...
preempt bool // запрос на preemption
preemptStop bool // для STW (stop-the-world)
lockedm *m // LockOSThread() привязывает G к M
waiting *sudog // список ожидания для каналов
goid uint64 // уникальный ID горутины
}runtime.m — машина (OS thread)
type m struct {
g0 *g // системная горутина для scheduler кода
curg *g // текущая пользовательская горутина
p *p // привязанный P (nil при syscall/idle)
nextp *p // P для следующего запуска
oldp *p // P перед syscall (для быстрого возврата)
spinning bool // M в режиме work stealing
blocked bool // заблокирован на syscall
park note // семафор для parking/unparking
schedlink *m // linked list для idle M pool
}runtime.p — процессор
type p struct {
status uint32 // _Pidle, _Prunning, _Psyscall, _Pgcstop
m *m // привязанный M (nil если idle)
// Local Run Queue — lock-free circular buffer
runqhead uint32
runqtail uint32
runq [256]guintptr // фиксированный размер!
runnext guintptr // следующая G для запуска (fast path)
// Локальные ресурсы
mcache *mcache // кэш аллокатора для этого P
gFree gList // кэш свободных G структур
// GC
gcBgMarkWorker *g // background mark worker
}Очереди планировщика
Local Run Queue (LRQ)
Каждый P имеет локальную очередь на 256 горутин:
P0.runq (circular buffer):
┌─────────────────────────────────────────────────┐
│ [0] [1] [2] [3] [4] ... [255] │
│ G G G G G │
│ ↑ ↑ │
│ head tail │
└─────────────────────────────────────────────────┘
runqhead = 0 (consumer берёт отсюда)
runqtail = 5 (producer добавляет сюда)Особенности:
- Lock-free операции через atomic
- Фиксированный размер 256 — overflow идёт в GRQ
runnext— fast path для только что созданных горутин
Global Run Queue (GRQ)
type schedt struct {
lock mutex
runq gQueue // linked list горутин
runqsize int32 // размер очереди
// Idle ресурсы
midle *m // idle M list
pidle *p // idle P list
nmidlelocked int32
}Когда G попадает в GRQ:
- Overflow из LRQ (> 256 горутин)
- Горутина разблокировалась, а её P занят
runtime.Gosched()с переполненным LRQ
Network Poller
epoll/kqueue/IOCP
│
▼
┌──────────────┐
│ netpoller │ ── готовые G возвращаются
│ │ в scheduler
│ [waiting G] │
│ [waiting G] │
└──────────────┘Network poller интегрирован в scheduler:
- Горутины на IO блокируются без занятия M
netpoll()вызывается вfindrunnable()для получения ready G
Цикл планировщика
schedule() — главный цикл
// runtime/proc.go
func schedule() {
mp := getg().m
top:
pp := mp.p.ptr()
// Проверка preemption для GC STW
if sched.gcwaiting.Load() {
gcstopm()
goto top
}
// Найти горутину для выполнения
gp, inheritTime, tryWakeP := findRunnable()
// Выполнить горутину
execute(gp, inheritTime)
}findrunnable() — поиск работы
func findrunnable() (gp *g, inheritTime, tryWakeP bool) {
mp := getg().m
pp := mp.p.ptr()
// 1. Локальная очередь
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// 2. Глобальная очередь (каждые 61 тиков!)
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 3. Network poller
if netpollinited() && netpollAnyWaiters() {
if list, delta := netpoll(0); !list.empty() {
gp := list.pop()
injectglist(&list) // остальные в GRQ
return gp, false, false
}
}
// 4. Work stealing
gp, inheritTime, _, _ = stealWork(now, &newWork)
if gp != nil {
return gp, inheritTime, false
}
// 5. Ничего нет — parking
stopm()
goto top
}Магическое число 61: проверка GRQ каждые 61 тиков предотвращает starvation глобальных горутин при активном work stealing. Почему именно 61?
- Простое число — не делится ни на какие типичные значения (количество CPU, размеры кэш-линий, степени двойки)
- Избежание lock contention — если бы использовалось, например, 64, все P могли бы синхронизироваться и одновременно обращаться к
sched.lock - Равномерное распределение — простое число гарантирует, что разные P будут проверять GRQ в разные моменты времени, даже если стартовали синхронно
Work Stealing
Когда P исчерпал свою LRQ, он крадёт работу у других P:
func stealWork(now int64) (gp *g, ...) {
pp := getg().m.p.ptr()
// Рандомный порядок обхода P
for i := 0; i < 4; i++ {
// Выбираем случайную жертву
victim := allp[fastrandn(uint32(gomaxprocs))]
if victim == pp {
continue
}
// Крадём РОВНО половину очереди
if gp := runqsteal(pp, victim, stealRunNextG); gp != nil {
return gp, false
}
}
return nil, false
}
func runqsteal(pp, victim *p, stealRunNextG bool) *g {
t := victim.runqtail
h := victim.runqhead
n := t - h // количество в очереди жертвы
n = n - n/2 // крадём половину
// Копируем G из victim.runq в pp.runq
for i := uint32(0); i < n; i++ {
gp := victim.runq[(h+i) % 256]
pp.runq[(pp.runqtail+i) % 256] = gp
}
// Атомарно обновляем указатели
atomic.StoreUint32(&pp.runqtail, pp.runqtail+n)
atomic.StoreUint32(&victim.runqhead, h+n)
return pp.runq[pp.runqhead].ptr()
}Work Stealing Simulator
Preemption
Cooperative Preemption (до Go 1.14)
Проверка в прологе каждой функции:
TEXT ·myFunc(SB), $0
MOVQ (TLS), CX // g в CX
CMPQ SP, 16(CX) // SP vs g.stackguard0
JLS morestack // если меньше — прерывание
// тело функции...Проблема: tight loop без вызовов функций никогда не прерывается:
// Этот код блокирует P навсегда до Go 1.14!
func tightLoop() {
for {
sum += i
}
}Async Preemption (Go 1.14+)
Signal-based preemption через SIGURG:
// runtime/signal_unix.go
func preemptM(mp *m) {
signalM(mp, sigPreempt) // посылаем SIGURG
}
// Обработчик сигнала
func sighandler(sig uint32, ...) {
if sig == sigPreempt {
doSigPreempt(gp, ctxt)
}
}
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Проверяем safe point
if wantAsyncPreempt(gp) {
// Модифицируем контекст для возврата в asyncPreempt
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}sysmon периодически проверяет и прерывает долгоработающие горутины:
func sysmon() {
for {
usleep(delay)
// Проверяем каждый P
for _, pp := range allp {
s := pp.status
if s == _Prunning {
t := pp.schedtick
if t == pp.sysmontick.schedtick {
// P не переключался — preempt!
preemptone(pp)
}
}
}
}
}Правильное использование runtime.Gosched()
// Явная точка переключения
func processItems(items []Item) {
for i, item := range items {
process(item)
// Уступаем процессор каждые 1000 итераций
if i%1000 == 0 {
runtime.Gosched()
}
}
}Когда НЕ нужен Gosched()
С Go 1.14+ async preemption работает автоматически. Gosched() нужен только для:
- Явного контроля fairness
- Совместимости со старыми версиями Go
- Специфичных сценариев с
GOMAXPROCS=1
System Calls и Handoff
При blocking syscall M освобождает P для других горутин:
Before syscall:
┌────────────────────────────────┐
│ M0 ←──────→ P0 │
│ │ │ │
│ └── curg: G1 (running) │
└────────────────────────────────┘
During syscall:
┌────────────────────────────────┐
│ M0 (blocked in syscall) │
│ │ │
│ └── G1 (waiting) │
│ │
│ M1 ←──────→ P0 (handoff!) │
│ │ │ │
│ └── curg: G2 (running) │
└────────────────────────────────┘
After syscall:
┌────────────────────────────────┐
│ M0 пытается: │
│ 1. Забрать P0 обратно (oldp) │
│ 2. Взять любой idle P │
│ 3. Положить G1 в GRQ и park │
└────────────────────────────────┘// runtime/proc.go
func entersyscall() {
// Сохраняем P в oldp для быстрого возврата
pp := mp.p.ptr()
mp.oldp.set(pp)
// Отвязываем P от M
pp.m = 0
mp.p = 0
pp.status = _Psyscall
}
func exitsyscall() {
// Пробуем вернуть свой P
if oldp := mp.oldp.ptr(); oldp != nil && oldp.status == _Psyscall {
if atomic.Cas(&oldp.status, _Psyscall, _Prunning) {
mp.p.set(oldp)
return
}
}
// Берём любой idle P
if pp := pidleget(); pp != nil {
mp.p.set(pp)
return
}
// Нет свободных P — паркуем M, G в GRQ
globrunqput(gp)
stopm()
}handoffp() вызывается sysmon когда обнаружен P в состоянии _Psyscall:
func handoffp(pp *p) {
// Ищем или создаём M для этого P
if sched.runqsize > 0 || sched.npidle == 0 {
startm(pp, true) // запускаем M для P
return
}
// Все спокойно — P в idle
pidleput(pp)
}GOMAXPROCS
GOMAXPROCS определяет количество P (логических процессоров), которые могут одновременно выполнять Go-код. По умолчанию равен runtime.NumCPU().
// Получить текущее значение (0 не меняет, только возвращает)
current := runtime.GOMAXPROCS(0)
// Установить новое значение
runtime.GOMAXPROCS(4)Как GOMAXPROCS влияет на планировщик
GOMAXPROCS = 2: GOMAXPROCS = 4:
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ P0 │ │ P1 │ │ P0 │ │ P1 │ │ P2 │ │ P3 │
│ LRQ │ │ LRQ │ │ LRQ │ │ LRQ │ │ LRQ │ │ LRQ │
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │ │ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│ M0 │ │ M1 │ │ M0 │ │ M1 │ │ M2 │ │ M3 │
└─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘
Max 2 горутины Max 4 горутины
выполняются параллельно выполняются параллельноКлючевой момент: GOMAXPROCS ограничивает только количество P. Количество M (OS threads) может быть больше — M создаются при blocking syscalls и паркуются когда не нужны.
CPU-bound vs IO-bound
CPU-bound нагрузка
Задачи, которые постоянно используют CPU: вычисления, сжатие, шифрование, парсинг.
// CPU-bound: вычисление хэшей
func hashFiles(files []string) {
for _, f := range files {
data, _ := os.ReadFile(f)
sha256.Sum256(data) // CPU работает 100% времени
}
}Почему GOMAXPROCS = NumCPU() оптимален:
- Каждое ядро CPU может выполнять только один поток в единицу времени
- Если P > CPU cores, лишние P будут ждать в очереди на выполнение
- Каждое переключение между P на одном ядре — это context switch (~1-10μs)
- При
GOMAXPROCS = 8на 4-ядерном CPU: 4 P работают, 4 P ждут, удваивая context switches
4 CPU cores, GOMAXPROCS = 8:
Core 0: P0 ──▶ P4 ──▶ P0 ──▶ P4 (постоянные переключения)
Core 1: P1 ──▶ P5 ──▶ P1 ──▶ P5
Core 2: P2 ──▶ P6 ──▶ P2 ──▶ P6
Core 3: P3 ──▶ P7 ──▶ P3 ──▶ P7
↑ ↑
context switches = overhead без пользыIO-bound нагрузка
Задачи, которые часто ждут внешние ресурсы: сеть, диск, базы данных.
// IO-bound: HTTP запросы
func fetchURLs(urls []string) {
for _, url := range urls {
resp, _ := http.Get(url) // 99% времени ждём сеть
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}Почему GOMAXPROCS > NumCPU() может помочь (но не всегда):
При blocking syscall (сетевой запрос, файловый IO) происходит handoff:
Горутина делает syscall:
1. M0 отдаёт P0 другому M 2. P0 продолжает работать
┌─────┐ ┌─────┐
│ P0 │──→ handoff ──→ │ P0 │
└──┬──┘ └──┬──┘
│ │
┌──┴──┐ ┌──┴──┐
│ M0 │ (blocked on syscall) │ M1 │ (выполняет другие G)
└─────┘ └─────┘Однако сетевые операции в Go используют netpoller (epoll/kqueue) и не блокируют M. Поэтому даже при IO-bound нагрузке GOMAXPROCS > NumCPU() даёт минимальный выигрыш.
Когда увеличение GOMAXPROCS реально помогает:
- CGO вызовы, которые блокируют M
- Blocking syscalls без netpoller (некоторые файловые операции)
- DNS резолвинг через системный резолвер
Практические рекомендации
| Нагрузка | GOMAXPROCS | Обоснование |
|---|---|---|
| CPU-bound | NumCPU() | Больше P только добавит context switch overhead |
| IO-bound (net) | NumCPU() | Netpoller не блокирует M, лишние P не помогут |
| IO-bound (CGO/syscalls) | NumCPU() * 2 | Компенсирует заблокированные M |
| Неизвестно | NumCPU(), затем профилировать | schedtrace покажет утилизацию P |
Container-aware (Go 1.25)
Проблема: NumCPU() в контейнерах
runtime.NumCPU() использует системный вызов, который возвращает количество CPU хоста, игнорируя ограничения контейнера:
# 64-ядерный хост, контейнер с лимитом 2 CPU
docker run --cpus=2 myapp
# Внутри контейнера:
# runtime.NumCPU() = 64 ← видит все ядра хоста!
# GOMAXPROCS = 64 ← по умолчанию равен NumCPU()Результат: 64 P конкурируют за квоту в 2 CPU. Планировщик Go пытается загрузить все 64 P работой, но cgroups душит процесс после исчерпания квоты.
Как работает CPU throttling
Linux cgroups ограничивает CPU время через механизм квот:
cgroups v2: /sys/fs/cgroup/cpu.max
Формат: $QUOTA $PERIOD (микросекунды)
Пример: "200000 100000" = 200ms из каждых 100ms = 2 CPU
cgroups v1: /sys/fs/cgroup/cpu/cpu.cfs_quota_us
/sys/fs/cgroup/cpu/cpu.cfs_period_usВременная шкала (period = 100ms, quota = 200ms = 2 CPU):
0ms 100ms 200ms 300ms
│──────────│──────────│──────────│
│▓▓▓▓▓▓▓▓▓▓│░░░░░░░░░░│▓▓▓▓▓▓▓▓▓▓│ ← CPU 0
│▓▓▓▓▓▓▓▓▓▓│░░░░░░░░░░│▓▓▓▓▓▓▓▓▓▓│ ← CPU 1
│░░░░░░░░░░│░░░░░░░░░░│░░░░░░░░░░│ ← CPU 2-63 (квота исчерпана)
▓ = работает ░ = throttled (ждёт новый period)При 64 P на 2 CPU квоте:
- Каждый P получает ~3% CPU времени вместо 100%
- Постоянные context switches между 64 P
- Периодические "заморозки" когда квота исчерпана (throttling)
Диагностика throttling
# Проверить текущий throttling (cgroups v2)
cat /sys/fs/cgroup/cpu.stat
# usage_usec 123456789 ← использованное CPU время
# nr_periods 12345 ← количество periods
# nr_throttled 1234 ← сколько раз throttled (должно быть ~0!)
# throttled_usec 56789000 ← время в throttled состоянии
# Проверить лимиты
cat /sys/fs/cgroup/cpu.max
# 200000 100000 ← quota period (2 CPU)// Программная проверка throttling
func checkThrottling() {
data, _ := os.ReadFile("/sys/fs/cgroup/cpu.stat")
// Парсим nr_throttled — если растёт, GOMAXPROCS слишком большой
fmt.Println(string(data))
}Симптомы неправильного GOMAXPROCS в контейнере
- P99 latency spikes — периодические задержки когда квота исчерпана
- Нестабильный throughput — пилообразный график RPS
- Высокий CPU usage при низкой полезной работе — overhead на context switches
- nr_throttled растёт в cpu.stat
Решение до Go 1.25: automaxprocs
import _ "go.uber.org/automaxprocs"
// При импорте автоматически:
// 1. Определяет cgroups v1 или v2
// 2. Читает quota и period
// 3. Вычисляет: GOMAXPROCS = quota / period (округление вниз)
// 4. Вызывает runtime.GOMAXPROCS()Как automaxprocs вычисляет значение:
// Псевдокод логики automaxprocs
func determineGOMAXPROCS() int {
// Пробуем cgroups v2
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil {
// Формат: "quota period" или "max period"
var quota, period int
fmt.Sscanf(string(data), "%d %d", "a, &period)
if quota > 0 {
return quota / period // например 200000/100000 = 2
}
}
// Fallback на cgroups v1
quota, _ := readInt("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
period, _ := readInt("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
if quota > 0 && period > 0 {
return quota / period
}
// Нет лимитов — используем NumCPU()
return runtime.NumCPU()
}Go 1.25+: встроенная поддержка
Go 1.25 добавил автоматическое определение CPU лимитов контейнера в runtime:
// runtime/os_linux.go (Go 1.25+)
func osinit() {
ncpu = getproccount() // сначала читает /proc/cpuinfo
// Новое: проверяем cgroups quota
if quota := getCgroupCPUQuota(); quota > 0 {
if quota < ncpu {
ncpu = quota
}
}
}Что изменилось:
runtime.NumCPU()теперь учитывает cgroups v2 лимитыGOMAXPROCSпо умолчанию корректен в контейнерах- Библиотека automaxprocs больше не нужна (но не вредит)
// Go 1.25+ — просто работает
func main() {
// В контейнере с --cpus=2:
fmt.Println(runtime.NumCPU()) // 2 (не 64!)
fmt.Println(runtime.GOMAXPROCS(0)) // 2
}Kubernetes: requests vs limits
В Kubernetes CPU ресурсы задаются двумя параметрами:
resources:
requests:
cpu: "500m" # 0.5 CPU — для планировщика K8s
limits:
cpu: "2000m" # 2 CPU — жёсткий лимит (cgroups quota)requests — минимум гарантированного CPU, используется планировщиком K8s для размещения пода. Не влияет на cgroups.
limits — максимум CPU, транслируется в cgroups quota. Именно это значение видит Go runtime.
K8s spec cgroups v2
─────────────────────────────────────────
cpu.limits: 2000m → cpu.max: "200000 100000"
cpu.limits: 500m → cpu.max: "50000 100000"
cpu.limits: не задан → cpu.max: "max 100000" (без лимита)Частая ошибка
# Плохо: нет limits, только requests
resources:
requests:
cpu: "500m"
# GOMAXPROCS = NumCPU() хоста (может быть 64+)
# Pod получит минимум 0.5 CPU, но Go создаст 64 P
# Хорошо: явные limits
resources:
requests:
cpu: "500m"
limits:
cpu: "2000m"
# GOMAXPROCS = 2 (Go 1.25+ автоматически)Дробные CPU лимиты
Что происходит при лимите меньше 1 CPU:
limits:
cpu: "500m" # 0.5 CPU// Go округляет вниз, минимум 1
// 500m → quota=50000, period=100000 → 50000/100000 = 0 → GOMAXPROCS = 1
runtime.GOMAXPROCS(0) // 1При GOMAXPROCS=1 с квотой 0.5 CPU:
- Один P работает 50ms из каждых 100ms
- Остальные 50ms — throttled
- Это нормально для sidecar контейнеров и лёгких сервисов
Ручная настройка в контейнерах
Иногда автоматика не подходит:
func init() {
// Переопределить автоматическое значение
if v := os.Getenv("GOMAXPROCS"); v != "" {
n, _ := strconv.Atoi(v)
runtime.GOMAXPROCS(n)
return
}
// Или программно для специфичных случаев
// Например: CGO-heavy приложение, нужно больше M
quota := getContainerCPUQuota()
if quota > 0 {
runtime.GOMAXPROCS(quota * 2) // удвоенное значение для CGO
}
}# Dockerfile / docker-compose
ENV GOMAXPROCS=4
# Kubernetes
env:
- name: GOMAXPROCS
value: "4"Мониторинг в production
Добавьте метрики для отслеживания:
// Prometheus metrics
prometheus.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{Name: "go_gomaxprocs"},
func() float64 { return float64(runtime.GOMAXPROCS(0)) },
))
prometheus.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{Name: "go_num_cpu"},
func() float64 { return float64(runtime.NumCPU()) },
))Динамическое изменение
runtime.GOMAXPROCS(n) можно вызывать в любой момент выполнения программы. Функция возвращает предыдущее значение и устанавливает новое (если n > 0).
// Получить текущее значение без изменения
current := runtime.GOMAXPROCS(0)
// Установить новое значение, сохранить старое
old := runtime.GOMAXPROCS(8)
// Вернуть обратно
runtime.GOMAXPROCS(old)Что происходит внутри runtime
При вызове GOMAXPROCS(n) runtime выполняет сложную процедуру изменения количества P:
// runtime/proc.go (упрощённо)
func GOMAXPROCS(n int) int {
lock(&sched.lock)
ret := int(gomaxprocs)
if n <= 0 || n == ret {
unlock(&sched.lock)
return ret
}
// STW: остановить все P
stopTheWorldGC("GOMAXPROCS")
// Изменить количество P
procresize(int32(n))
// Возобновить выполнение
startTheWorld()
unlock(&sched.lock)
return ret
}Ключевой момент: изменение GOMAXPROCS требует Stop-The-World паузы.
Почему нужен STW
Изменение количества P — это структурная перестройка планировщика:
GOMAXPROCS: 4 → 2 (уменьшение)
До: После:
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ P0 │ │ P1 │ │ P2 │ │ P3 │ │ P0 │ │ P1 │
│LRQ │ │LRQ │ │LRQ │ │LRQ │ │LRQ │ │LRQ │
│ 5G │ │ 3G │ │ 7G │ │ 2G │ │12G │ │ 5G │ ← горутины перераспределены
└────┘ └────┘ └────┘ └────┘ └────┘ └────┘
↑
P2, P3 уничтожены,
их горутины переданы P0, P1При уменьшении GOMAXPROCS:
- "Лишние" P останавливаются
- Горутины из их LRQ перемещаются в GRQ или другие P
- mcache освобождаются
- M отсоединяются от P и паркуются
При увеличении GOMAXPROCS:
- Создаются новые структуры P
- Выделяются mcache для каждого P
- Запускаются (или пробуждаются) M для новых P
- Горутины из GRQ распределяются по новым P
Стоимость операции
// Измерение стоимости GOMAXPROCS
func BenchmarkGOMAXPROCS(b *testing.B) {
for i := 0; i < b.N; i++ {
runtime.GOMAXPROCS(4)
runtime.GOMAXPROCS(8)
}
}
// Результат: ~50-200μs на операцию (зависит от количества горутин)Факторы, влияющие на длительность STW:
- Количество активных горутин (больше G → дольше перераспределение)
- Разница между старым и новым значением
- Размер LRQ на каждом P
- Количество M, которые нужно остановить
Не вызывайте GOMAXPROCS в hot path
// Плохо: STW на каждый запрос
func HandleRequest(w http.ResponseWriter, r *http.Request) {
runtime.GOMAXPROCS(runtime.NumCPU()) // STW пауза!
// ...
}
// Хорошо: один раз при старте
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
http.ListenAndServe(":8080", nil)
}Потокобезопасность
GOMAXPROCS потокобезопасен — можно вызывать из любой горутины. Однако concurrent вызовы сериализуются через sched.lock:
// Два параллельных вызова — один будет ждать
go runtime.GOMAXPROCS(4) // захватывает sched.lock
go runtime.GOMAXPROCS(8) // ждёт освобождения lockРеальные use cases
1. Адаптация к нагрузке (редко оправдано):
// Автоматическая подстройка под нагрузку
func adaptiveGOMAXPROCS(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Читаем метрики CPU
usage := getCurrentCPUUsage()
current := runtime.GOMAXPROCS(0)
if usage > 90 && current < runtime.NumCPU() {
// Высокая нагрузка — добавляем P
runtime.GOMAXPROCS(current + 1)
log.Printf("GOMAXPROCS increased to %d", current+1)
} else if usage < 30 && current > 1 {
// Низкая нагрузка — уменьшаем P (экономим ресурсы)
runtime.GOMAXPROCS(current - 1)
log.Printf("GOMAXPROCS decreased to %d", current-1)
}
}
}
}Почему это редко нужно
Go планировщик уже адаптивен: неактивные P не потребляют CPU. Динамическое изменение GOMAXPROCS имеет смысл только в специфичных сценариях (multi-tenant системы, жёсткие требования к изоляции ресурсов).
2. Тестирование race conditions:
func TestRaceCondition(t *testing.T) {
// Тест с разным уровнем параллелизма
for _, procs := range []int{1, 2, 4, 8} {
t.Run(fmt.Sprintf("GOMAXPROCS=%d", procs), func(t *testing.T) {
old := runtime.GOMAXPROCS(procs)
defer runtime.GOMAXPROCS(old)
// Тест с повышенным шансом race condition
testConcurrentAccess(t)
})
}
}3. Изоляция CPU-heavy операций:
// Ограничение CPU для фоновых задач
func runBackgroundJob(job Job) {
// Уменьшаем параллелизм для фоновой работы
// чтобы не мешать основным запросам
old := runtime.GOMAXPROCS(2)
defer runtime.GOMAXPROCS(old)
job.Execute()
}Проблема глобального состояния
GOMAXPROCS — глобальная настройка. Изменение в одной горутине влияет на всю программу:
// Горутина A // Горутина B (HTTP handler)
runtime.GOMAXPROCS(1) // Внезапно работает на 1 P!
defer runtime.GOMAXPROCS(old) // Latency вырос в N раз
heavyWork()Правильные альтернативы
Для ограничения параллелизма конкретной операции используйте worker pool или semaphore вместо изменения GOMAXPROCS:
Worker Pool
Фиксированное количество горутин-воркеров обрабатывает задачи из общей очереди. Количество горутин не растёт с количеством задач.
Когда использовать:
- CPU-bound задачи (оптимально
workers = GOMAXPROCS) - Длительные операции с предсказуемым временем выполнения
- Когда важен порядок обработки (FIFO через канал)
Особенности:
- Горутины переиспользуются — нет overhead на создание/уничтожение
- Память предсказуема: N воркеров + буфер канала
- Легко добавить graceful shutdown через
context
Схема работы Worker Pool:
items: [A, B, C, D, E, F, G, H] workers = 3
jobs channel
┌─────────────┐
Producer ─────────▶│ A B C D E F │
(main goroutine) └──────┬──────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Worker 1│ │ Worker 2│ │ Worker 3│
│ A → Ra │ │ B → Rb │ │ C → Rc │
│ D → Rd │ │ E → Re │ │ F → Rf │
│ G → Rg │ │ │ │ H → Rh │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└──────────────┼──────────────┘
▼
results channel
┌───────────────┐
│Ra Rb Rc Rd ...│
└───────┬───────┘
▼
Consumer (main goroutine)func processItems(items []Item, workers int) {
// 1. Создаём два канала с буфером на все элементы
jobs := make(chan Item, len(items)) // очередь задач
results := make(chan Result, len(items)) // очередь результатов
// 2. Запускаем N воркеров (горутин)
// Каждый воркер — бесконечный цикл, читающий из jobs
for i := 0; i < workers; i++ {
go func() {
// range по каналу: читает пока канал не закрыт
for item := range jobs {
// Обрабатываем задачу и пишем результат
results <- process(item)
}
// Воркер завершается когда jobs закрыт и пуст
}()
}
// 3. Producer: отправляем все задачи в очередь
for _, item := range items {
jobs <- item // блокируется если буфер полон
}
close(jobs) // сигнал воркерам: задач больше не будет
// 4. Consumer: собираем все результаты
// Знаем точное количество — по числу items
for range items {
<-results // порядок результатов НЕ гарантирован!
}
}Порядок результатов
Результаты приходят в порядке завершения обработки, а не в порядке входных данных. Если нужен исходный порядок — передавайте индекс вместе с задачей:
type indexedJob struct {
index int
item Item
}Semaphore (golang.org/x/sync/semaphore)
Ограничивает количество одновременно выполняемых операций. Горутины создаются динамически, но не более N могут работать параллельно.
Когда использовать:
- IO-bound задачи (rate limiting к внешним API)
- Разная длительность задач
- Нужна отмена через
context - Weighted семафор для задач разной "стоимости"
Особенности:
- Поддержка
contextдля таймаутов и отмены TryAcquire()для non-blocking проверки- Weighted: можно "занимать" разное количество слотов
import "golang.org/x/sync/semaphore"
func processWithLimit(ctx context.Context, items []Item, limit int64) {
sem := semaphore.NewWeighted(limit)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
// Ждём слот (блокируется или возвращает ошибку по ctx)
if err := sem.Acquire(ctx, 1); err != nil {
wg.Done()
continue
}
go func(it Item) {
defer wg.Done()
defer sem.Release(1)
process(it)
}(item)
}
wg.Wait()
}Buffered Channel как semaphore
Простейший паттерн без внешних зависимостей. Размер буфера канала = максимальный параллелизм.
Когда использовать:
- Не хочется тянуть
golang.org/x/sync - Простые случаи без таймаутов
- Быстрый прототип
Особенности:
- Zero dependencies — только stdlib
- Нет поддержки
contextиз коробки (можно добавить черезselect) - Нет weighted семантики
func processWithChannelSem(items []Item, limit int) {
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire (блокируется если limit достигнут)
go func(it Item) {
defer wg.Done()
defer func() { <-sem }() // release
process(it)
}(item)
}
wg.Wait()
}Сравнение подходов:
| Критерий | Worker Pool | Semaphore | Buffered Channel |
|---|---|---|---|
| Количество горутин | Фиксированное | Динамическое (до N) | Динамическое (до N) |
| Context support | Нужно добавлять | Встроено | Через select |
| Weighted операции | Нет | Да | Нет |
| Dependencies | Нет | x/sync | Нет |
| Лучше для | CPU-bound | IO-bound с таймаутами | Простые случаи |
Мониторинг изменений GOMAXPROCS
// Логирование изменений для отладки
var lastGOMAXPROCS int32
func init() {
lastGOMAXPROCS = int32(runtime.GOMAXPROCS(0))
go func() {
for {
time.Sleep(time.Second)
current := int32(runtime.GOMAXPROCS(0))
if current != atomic.LoadInt32(&lastGOMAXPROCS) {
log.Printf("GOMAXPROCS changed: %d → %d",
atomic.LoadInt32(&lastGOMAXPROCS), current)
atomic.StoreInt32(&lastGOMAXPROCS, current)
}
}
}()
}Рекомендация
В 99% случаев оставляйте GOMAXPROCS по умолчанию (NumCPU()). Динамическое изменение — это низкоуровневый инструмент для специфичных сценариев, не средство управления concurrency.
Состояния горутин
┌─────────────┐
┌────▶│ _Grunnable │◀────┐
│ └──────┬──────┘ │
│ │ │
goready() schedule() preempt
│ │ │
│ ▼ │
┌────┴────┐ ┌─────────┐ ┌────┴──────┐
│_Gwaiting│◀─│_Grunning│─▶│_Gpreempted│
└─────────┘ └────┬────┘ └───────────┘
│
exit/panic
│
▼
┌──────────┐
│ _Gdead │
└──────────┘Основные состояния:
| Состояние | Описание |
|---|---|
_Gidle | G только создана, не инициализирована |
_Grunnable | В очереди, готова к выполнению |
_Grunning | Выполняется на M |
_Gsyscall | В blocking syscall |
_Gwaiting | Заблокирована (channel, mutex, etc) |
_Gpreempted | Прервана async preemption |
_Gdead | Завершена, готова к переиспользованию |
gopark / goready
// Блокировка горутины
func gopark(unlockf func(*g, unsafe.Pointer) bool, ...) {
mp := acquirem()
gp := mp.curg
gp.waitreason = reason
gp.atomicstatus.Store(_Gwaiting)
// Переключаемся на g0 и вызываем schedule()
mcall(park_m)
}
// Разблокировка горутины
func goready(gp *g, traceskip int) {
// Добавляем в runnext текущего P (fast path)
runqput(pp, gp, true)
// Будим idle P если есть
wakep()
}Диагностика
GODEBUG=schedtrace
GODEBUG=schedtrace=1000 ./appScheduler Trace Explorer
SCHED 1004ms: gomaxprocs=4 idleprocs=1 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [3 2 1 0]
GODEBUG=schedtrace=1000 ./app — вывод каждые 1000msGODEBUG=schedtrace=1000,scheddetail=1 ./app — детальный вывод с каждым P runtime/trace
import "runtime/trace"
func main() {
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// код приложения
}go tool trace trace.outpprof goroutine profile
import _ "net/http/pprof"
func main() {
go http.ListenAndServe(":6060", nil)
// ...
}go tool pprof http://localhost:6060/debug/pprof/goroutineGoroutine leak detection
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}Практические паттерны
Worker Pool для CPU-bound
func WorkerPool(jobs <-chan Job, workers int) <-chan Result {
results := make(chan Result, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
// Использование
workers := runtime.GOMAXPROCS(0) // число P
results := WorkerPool(jobs, workers)Ограничение параллелизма
// Semaphore через buffered channel
type Semaphore chan struct{}
func NewSemaphore(n int) Semaphore {
return make(chan struct{}, n)
}
func (s Semaphore) Acquire() { s <- struct{}{} }
func (s Semaphore) Release() { <-s }
// Использование
sem := NewSemaphore(10) // max 10 concurrent
for _, item := range items {
sem.Acquire()
go func(item Item) {
defer sem.Release()
process(item)
}(item)
}Anti-patterns
Слишком много горутин
// Плохо: миллион горутин
for i := 0; i < 1_000_000; i++ {
go process(i)
}
// Хорошо: worker pool
jobs := make(chan int, 100)
for i := 0; i < runtime.GOMAXPROCS(0); i++ {
go worker(jobs)
}
for i := 0; i < 1_000_000; i++ {
jobs <- i
}Tight loop без preemption points (Go < 1.14)
// Проблема в Go < 1.14
for {
// бесконечный цикл без вызовов функций
counter++
}
// Решение для совместимости
for {
counter++
if counter%10000 == 0 {
runtime.Gosched()
}
}Выводы
G-M-P модель — три уровня абстракции позволяют эффективно мультиплексировать горутины на ограниченное число OS threads
Work Stealing обеспечивает автоматическую балансировку нагрузки между P
Async Preemption (Go 1.14+) решает проблему tight loops, но понимание cooperative preemption важно для legacy кода
GOMAXPROCS — для большинства случаев
NumCPU()оптимален. Увеличение помогает только при blocking CGO/syscalls. В контейнерах критично учитывать CPU limitsschedtrace — первый инструмент диагностики проблем с планировщиком
Worker pools — правильный паттерн для CPU-bound задач вместо spawn миллиона горутин