Горутины и планировщик (GMP)
Горутины — фундамент concurrency в Go. Это лёгкие “потоки”, управляемые runtime, а не ОС. Понимание того, как они устроены под капотом (GMP-модель, work stealing, рост стека), отличает джуна, который “просто пишет
go func()”, от того, кто понимает, почему его сервис тормозит, утекает и падает. На собеседовании это топ-тема: спросят про GOMAXPROCS, leaks, GMP, capture переменных циклом.
Содержание
Заголовок раздела «Содержание»- Базовое API
- Под капотом: стек, GMP, work stealing
- Тонкие моменты / Gotchas
- Производительность
- Типичные вопросы на собесе
- Practice
- Источники
1. Базовое API
Заголовок раздела «1. Базовое API»1.1. Что такое goroutine
Заголовок раздела «1.1. Что такое goroutine»Goroutine — лёгкая единица исполнения, управляемая Go runtime. Это НЕ OS thread, это userspace-поток, который мультиплексируется на несколько OS-потоков по модели M:N (много goroutines на меньшее число OS threads).
Сравнение стоимости:
| Сущность | Начальный стек | Создание | Контекст-свитч |
|---|---|---|---|
| OS thread (Linux) | 1-2 MB | ~10-100 μs | ~1-2 μs (kernel) |
| Goroutine (Go 1.4+) | 2 KB | ~100-300 ns | ~100-300 ns (user) |
| Userspace thread | зависит | дешёво | дешёво |
Поэтому в Go нормально иметь сотни тысяч горутин на одном процессе. Миллион — тоже бывает, если они не голодны на CPU и память.
1.2. Запуск горутины
Заголовок раздела «1.2. Запуск горутины»package main
import ( "fmt" "time")
func say(s string) { for i := 0; i < 3; i++ { fmt.Println(s) time.Sleep(100 * time.Millisecond) }}
func main() { go say("world") // отдельная горутина say("hello") // в основной горутине time.Sleep(time.Second) // ждём, иначе main завершится и убьёт world}Ключевое слово go принимает вызов функции (не определение). Можно передать аргументы:
go func(x int) { fmt.Println(x)}(42)1.3. main горутина
Заголовок раздела «1.3. main горутина»main() сама запускается в горутине (главной). Когда main возвращается — весь процесс завершается, даже если другие горутины ещё работают. Они просто прерываются (без deferred очистки!).
func main() { go func() { time.Sleep(time.Hour) fmt.Println("never printed") }() // main вышла — горутина выше убита.}1.4. runtime helpers
Заголовок раздела «1.4. runtime helpers»runtime.NumGoroutine() // сколько горутин сейчас живётruntime.GOMAXPROCS(n) // выставить число P (см. ниже)runtime.Gosched() // вручную отдать P другой Gruntime.NumCPU() // число CPU ядер ОСruntime.Gosched() — это “я устал, дайте кому-нибудь поработать”. На практике почти не нужен (планировщик preemptive с Go 1.14+), но полезен в редких случаях, когда горутина крутит CPU-bound цикл без точек прерывания.
2. Под капотом
Заголовок раздела «2. Под капотом»2.1. Стек горутины
Заголовок раздела «2.1. Стек горутины»Раньше (до Go 1.4) использовались сегментированные стеки (segmented stacks): когда стек кончался, выделялся новый сегмент, связанный с предыдущим. Это породило проблему “hot split” — частые входы/выходы из функции на границе сегмента приводили к постоянному allocation/free.
С Go 1.4 перешли на contiguous stacks: стек горутины — это один смежный кусок памяти. Когда он переполняется:
- Аллоцируется новый стек в 2 раза больше.
- Старый стек копируется в новый (включая указатели, которые runtime переписывает).
- Старый стек освобождается.
Начальный размер — 2 KB (Go 1.4+). Максимум — 1 GB (на 64-bit, см. runtime.SetMaxStack). Стек может и сжиматься обратно (GC вызывает shrinkstack).
// Пример: рекурсия глубиной 1000 не страшна — стек вырастет.func deep(n int) { if n == 0 { return } deep(n - 1)}
func main() { go deep(100000) // ок, стек вырастет до нужного размера time.Sleep(time.Second)}⚠️ Подвох: рост стека — это копирование. На очень глубокой рекурсии в hot path может быть просадка. Но в 99% случаев это незаметно.
2.2. GMP-модель
Заголовок раздела «2.2. GMP-модель»Go использует GMP-планировщик (введён в Go 1.1). Три ключевые сущности:
- G (goroutine) — описание задачи (стек, PC, состояние).
- M (machine) — OS thread. Это то, что реально исполняется на CPU.
- P (processor) — логический процессор. Содержит ресурсы, нужные M для запуска G: локальную очередь, кеш аллокатора (mcache).
Число P = GOMAXPROCS (по умолчанию = числу CPU). M-ов может быть больше P (например, когда M залип в syscall — runtime создаёт нового M, чтобы P не простаивал).
ASCII-схема GMP
Заголовок раздела «ASCII-схема GMP» ┌──────────────────────────────────────────────┐ │ Global Run Queue │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │ G │ │ G │ │ G │ │ G │ │ G │ ... │ │ └────┘ └────┘ └────┘ └────┘ └────┘ │ └──────────────────────────────────────────────┘ ▲ ▲ │ steal (1/61) │ steal │ │ ┌──────────────┴───────┐ ┌───────────────┴──────┐ │ P0 │ │ P1 │ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │ │ Local Run Queue │ │ │ │ Local Run Queue │ │ │ │ ┌──┐ ┌──┐ ┌──┐ │ │ ◄──work──┤ │ ┌──┐ ┌──┐ │ │ │ │ │G │ │G │ │G │ │ │ steal │ │ │G │ │G │ │ │ │ │ └──┘ └──┘ └──┘ │ │ │ │ └──┘ └──┘ │ │ │ │ (FIFO, 256) │ │ │ │ (FIFO, 256) │ │ │ └─────────────────┘ │ │ └─────────────────┘ │ │ runnext: ┌──┐ │ │ runnext: ┌──┐ │ │ │G │ │ │ │G │ │ │ └──┘ │ │ └──┘ │ └──────────┬───────────┘ └──────────┬───────────┘ │ │ ▼ ▼ ┌────────┐ ┌────────┐ │ M0 │ │ M1 │ │ (OS │ │ (OS │ │ thread)│ │ thread)│ └────────┘ └────────┘ │ │ ▼ ▼ [ CPU 0 ] [ CPU 1 ]Как это работает
Заголовок раздела «Как это работает»go func()→ создаётся новая G, кладётся вrunnextтекущего P (один слот для “только что созданной” — это микро-оптимизация: горячая G).- Если
runnextуже занят — старая G изrunnextуезжает в local run queue. - M, привязанный к P, в цикле берёт G и выполняет её.
- Порядок выбора в
findrunnable:- 1/61 раз — берёт из global queue (чтобы global не голодал).
- Иначе — из локальной очереди (FIFO).
- Если локальная пуста — пытается украсть половину очереди у случайного P (work stealing).
- Если красть не у кого — проверяет netpoller (готовые I/O горутины).
- Если и там пусто — паркует M (засыпает).
Work stealing
Заголовок раздела «Work stealing»Когда P без работы, он “крадёт” половину локальной очереди у другого случайного P. Это балансирует нагрузку без centralized scheduler-а. Сравните с моделью Erlang (там тоже work stealing).
Состояния G
Заголовок раздела «Состояния G»_Gidle — только создана_Grunnable — в очереди, готова бежать_Grunning — исполняется на M_Gsyscall — внутри syscall, M отвязан от P_Gwaiting — заблокирована (chan, mutex, time.Sleep)_Gdead — завершилась, лежит в кеше runtime для переиспользованияM в syscall
Заголовок раздела «M в syscall»Когда G делает блокирующий syscall (read из файла, gettimeofday и т.п.):
- M остаётся привязан к G.
- P отвязывается от M.
- Runtime ищет/создаёт другого M, чтобы он подхватил P и продолжил работу.
- Когда syscall завершён — M пытается забрать обратно P. Если не получилось — G кладётся в global queue, а M идёт в кеш.
Это означает: число M ≥ число P. На сервисах с тяжёлым I/O M-ов может быть тысячи.
2.3. Preemption (вытеснение)
Заголовок раздела «2.3. Preemption (вытеснение)»До Go 1.14 планировщик был cooperative — горутина могла быть вытеснена только в safe points (function call, channel op, allocation). Если горутина крутила for { x++ } без вызовов — она никогда не отпускала P, и могла повесить GC.
С Go 1.14 — asynchronous preemption через сигнал SIGURG. Runtime посылает сигнал зависшей G, обработчик ставит флаг “тебя пора отпустить”, и при следующей возможности G паркуется. Это работает на amd64/arm64/etc.
// До Go 1.14 это могло повесить GC. Теперь — нет.func busyLoop() { for i := 0; i < 1e18; i++ { _ = i }}2.4. GOMAXPROCS
Заголовок раздела «2.4. GOMAXPROCS»GOMAXPROCS = число P = максимальное число горутин, исполняющихся параллельно на CPU.
- По умолчанию (Go 1.5+) =
runtime.NumCPU(). - Можно поставить руками:
runtime.GOMAXPROCS(4)илиGOMAXPROCS=4 ./app.
Контейнеры: проблема с CPU limits
Заголовок раздела «Контейнеры: проблема с CPU limits»В Kubernetes/Docker контейнер может иметь CPU limit = 2 ядра, но runtime.NumCPU() возвращает все физические ядра ноды (например, 64). Это значит, что Go запустит 64 P, и контейнер будет throttled CFS-ом → латентность.
Решение: библиотека go.uber.org/automaxprocs:
import _ "go.uber.org/automaxprocs"
func main() { // GOMAXPROCS теперь = CPU quota контейнера}В Go 1.25 это поведение встроено в стандартный runtime (читает cgroup limits), но automaxprocs остаётся актуальным для Go 1.22-1.24.
3. Тонкие моменты / Gotchas
Заголовок раздела «3. Тонкие моменты / Gotchas»3.1. Захват переменной циклом (loopvar)
Заголовок раздела «3.1. Захват переменной циклом (loopvar)»⚠️ Классическая ловушка ДО Go 1.22:
// Go 1.21 и раньше:for i := 0; i < 5; i++ { go func() { fmt.Println(i) // печатает 5 5 5 5 5 (или какие-то) }()}Переменная i была одна на весь цикл, и все горутины ссылались на неё. Когда они запускались, i уже была 5.
Фикс (старый стиль):
for i := 0; i < 5; i++ { i := i // shadow go func() { fmt.Println(i) }()}Или передать аргументом:
for i := 0; i < 5; i++ { go func(i int) { fmt.Println(i) }(i)}С Go 1.22: каждая итерация цикла имеет свою копию i. Старый код “просто работает”. Это одно из самых значимых breaking changes за последние годы (но контролируется через //go:build go1.22 и go.mod).
⚠️ Тем не менее: на собесе всё ещё спросят про “старое” поведение, и в legacy-проектах его можно встретить.
3.2. Panic в горутине
Заголовок раздела «3.2. Panic в горутине»func main() { go func() { panic("boom") // весь процесс падает }() time.Sleep(time.Second)}Panic в горутине без recover() → весь процесс падает. recover() в другой горутине не поможет. Каждая горутина должна себя защищать сама:
func safeGo(f func()) { go func() { defer func() { if r := recover(); r != nil { log.Printf("goroutine panic: %v", r) } }() f() }()}⚠️ Best practice: делайте safeGo обёртку для всех “фоновых” горутин в проде. Иначе один кривой запрос обрушит весь сервис.
3.3. Goroutine leaks
Заголовок раздела «3.3. Goroutine leaks»Leak — горутина “повисла” навсегда и больше не освободится. Типичные причины:
3.3.1. Send на канал без receiver
Заголовок раздела «3.3.1. Send на канал без receiver»func leak() { ch := make(chan int) // unbuffered go func() { ch <- 42 // блокируется навсегда, если никто не читает }() // ch выпадает из scope, но горутина живёт.}3.3.2. Receive из канала без sender
Заголовок раздела «3.3.2. Receive из канала без sender»func leak() <-chan int { ch := make(chan int) go func() { result := slowOp() ch <- result }() return ch // если caller никогда не прочтёт — горутина залипла}3.3.3. WithCancel без cancel
Заголовок раздела «3.3.3. WithCancel без cancel»func leak() { ctx, cancel := context.WithCancel(context.Background()) _ = cancel // забыли вызвать! go worker(ctx) // ctx живёт вечно → worker никогда не отменится}Всегда defer cancel().
3.3.4. Бесконечный select без exit
Заголовок раздела «3.3.4. Бесконечный select без exit»go func() { for { select { case msg := <-ch: handle(msg) // нет case <-ctx.Done()! } }}()3.4. Как ловить leaks
Заголовок раздела «3.4. Как ловить leaks»3.4.1. runtime.NumGoroutine()
Заголовок раздела «3.4.1. runtime.NumGoroutine()»Простейший способ — мониторить число горутин. Если оно растёт линейно от RPS — у вас leak.
http.HandleFunc("/debug/goroutines", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "goroutines: %d\n", runtime.NumGoroutine())})3.4.2. pprof
Заголовок раздела «3.4.2. pprof»import _ "net/http/pprof"
func main() { go http.ListenAndServe("localhost:6060", nil) // ... ваш код}Затем:
go tool pprof http://localhost:6060/debug/pprof/goroutine# в pprof: top, list, webили прямо в браузере:
http://localhost:6060/debug/pprof/goroutine?debug=2— покажет полный stack trace каждой горутины. Если 10000 горутин висят на одной строке <-ch — вот вам leak.
3.4.3. goleak в тестах
Заголовок раздела «3.4.3. goleak в тестах»go.uber.org/goleak — детектор утечек для тестов:
func TestNoLeak(t *testing.T) { defer goleak.VerifyNone(t) // ... ваш тест}3.5. Слишком много горутин — это сколько?
Заголовок раздела «3.5. Слишком много горутин — это сколько?»- 1000-10000 — норма для большинства сервисов.
- 100000+ — нормально, если они не активны одновременно (например, висят на I/O).
- Миллион — на одной машине бывает (Discord, WhatsApp).
Что плохого в “лишних” горутинах?
- Каждая занимает минимум 2 KB стека (часто больше).
- Планировщик обходит их при поиске работы.
- GC сканирует их стеки.
Если горутин слишком много активных — используйте worker pool:
func workerPool(jobs <-chan Job, n int) { var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func() { defer wg.Done() for j := range jobs { process(j) } }() } wg.Wait()}3.6. “Не делайте Go ради concurrency”
Заголовок раздела «3.6. “Не делайте Go ради concurrency”»Цитата Роба Пайка: “Don’t make Go programs concurrent; make Go programs work, then make them concurrent if needed.”
Concurrency — это сложно. Гонки, дедлоки, leaks. Если задача линейная — пусть будет линейной. Не запускайте горутины “на всякий случай”.
4. Производительность
Заголовок раздела «4. Производительность»4.1. Стоимость запуска
Заголовок раздела «4.1. Стоимость запуска»go func() { ... }() // ~200-400 ns + 2 KB stackВ тысячу раз дешевле OS thread. Но не бесплатно. Не запускайте горутины в hot loop без необходимости.
4.2. Контекст-свитч
Заголовок раздела «4.2. Контекст-свитч»- OS thread switch: 1-2 μs (kernel involvement).
- Goroutine switch: ~200 ns (userspace).
4.3. Cache locality
Заголовок раздела «4.3. Cache locality»Goroutines на одном P используют один и тот же mcache (аллокатор). Это улучшает cache locality. Когда горутина “украдена” другим P — её данные могут оказаться в L2/L3 другого CPU → cache miss.
4.4. Профилирование
Заголовок раздела «4.4. Профилирование»# CPUgo test -cpuprofile=cpu.profgo tool pprof cpu.prof
# Goroutines (живые)curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
# Block (на чём блокируются)runtime.SetBlockProfileRate(1)go tool pprof http://localhost:6060/debug/pprof/block
# Mutex contentionruntime.SetMutexProfileFraction(1)go tool pprof http://localhost:6060/debug/pprof/mutex4.5. Трассировка
Заголовок раздела «4.5. Трассировка»import "runtime/trace"
f, _ := os.Create("trace.out")trace.Start(f)defer trace.Stop()// ... кодЗатем go tool trace trace.out — браузерный UI с graphical view горутин, GC, syscalls. Незаменим для отладки латентности.
5. Типичные вопросы на собесе
Заголовок раздела «5. Типичные вопросы на собесе»-
Что такое goroutine? Чем отличается от OS thread? Лёгкий userspace-поток, управляемый Go runtime. 2 KB начальный стек vs 1-2 MB у OS. Мультиплексируется на OS threads по модели M:N.
-
Что такое GMP? G — goroutine, M — OS thread, P — логический процессор (содержит локальную очередь и кеш аллокатора). Число P = GOMAXPROCS.
-
Сколько по умолчанию GOMAXPROCS? Равно
runtime.NumCPU()(с Go 1.5). -
Что такое work stealing? Когда у P пустая локальная очередь, он “крадёт” половину очереди у другого случайного P.
-
С какого размера стек у goroutine? С Go 1.4 — 2 KB. Растёт динамически (удваивается, копированием).
-
Почему стек горутины может вырасти? Когда вызов функции не помещается. Runtime аллоцирует новый стек больше и копирует туда содержимое.
-
Что произойдёт при panic в горутине без recover? Весь процесс упадёт. Recover в другой горутине не поможет.
-
Что такое goroutine leak? Приведите пример. Горутина повисла навсегда. Пример:
go func(){ ch <- v }()без receiver-а. -
Как обнаружить goroutine leaks?
runtime.NumGoroutine(), pprof (/debug/pprof/goroutine), goleak в тестах. -
В каких состояниях бывает горутина? Grunnable, Grunning, Gwaiting, Gsyscall, Gdead, Gidle.
-
Что происходит, когда горутина делает блокирующий syscall? M отвязывается от P. Runtime создаёт/берёт другой M, чтобы продолжить работу на P. Число M ≥ числу P.
-
Что такое asynchronous preemption? С Go 1.14 — runtime может вытеснить горутину через SIGURG, даже если она в CPU-bound цикле без safe points.
-
Что не так с
for i := 0; i < 5; i++ { go func(){ println(i) }() }в Go 1.21? Переменнаяiбыла одна на цикл — все горутины видели её последнее значение. С Go 1.22 — по копии на итерацию. -
Что такое GOMAXPROCS и зачем automaxprocs? Число P. В контейнерах
NumCPU()возвращает все ядра ноды, а не лимит контейнера → throttling. automaxprocs читает cgroup limits. -
Чем
runtime.Gosched()отличается отtime.Sleep(0)? Оба отдают P другой горутине.time.Sleep(0)идёт через таймер (чуть дороже),Gosched()напрямую дёргает планировщик. -
Что произойдёт, если main вернётся, а другие горутины ещё работают? Они будут прерваны (без defer cleanup!). Процесс завершится.
-
Как корректно дождаться завершения всех горутин?
sync.WaitGroupилиerrgroup. Никогда неtime.Sleep. -
Можно ли запустить миллион горутин? Да, если они не голодны (например, висят на I/O). 2 KB × 1M = 2 GB памяти минимум.
-
Почему worker pool лучше “go на каждый job”? Контроль над параллелизмом, переиспользование стеков, отсутствие burst-аллокаций.
-
Что такое global run queue? Очередь горутин, доступная всем P. Туда попадают: горутины, которые не уместились в local queue; G, вернувшиеся из syscall, когда их P занят.
-
Зачем нужен
runnext? Слот в P для “только что созданной” горутины — она с высокой вероятностью продолжит работу того же контекста (cache locality, child-of-parent). -
Что такое netpoller? Не-блокирующий I/O loop runtime’а (epoll/kqueue/IOCP). Горутины, ждущие сетевой I/O, паркуются, не блокируя M. Когда I/O готов — G возвращается в run queue.
-
Каким образом GC взаимодействует с горутинами? GC требует STW-пауз для критических фаз. Использует preemption, чтобы поставить все горутины в safe point. GC сканирует стеки всех горутин.
-
Почему
go func() { for {} }()мог повесить GC в Go 1.13? Не было async preemption — горутина никогда не доходила до safe point. STW-фаза GC не могла начаться, потому что одна G не паркуется. -
Имеют ли горутины свой goroutine ID? Да, внутренне есть G.goid. Но публичного API нет — разработчики Go сознательно скрыли, чтобы не плодили “thread-local storage” паттернов.
6. Practice
Заголовок раздела «6. Practice»Задача 1: Worker pool
Заголовок раздела «Задача 1: Worker pool»Напишите функцию parallelMap[T, R any](items []T, n int, f func(T) R) []R, которая выполняет f параллельно на n горутинах, сохраняя порядок результатов.
func parallelMap[T, R any](items []T, n int, f func(T) R) []R { results := make([]R, len(items)) jobs := make(chan int, len(items)) var wg sync.WaitGroup for w := 0; w < n; w++ { wg.Add(1) go func() { defer wg.Done() for idx := range jobs { results[idx] = f(items[idx]) } }() } for i := range items { jobs <- i } close(jobs) wg.Wait() return results}Задача 2: Найти leak
Заголовок раздела «Задача 2: Найти leak»func server() { for { conn, _ := listener.Accept() go func() { buf := make([]byte, 1024) conn.Read(buf) // без таймаута, без context // ... process }() }}Leak: медленные/мёртвые клиенты держат горутины. Фикс: conn.SetReadDeadline() или context.WithTimeout.
Задача 3: Безопасный go
Заголовок раздела «Задача 3: Безопасный go»Напишите safeGo(f func()) func(), который запускает f в горутине с recover и логированием. Возвращает функцию wait(), которая блокируется до завершения.
Задача 4: Сколько горутин
Заголовок раздела «Задача 4: Сколько горутин»Запустите программу:
func main() { for i := 0; i < 1000; i++ { go func() { ch := make(chan int) <-ch }() } fmt.Println("goroutines:", runtime.NumGoroutine()) time.Sleep(time.Hour)}Запустите pprof, посмотрите stack trace. Все 1000 горутин висят на <-ch.
Задача 5: GOMAXPROCS эксперимент
Заголовок раздела «Задача 5: GOMAXPROCS эксперимент»func main() { runtime.GOMAXPROCS(1) var wg sync.WaitGroup for i := 0; i < 4; i++ { wg.Add(1) go func(i int) { defer wg.Done() for j := 0; j < 1e9; j++ { _ = j } }(i) } start := time.Now() wg.Wait() fmt.Println(time.Since(start))}Сравните с GOMAXPROCS(4). Замерьте время. Поймите, как ядра влияют на CPU-bound задачи.
7. Источники
Заголовок раздела «7. Источники»- Go Scheduler: Ms, Ps & Gs — Kavya Joshi: https://www.youtube.com/watch?v=YHRO5WQGh0k — лучшее видео-объяснение GMP.
- Дмитрий Вьюков, “Scalable Go Scheduler Design Doc” — оригинальный документ: https://golang.org/s/go11sched
- Go runtime source (proc.go) — https://github.com/golang/go/blob/master/src/runtime/proc.go — для тех, кто хочет погрузиться в код.
- William Kennedy, “Scheduling In Go” — серия из 3 статей: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html
- The Go Memory Model — https://go.dev/ref/mem — формальная спецификация happens-before.