Goroutines Deep Dive
Горутина — фундаментальная единица конкурентности в Go. Это не поток и не корутина в классическом понимании — это уникальная абстракция Go runtime.
Связь с GMP Scheduler
Структура runtime.g и её связь с M и P рассмотрены в разделе GMP Scheduler. Здесь фокус на жизненном цикле, стеке и preemption.
Жизненный цикл Goroutine
Создание: runtime.newproc
Когда вы пишете go func(), компилятор генерирует вызов runtime.newproc:
// Компилятор преобразует:
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
// 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 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) │ │
│ │ ем стек │ │ остановлена │ │ сканирование │ │ │ │
│ └─────────────┘ └──────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘Константы состояний
// 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 → _Grunnable | newproc | Создание горутины |
_Grunnable → _Grunning | execute | Scheduler выбрал G |
_Grunning → _Gwaiting | gopark | Блокировка на channel/mutex |
_Gwaiting → _Grunnable | goready | Разблокировка |
_Grunning → _Gsyscall | entersyscall | Вход в системный вызов |
_Gsyscall → _Grunnable | exitsyscall | Выход из syscall |
_Grunning → _Gdead | goexit | Завершение горутины |
_Grunning → _Gcopystack | copystack | Рост стека |
_Grunning → _Gpreempted | async preemption | SIGURG signal |
Stack Growth: Как стек растёт
Go использует contiguous stacks — стек всегда непрерывный блок памяти. При переполнении создаётся новый, бо́льший стек, и данные копируются.
Stack Check: Preamble компилятора
Компилятор вставляет проверку стека в начало каждой функции:
; Пролог функции с проверкой стека
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
// 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 подробнее
// 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. Это важная оптимизация — без неё горутины, которые один раз использовали много стека, навсегда бы занимали эту память.
// 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()
// runtime/mgcmark.go
func scanstack(gp *g, gcw *gcWork) {
// 1. Сканировать стек для нахождения живых указателей
scanStackData(gp, gcw)
// 2. Попытаться сжать стек
// Это оптимальное место — стек уже сканирован,
// все указатели известны, горутина остановлена
shrinkstack(gp)
}Вывод GC со shrinking
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 shrinkingPreemption: Cooperative и Asynchronous
Cooperative Preemption (до Go 1.14)
Горутина отдаёт управление только в safe points:
- Вызов функции (stack check → morestack)
- Channel операции
- Блокировки
- runtime.Gosched()
Проблема: tight loop без вызовов функций не preempt'ится:
// Эта горутина НЕ МОЖЕТ быть прервана до 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?
// SIGURG выбран потому что:
// 1. Редко используется приложениями (out-of-band data в TCP)
// 2. По умолчанию игнорируется
// 3. Не мешает другим сигналам (SIGPROF для pprof)Preemption-safe vs Preemption-unsafe точки
// 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.
Когда использовать
// 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)
}Внутренняя реализация
// 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
Причины утечек
// 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
# 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
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// Или в каждом тесте
func TestSomething(t *testing.T) {
defer goleak.VerifyNone(t)
// ...
}Паттерны предотвращения
// 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: Трассировка планировщика
# Базовый вывод каждые 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 3005ms | 3005 | Время с запуска программы в миллисекундах |
gomaxprocs=4 | 4 | Количество P (логических процессоров) |
idleprocs=1 | 1 | P без работы (нет горутин в локальной очереди) |
threads=6 | 6 | Общее количество OS threads (M) |
spinningthreads=1 | 1 | M в spinning режиме (ищут работу) |
needspinning=0 | 0 | Нужны ли дополнительные spinners |
idlethreads=2 | 2 | M в состоянии parking (спят) |
runqueue=5 | 5 | Горутины в глобальной очереди |
[3 1 0 2] | — | Горутины в локальных очередях P0, P1, P2, P3 |
Интерпретация для диагностики
# Здоровая система под нагрузкой:
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 или mutexscheddetail: Детальный вывод
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=0 | M привязан к P0 |
p=-1 | M не привязан к P (idle или syscall) |
curg=1 | Текущая горутина (G ID) |
curg=-1 | Нет текущей горутины |
runq=5 | Горутин в очереди |
gfreecnt=32 | Закешированных мёртвых G для переиспользования |
runtime/trace
import "runtime/trace"
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// ... your code ...
}go tool trace trace.out
# Открывает web UI с:
# - Goroutine analysis
# - Network blocking profile
# - Synchronization blocking
# - Syscall blockingПрактические рекомендации
Сколько горутин — нормально?
| Количество | Контекст | Рекомендация |
|---|---|---|
| 1-100 | CLI tool | Нормально |
| 100-1K | Web server | Нормально |
| 1K-10K | High-load server | Мониторить |
| 10K-100K | Специфичные задачи | Ограничивать |
| 100K+ | Вероятно проблема | Рефакторить |
Memory footprint
// Минимальный overhead горутины:
// - Stack: 2KB (растёт до ~1GB max)
// - runtime.g struct: ~400 bytes
// - Связанные структуры: ~100-200 bytes
// Итого: ~2.5KB минимум на горутину
// 100K горутин ≈ 250MB RAM только на стекиWorker Pool Pattern
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)
}