Skip to content

Goroutines Deep Dive

Горутина — фундаментальная единица конкурентности в Go. Это не поток и не корутина в классическом понимании — это уникальная абстракция Go runtime.

Связь с GMP Scheduler

Структура runtime.g и её связь с M и P рассмотрены в разделе GMP Scheduler. Здесь фокус на жизненном цикле, стеке и preemption.

Жизненный цикл Goroutine

Создание: runtime.newproc

Когда вы пишете go func(), компилятор генерирует вызов runtime.newproc:

go
// Компилятор преобразует:
go myFunc(arg1, arg2)

// В:
runtime.newproc(funcval, arg1, arg2)

Внутри newproc:

┌─────────────────────────────────────────────────────────────────────────────┐
│                          runtime.newproc                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. Получить текущий P                                                      │
│     gp := getg()                                                            │
│     _p_ := gp.m.p.ptr()                                                     │
│     │                                                                       │
│     ▼                                                                       │
│  2. Получить G из кэша или создать новую                                    │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  if _p_.gFree.empty() {                                         │     │
│     │      // Попробовать взять из глобального кэша                   │     │
│     │      if !sched.gFree.stack.empty() || !sched.gFree.noStack...   │     │
│     │  } else {                                                       │     │
│     │      newg = _p_.gFree.pop()  // Взять из локального кэша P      │     │
│     │  }                                                              │     │
│     │  if newg == nil {                                               │     │
│     │      newg = malg(_StackMin)   // Аллокация новой G              │     │
│     │  }                                                              │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│     │                                                                       │
│     ▼                                                                       │
│  3. Инициализировать стек и контекст                                        │
│     newg.sched.sp = sp                                                      │
│     newg.sched.pc = goexit + 1  // Return address → goexit                  │
│     newg.sched.g = guintptr(unsafe.Pointer(newg))                           │
│     │                                                                       │
│     ▼                                                                       │
│  4. Установить статус _Grunnable                                            │
│     casgstatus(newg, _Gdead, _Grunnable)                                    │
│     │                                                                       │
│     ▼                                                                       │
│  5. Добавить в очередь P                                                    │
│     runqput(_p_, newg, true)  // true = может идти в runnext                │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Начальный стек: _StackMin

go
// runtime/stack.go
const _StackMin = 2048  // 2KB минимальный стек

// malg создаёт G с заданным размером стека
func malg(stacksize int32) *g {
    newg := new(g)
    if stacksize >= 0 {
        stacksize = round2(_StackSystem + stacksize)
        newg.stack = stackalloc(uint32(stacksize))
        newg.stackguard0 = newg.stack.lo + _StackGuard
        newg.stackguard1 = ^uintptr(0)  // Для C stack
    }
    return newg
}

Все состояния Goroutine

Горутина проходит через 10 возможных состояний:

🔄
Goroutine Lifecycle
Симулятор жизненного цикла горутины и переходов состояний
Открыть
┌─────────────────────────────────────────────────────────────────────────────┐
│                       Goroutine State Machine                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│                              ┌──────────┐                                   │
│                              │  _Gidle  │ 0 - только создана                │
│                              └────┬─────┘                                   │
│                                   │ malg()                                  │
│                                   ▼                                         │
│                              ┌──────────┐                                   │
│                         ┌───▶│  _Gdead  │ 6 - не используется               │
│                         │    └────┬─────┘                                   │
│                         │         │ newproc()                               │
│              goexit()   │         ▼                                         │
│                         │    ┌──────────────┐                               │
│                         └────│ _Grunnable   │ 1 - в очереди на выполнение   │
│                              └──────┬───────┘                               │
│                                     │ execute()                             │
│                                     ▼                                       │
│  ┌────────────┐             ┌──────────────┐              ┌────────────┐    │
│  │_Gwaiting   │◀────────────│  _Grunning   │─────────────▶│ _Gsyscall  │    │
│  │ 4 - blocked│  gopark()   │ 2 - running  │  entersyscall│ 3 - syscall│    │
│  └─────┬──────┘             └──────┬───────┘              └─────┬──────┘    │
│        │                           │                            │           │
│        │ goready()                 │ Gosched()         exitsyscall()        │
│        │                           │                            │           │
│        └───────────────────────────┼────────────────────────────┘           │
│                                    │                                        │
│                                    ▼                                        │
│                              back to _Grunnable                             │
│                                                                             │
├─────────────────────────────────────────────────────────────────────────────┤
│  Специальные состояния:                                                     │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────┐  ┌───────────────┐     │
│  │ _Gcopystack │  │ _Gpreempted  │  │ _Gscan...    │  │ _Gmoribund_   │     │
│  │ 8 - копиру- │  │ 9 - preempt  │  │ 0x1000+ scan │  │ unused (7)    │     │
│  │ ем стек     │  │ остановлена  │  │ сканирование │  │               │     │
│  └─────────────┘  └──────────────┘  └──────────────┘  └───────────────┘     │
└─────────────────────────────────────────────────────────────────────────────┘

Константы состояний

go
// runtime/runtime2.go
const (
    _Gidle    = iota // 0 - G создана, но не инициализирована
    _Grunnable       // 1 - в очереди, готова к выполнению
    _Grunning        // 2 - выполняется на M
    _Gsyscall        // 3 - выполняет syscall
    _Gwaiting        // 4 - заблокирована (channel, mutex, etc.)
    _Gmoribund_unused
    _Gdead           // 6 - завершена или не использована
    _Genqueue_unused
    _Gcopystack      // 8 - стек копируется (stack growth)
    _Gpreempted      // 9 - preempted, ждёт reschedule

    _Gscan          = 0x1000  // Комбинируется с другими для GC scan
    _Gscanrunnable  = _Gscan + _Grunnable
    _Gscanrunning   = _Gscan + _Grunning
    // ...
)

Переходы между состояниями

ПереходФункцияКогда происходит
_Gdead_GrunnablenewprocСоздание горутины
_Grunnable_GrunningexecuteScheduler выбрал G
_Grunning_GwaitinggoparkБлокировка на channel/mutex
_Gwaiting_GrunnablegoreadyРазблокировка
_Grunning_GsyscallentersyscallВход в системный вызов
_Gsyscall_GrunnableexitsyscallВыход из syscall
_Grunning_GdeadgoexitЗавершение горутины
_Grunning_GcopystackcopystackРост стека
_Grunning_Gpreemptedasync preemptionSIGURG signal

Stack Growth: Как стек растёт

Go использует contiguous stacks — стек всегда непрерывный блок памяти. При переполнении создаётся новый, бо́льший стек, и данные копируются.

Stack Check: Preamble компилятора

Компилятор вставляет проверку стека в начало каждой функции:

asm
; Пролог функции с проверкой стека
TEXT ·myFunction(SB), NOSPLIT, $64-16
    MOVQ    (TLS), CX           ; CX = текущий g
    LEAQ    -64(SP), AX         ; AX = SP - framesize
    CMPQ    AX, 16(CX)          ; Сравнить с g.stackguard0
    JBE     morestack           ; Если меньше → нужно больше стека
    ; ... тело функции ...

morestack:
    CALL    runtime·morestack(SB)
    JMP     myFunction(SB)      ; Retry после расширения стека

runtime.morestack

┌─────────────────────────────────────────────────────────────────────────────┐
│                          Stack Growth Process                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. morestack_noctxt вызывается                                             │
│     │                                                                       │
│     ▼                                                                       │
│  2. Сохранить контекст в g.sched (SP, PC, ctxt)                             │
│     gp.sched.sp = sp                                                        │
│     gp.sched.pc = pc                                                        │
│     │                                                                       │
│     ▼                                                                       │
│  3. Переключиться на g0 stack (системный стек M)                            │
│     │                                                                       │
│     ▼                                                                       │
│  4. newstack() на g0                                                        │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │ Старый стек (2KB)          Новый стек (4KB)                     │     │
│     │ ┌───────────────┐          ┌───────────────────────────────┐    │     │
│     │ │ frame N       │          │                               │    │     │
│     │ ├───────────────┤   copy   │                               │    │     │
│     │ │ frame N-1     │ ────────▶│ frame N (adjusted pointers)   │    │     │
│     │ ├───────────────┤          ├───────────────────────────────┤    │     │
│     │ │ frame N-2     │          │ frame N-1                     │    │     │
│     │ ├───────────────┤          ├───────────────────────────────┤    │     │
│     │ │ ...           │          │ frame N-2                     │    │     │
│     │ └───────────────┘          ├───────────────────────────────┤    │     │
│     │                            │ ...                           │    │     │
│     │                            ├───────────────────────────────┤    │     │
│     │                            │         FREE SPACE            │    │     │
│     │                            │                               │    │     │
│     │                            └───────────────────────────────┘    │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│     │                                                                       │
│     ▼                                                                       │
│  5. Pointer Adjustment                                                      │
│     • Обновить все указатели на стек внутри стека                           │
│     • Использовать stack map от компилятора                                 │
│     │                                                                       │
│     ▼                                                                       │
│  6. Обновить g.stack, g.stackguard0                                         │
│     │                                                                       │
│     ▼                                                                       │
│  7. Освободить старый стек                                                  │
│     │                                                                       │
│     ▼                                                                       │
│  8. gogo(&gp.sched) — продолжить выполнение                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

copystack: Копирование с fixup

go
// runtime/stack.go
func copystack(gp *g, newsize uintptr) {
    old := gp.stack
    // used — количество байт, реально занятых на стеке
    // old.hi — верхняя граница стека (стек растёт вниз!)
    // gp.sched.sp — текущий stack pointer
    used := old.hi - gp.sched.sp

    // 1. Аллоцировать новый стек из span cache
    //    stackalloc использует size classes: 2KB, 4KB, 8KB, 16KB...
    //    Для больших стеков (>32KB) выделяет напрямую из heap
    new := stackalloc(uint32(newsize))

    // 2. Вычислить delta для pointer adjustment
    //    Все указатели на старый стек нужно сдвинуть на эту величину
    //    delta положительна если стек растёт, отрицательна если сжимается
    delta := new.hi - old.hi

    // 3. Скопировать содержимое стека
    //    Копируем от текущего SP до верхней границы
    //    Данные ниже SP — мусор, не нужно копировать
    memmove(unsafe.Pointer(new.hi-used),
            unsafe.Pointer(old.hi-used),
            used)

    // 4. Пройтись по всем указателям на стек и обновить их
    //    adjustpointers использует stack maps от компилятора
    //    Stack map содержит bitmap: какие слова в frame — указатели
    //    Для каждого frame вызывается adjustframe()
    adjustpointers(...)

    // 5. Обновить defer frames
    //    defer записи содержат указатель на frame — их тоже нужно обновить
    adjustdefers(gp, delta)

    // 6. Обновить panic frames
    //    panic записи аналогично содержат stack pointers
    adjustpanics(gp, delta)

    // 7. Обновить структуру g
    gp.stack = new
    gp.stackguard0 = new.lo + _StackGuard  // Новая guard zone
    gp.sched.sp += delta                    // Обновить saved SP

    // 8. Освободить старый стек
    //    Возвращает в span cache или heap
    stackfree(old)
}

Pointer Adjustment подробнее

go
// adjustframe вызывается для каждого stack frame
func adjustframe(frame *stkframe, arg unsafe.Pointer) bool {
    adjinfo := (*adjustinfo)(arg)

    // Получить stack map для этого PC
    // Stack map генерируется компилятором и хранится в PCDATA
    locals, args := getStackMap(frame)

    // Обновить указатели в локальных переменных
    adjustpointers(unsafe.Pointer(frame.varp-size), &locals, adjinfo)

    // Обновить указатели в аргументах
    adjustpointers(unsafe.Pointer(frame.argp), &args, adjinfo)

    return true  // Продолжить обход frames
}

// adjustpointers обновляет указатели согласно bitmap
func adjustpointers(scanp unsafe.Pointer, bv *bitvector, adjinfo *adjustinfo) {
    delta := adjinfo.delta
    for i := uintptr(0); i < bv.n; i++ {
        // Проверить, является ли слово указателем (по bitmap)
        if bv.ptrbit(i) {
            pp := (*uintptr)(add(scanp, i*goarch.PtrSize))
            p := *pp

            // Проверить, указывает ли на старый стек
            if adjinfo.old.lo <= p && p < adjinfo.old.hi {
                // Сдвинуть указатель на delta
                *pp = p + delta
            }
        }
    }
}

Stack Shrinking

Стек сжимается во время GC, если используется менее 1/4. Это важная оптимизация — без неё горутины, которые один раз использовали много стека, навсегда бы занимали эту память.

go
// runtime/stack.go (упрощённо)
func shrinkstack(gp *g) {
    // Вызывается из scanstack() во время GC mark phase
    // ВАЖНО: горутина должна быть остановлена (не _Grunning)

    // Текущий размер стека
    oldsize := gp.stack.hi - gp.stack.lo
    // Целевой размер — половина текущего
    newsize := oldsize / 2

    // Не сжимать меньше минимума (2KB по умолчанию)
    // _FixedStack = _StackMin для обычных горутин
    if newsize < _FixedStack {
        return
    }

    // Вычислить реально используемый стек
    // gp.sched.sp — сохранённый stack pointer при остановке
    used := gp.stack.hi - gp.sched.sp

    // Порог: используется меньше 1/4 текущего размера
    // Почему 1/4, а не 1/2?
    // - Гистерезис: избежать thrashing (grow → shrink → grow)
    // - После shrink на 1/2, будет использоваться ~1/2 нового размера
    // - Оставляет запас для небольшого роста без reallocation
    if used >= oldsize/4 {
        return  // Используется больше 1/4 — не сжимать
    }

    // Проверить, безопасно ли сжимать
    // Нельзя сжимать если горутина:
    // - Заблокирована на cgo callback
    // - Держит runtime locks
    // - В состоянии _Gsyscall с pinned stack
    if gp.preemptShrink {
        // Флаг устанавливается если сжатие небезопасно
        gp.preemptShrink = false
        return
    }

    // Выполнить сжатие — вызывает copystack()
    copystack(gp, newsize)
}

Когда происходит shrinking

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Stack Shrinking Timeline                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. Горутина работает, стек вырос до 16KB                                   │
│     ┌────────────────────────────────┐                                      │
│     │ used: 12KB                     │ 16KB                                 │
│     └────────────────────────────────┘                                      │
│                                                                             │
│  2. Горутина вышла из глубокой рекурсии, used = 2KB                         │
│     ┌────────────────────────────────┐                                      │
│     │ used: 2KB │    wasted: 14KB    │ 16KB                                 │
│     └────────────────────────────────┘                                      │
│                                                                             │
│  3. GC запускает scanstack() → shrinkstack()                                │
│     used (2KB) < oldsize/4 (4KB) ✓ → сжимаем!                               │
│                                                                             │
│  4. После shrink: стек = 8KB                                                │
│     ┌────────────────┐                                                      │
│     │ used: 2KB │    │ 8KB                                                  │
│     └────────────────┘                                                      │
│                                                                             │
│  5. Следующий GC: used (2KB) < 8KB/4 (2KB)? Нет → не сжимаем                │
│     (граничный случай, может сжать в следующий раз)                         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Связь с scanstack()

go
// runtime/mgcmark.go
func scanstack(gp *g, gcw *gcWork) {
    // 1. Сканировать стек для нахождения живых указателей
    scanStackData(gp, gcw)

    // 2. Попытаться сжать стек
    //    Это оптимальное место — стек уже сканирован,
    //    все указатели известны, горутина остановлена
    shrinkstack(gp)
}

Вывод GC со shrinking

bash
GODEBUG=gctrace=1 ./app

# В выводе:
# gc 5 @2.150s 1%: 0.013+1.2+0.004 ms clock, 0.052+0.26/1.0/2.8+0.016 ms cpu,
# 4->4->2 MB, 5 MB goal, 4 P

# Если добавить GODEBUG=gcshrinkstackoff=1, стеки не будут сжиматься
# Полезно для отладки проблем с stack shrinking

Preemption: Cooperative и Asynchronous

Cooperative Preemption (до Go 1.14)

Горутина отдаёт управление только в safe points:

  • Вызов функции (stack check → morestack)
  • Channel операции
  • Блокировки
  • runtime.Gosched()

Проблема: tight loop без вызовов функций не preempt'ится:

go
// Эта горутина НЕ МОЖЕТ быть прервана до Go 1.14
func tightLoop() {
    for {
        x++  // Нет вызовов функций → нет safe points
    }
}

Asynchronous Preemption (Go 1.14+)

Go 1.14 добавил async preemption через сигналы:

┌─────────────────────────────────────────────────────────────────────────────┐
│                      Async Preemption (SIGURG)                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  Scheduler решает: "эту G пора прервать"                                    │
│     │                                                                       │
│     ▼                                                                       │
│  preemptM(mp) — отправить сигнал на M                                       │
│     │                                                                       │
│     ├─── Unix: tgkill(mp.procid, SIGURG)                                    │
│     └─── Windows: SuspendThread() + GetThreadContext()                      │
│     │                                                                       │
│     ▼                                                                       │
│  Signal Handler (sigtramp → sighandler → doSigPreempt)                      │
│     │                                                                       │
│     ▼                                                                       │
│  asyncPreempt()                                                             │
│     ┌─────────────────────────────────────────────────────────────────┐     │
│     │  1. Сохранить ВСЕ регистры (не только callee-saved)             │     │
│     │  2. Проверить, можно ли прервать в этой точке                   │     │
│     │     • Не внутри runtime                                         │     │
│     │     • Не в atomic section                                       │     │
│     │     • Есть stack map для этого PC                               │     │
│     │  3. Если можно:                                                 │     │
│     │     gp.asyncSafePoint = true                                    │     │
│     │     mcall(gopreempt_m)                                          │     │
│     │  4. Если нельзя:                                                │     │
│     │     Вернуться и попробовать позже                               │     │
│     └─────────────────────────────────────────────────────────────────┘     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Почему SIGURG?

go
// SIGURG выбран потому что:
// 1. Редко используется приложениями (out-of-band data в TCP)
// 2. По умолчанию игнорируется
// 3. Не мешает другим сигналам (SIGPROF для pprof)

Preemption-safe vs Preemption-unsafe точки

go
// runtime/preempt.go
// Точка безопасна для preemption если:
// 1. Есть stack map (compiler знает где указатели)
// 2. Не внутри write barrier
// 3. Не держим runtime locks
// 4. Не в middle of atomic operation

func isAsyncSafePoint(gp *g, pc uintptr) bool {
    // Проверить, есть ли frame info для этого PC
    f := findfunc(pc)
    if !f.valid() {
        return false
    }

    // Проверить, что не в runtime
    if f.funcID == funcID_systemstack {
        return false
    }

    // Проверить stack map
    if !hasStackMap(f, pc) {
        return false
    }

    return true
}

LockOSThread

runtime.LockOSThread() привязывает горутину к текущему OS thread.

Когда использовать

go
// 1. GUI/OpenGL — требуют вызовы из одного потока
func initOpenGL() {
    runtime.LockOSThread()
    // Все OpenGL вызовы будут на этом потоке
}

// 2. Thread-local storage в cgo
func useTLS() {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()

    C.init_thread_local()
    // ...
    C.cleanup_thread_local()
}

// 3. setns/unshare в Linux (network namespaces)
func enterNamespace(ns *os.File) error {
    runtime.LockOSThread()
    // ВАЖНО: не вызывать Unlock если успешно!
    // Иначе другие горутины могут попасть в этот namespace

    return unix.Setns(int(ns.Fd()), unix.CLONE_NEWNET)
}

Внутренняя реализация

go
// runtime/proc.go
func LockOSThread() {
    gp := getg()
    gp.m.lockedExt++
    dolockOSThread()
}

func dolockOSThread() {
    gp := getg()
    gp.m.lockedg.set(gp)  // M помечает G
    gp.lockedm.set(gp.m)  // G помечает M
}

Осторожно с LockOSThread

  • Забытый UnlockOSThread → thread leak
  • M не возвращается в pool пока G не завершится
  • Может привести к исчерпанию threads при неправильном использовании

Goroutine Leaks

Причины утечек

go
// 1. Забытый receiver
func leak1() {
    ch := make(chan int)
    go func() {
        ch <- 42  // Навсегда заблокирован — нет receiver
    }()
}

// 2. Бесконечный цикл без выхода
func leak2(ctx context.Context) {
    go func() {
        for {
            // Забыли проверить ctx.Done()
            doWork()
        }
    }()
}

// 3. Blocked на mutex/channel который никогда не освободится
func leak3() {
    var mu sync.Mutex
    mu.Lock()
    go func() {
        mu.Lock()  // Навсегда ждёт
        defer mu.Unlock()
    }()
    // mu.Unlock() забыли
}

Детекция с pprof

bash
# HTTP endpoint
curl http://localhost:6060/debug/pprof/goroutine?debug=2

# В программе
import _ "net/http/pprof"
go http.ListenAndServe(":6060", nil)

Пример вывода:

goroutine 42 [chan send, 5 minutes]:
main.leak1.func1()
    /app/main.go:15 +0x45
created by main.leak1
    /app/main.go:14 +0x35

Детекция с goleak

go
import "go.uber.org/goleak"

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

// Или в каждом тесте
func TestSomething(t *testing.T) {
    defer goleak.VerifyNone(t)
    // ...
}

Паттерны предотвращения

go
// 1. Всегда передавать context
func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return  // Чистый выход
        case job := <-jobs:
            process(job)
        }
    }
}

// 2. Buffered channel для "fire and forget"
func notify(ch chan<- Event, e Event) {
    select {
    case ch <- e:
    default:
        // Drop если receiver не готов
    }
}

// 3. Done channel pattern
func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            return
        default:
            doWork()
        }
    }
}

Диагностика: GODEBUG

schedtrace: Трассировка планировщика

bash
# Базовый вывод каждые 1000ms
GODEBUG=schedtrace=1000 ./app

# Детальный вывод с информацией о каждой горутине
GODEBUG=schedtrace=1000,scheddetail=1 ./app

Разбор вывода schedtrace

SCHED 3005ms: gomaxprocs=4 idleprocs=1 threads=6 spinningthreads=1
              needspinning=0 idlethreads=2 runqueue=5 [3 1 0 2]
ПолеЗначениеОписание
SCHED 3005ms3005Время с запуска программы в миллисекундах
gomaxprocs=44Количество P (логических процессоров)
idleprocs=11P без работы (нет горутин в локальной очереди)
threads=66Общее количество OS threads (M)
spinningthreads=11M в spinning режиме (ищут работу)
needspinning=00Нужны ли дополнительные spinners
idlethreads=22M в состоянии parking (спят)
runqueue=55Горутины в глобальной очереди
[3 1 0 2]Горутины в локальных очередях P0, P1, P2, P3

Интерпретация для диагностики

bash
# Здоровая система под нагрузкой:
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=10 spinningthreads=2
              runqueue=0 [12 8 15 10 7 11 9 14]
# ✅ Все P заняты (idleprocs=0)
# ✅ Небольшие локальные очереди (8-15 горутин)
# ✅ Глобальная очередь пуста
# ✅ 2 spinning M готовы подхватить работу

# Проблема: горутины голодают
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=10 spinningthreads=0
              runqueue=500 [64 58 71 62 55 68 60 65]
# ⚠️ Большие локальные очереди (55-71)
# ⚠️ Большая глобальная очередь (500)
# ⚠️ Нет spinning threads — все заняты
# Диагноз: CPU-bound работа, не хватает P

# Проблема: горутины заблокированы
SCHED 1000ms: gomaxprocs=8 idleprocs=7 threads=50 spinningthreads=0
              idlethreads=43 runqueue=0 [1 0 0 0 0 0 0 0]
# ⚠️ Почти все P idle (7 из 8)
# ⚠️ Много threads (50), большинство idle (43)
# ⚠️ Мало горутин в очередях
# Диагноз: горутины заблокированы на I/O или mutex

scheddetail: Детальный вывод

bash
GODEBUG=schedtrace=1000,scheddetail=1 ./app

# Дополнительно выводит информацию о каждом M и P:
# M0: p=0 curg=1 runq=5 gfreecnt=0
# M1: p=-1 curg=-1 runq=0 gfreecnt=0  (idle)
# P0: runq=5 gfreecnt=32
# P1: runq=3 gfreecnt=28
ПолеОписание
p=0M привязан к P0
p=-1M не привязан к P (idle или syscall)
curg=1Текущая горутина (G ID)
curg=-1Нет текущей горутины
runq=5Горутин в очереди
gfreecnt=32Закешированных мёртвых G для переиспользования

runtime/trace

go
import "runtime/trace"

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()

    trace.Start(f)
    defer trace.Stop()

    // ... your code ...
}
bash
go tool trace trace.out
# Открывает web UI с:
# - Goroutine analysis
# - Network blocking profile
# - Synchronization blocking
# - Syscall blocking

Практические рекомендации

Сколько горутин — нормально?

КоличествоКонтекстРекомендация
1-100CLI toolНормально
100-1KWeb serverНормально
1K-10KHigh-load serverМониторить
10K-100KСпецифичные задачиОграничивать
100K+Вероятно проблемаРефакторить

Memory footprint

go
// Минимальный overhead горутины:
// - Stack: 2KB (растёт до ~1GB max)
// - runtime.g struct: ~400 bytes
// - Связанные структуры: ~100-200 bytes
// Итого: ~2.5KB минимум на горутину

// 100K горутин ≈ 250MB RAM только на стеки

Worker Pool Pattern

go
func workerPool(jobs <-chan Job, results chan<- Result, workers int) {
    var wg sync.WaitGroup
    wg.Add(workers)

    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }

    wg.Wait()
    close(results)
}

Go Deep Dive — книга для Senior разработчиков