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

Горутины и планировщик (GMP)

Горутины — фундамент concurrency в Go. Это лёгкие “потоки”, управляемые runtime, а не ОС. Понимание того, как они устроены под капотом (GMP-модель, work stealing, рост стека), отличает джуна, который “просто пишет go func()”, от того, кто понимает, почему его сервис тормозит, утекает и падает. На собеседовании это топ-тема: спросят про GOMAXPROCS, leaks, GMP, capture переменных циклом.

  1. Базовое API
  2. Под капотом: стек, GMP, work stealing
  3. Тонкие моменты / Gotchas
  4. Производительность
  5. Типичные вопросы на собесе
  6. Practice
  7. Источники

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 и память.

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)

main() сама запускается в горутине (главной). Когда main возвращается — весь процесс завершается, даже если другие горутины ещё работают. Они просто прерываются (без deferred очистки!).

func main() {
go func() {
time.Sleep(time.Hour)
fmt.Println("never printed")
}()
// main вышла — горутина выше убита.
}
runtime.NumGoroutine() // сколько горутин сейчас живёт
runtime.GOMAXPROCS(n) // выставить число P (см. ниже)
runtime.Gosched() // вручную отдать P другой G
runtime.NumCPU() // число CPU ядер ОС

runtime.Gosched() — это “я устал, дайте кому-нибудь поработать”. На практике почти не нужен (планировщик preemptive с Go 1.14+), но полезен в редких случаях, когда горутина крутит CPU-bound цикл без точек прерывания.


Раньше (до Go 1.4) использовались сегментированные стеки (segmented stacks): когда стек кончался, выделялся новый сегмент, связанный с предыдущим. Это породило проблему “hot split” — частые входы/выходы из функции на границе сегмента приводили к постоянному allocation/free.

С Go 1.4 перешли на contiguous stacks: стек горутины — это один смежный кусок памяти. Когда он переполняется:

  1. Аллоцируется новый стек в 2 раза больше.
  2. Старый стек копируется в новый (включая указатели, которые runtime переписывает).
  3. Старый стек освобождается.

Начальный размер — 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% случаев это незаметно.

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 не простаивал).

┌──────────────────────────────────────────────┐
│ 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 ]
  1. go func() → создаётся новая G, кладётся в runnext текущего P (один слот для “только что созданной” — это микро-оптимизация: горячая G).
  2. Если runnext уже занят — старая G из runnext уезжает в local run queue.
  3. M, привязанный к P, в цикле берёт G и выполняет её.
  4. Порядок выбора в findrunnable:
    • 1/61 раз — берёт из global queue (чтобы global не голодал).
    • Иначе — из локальной очереди (FIFO).
    • Если локальная пуста — пытается украсть половину очереди у случайного P (work stealing).
    • Если красть не у кого — проверяет netpoller (готовые I/O горутины).
    • Если и там пусто — паркует M (засыпает).

Когда P без работы, он “крадёт” половину локальной очереди у другого случайного P. Это балансирует нагрузку без centralized scheduler-а. Сравните с моделью Erlang (там тоже work stealing).

_Gidle — только создана
_Grunnable — в очереди, готова бежать
_Grunning — исполняется на M
_Gsyscall — внутри syscall, M отвязан от P
_Gwaiting — заблокирована (chan, mutex, time.Sleep)
_Gdead — завершилась, лежит в кеше runtime для переиспользования

Когда G делает блокирующий syscall (read из файла, gettimeofday и т.п.):

  1. M остаётся привязан к G.
  2. P отвязывается от M.
  3. Runtime ищет/создаёт другого M, чтобы он подхватил P и продолжил работу.
  4. Когда syscall завершён — M пытается забрать обратно P. Если не получилось — G кладётся в global queue, а M идёт в кеш.

Это означает: число M ≥ число P. На сервисах с тяжёлым I/O M-ов может быть тысячи.

До 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
}
}

GOMAXPROCS = число P = максимальное число горутин, исполняющихся параллельно на CPU.

  • По умолчанию (Go 1.5+) = runtime.NumCPU().
  • Можно поставить руками: runtime.GOMAXPROCS(4) или GOMAXPROCS=4 ./app.

В 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.


⚠️ Классическая ловушка ДО 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-проектах его можно встретить.

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 обёртку для всех “фоновых” горутин в проде. Иначе один кривой запрос обрушит весь сервис.

Leak — горутина “повисла” навсегда и больше не освободится. Типичные причины:

func leak() {
ch := make(chan int) // unbuffered
go func() {
ch <- 42 // блокируется навсегда, если никто не читает
}()
// ch выпадает из scope, но горутина живёт.
}
func leak() <-chan int {
ch := make(chan int)
go func() {
result := slowOp()
ch <- result
}()
return ch // если caller никогда не прочтёт — горутина залипла
}
func leak() {
ctx, cancel := context.WithCancel(context.Background())
_ = cancel // забыли вызвать!
go worker(ctx)
// ctx живёт вечно → worker никогда не отменится
}

Всегда defer cancel().

go func() {
for {
select {
case msg := <-ch:
handle(msg)
// нет case <-ctx.Done()!
}
}
}()

Простейший способ — мониторить число горутин. Если оно растёт линейно от RPS — у вас leak.

http.HandleFunc("/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "goroutines: %d\n", runtime.NumGoroutine())
})
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.

go.uber.org/goleak — детектор утечек для тестов:

func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t)
// ... ваш тест
}
  • 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()
}

Цитата Роба Пайка: “Don’t make Go programs concurrent; make Go programs work, then make them concurrent if needed.”

Concurrency — это сложно. Гонки, дедлоки, leaks. Если задача линейная — пусть будет линейной. Не запускайте горутины “на всякий случай”.


go func() { ... }() // ~200-400 ns + 2 KB stack

В тысячу раз дешевле OS thread. Но не бесплатно. Не запускайте горутины в hot loop без необходимости.

  • OS thread switch: 1-2 μs (kernel involvement).
  • Goroutine switch: ~200 ns (userspace).

Goroutines на одном P используют один и тот же mcache (аллокатор). Это улучшает cache locality. Когда горутина “украдена” другим P — её данные могут оказаться в L2/L3 другого CPU → cache miss.

Окно терминала
# CPU
go test -cpuprofile=cpu.prof
go 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 contention
runtime.SetMutexProfileFraction(1)
go tool pprof http://localhost:6060/debug/pprof/mutex
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... код

Затем go tool trace trace.out — браузерный UI с graphical view горутин, GC, syscalls. Незаменим для отладки латентности.


  1. Что такое goroutine? Чем отличается от OS thread? Лёгкий userspace-поток, управляемый Go runtime. 2 KB начальный стек vs 1-2 MB у OS. Мультиплексируется на OS threads по модели M:N.

  2. Что такое GMP? G — goroutine, M — OS thread, P — логический процессор (содержит локальную очередь и кеш аллокатора). Число P = GOMAXPROCS.

  3. Сколько по умолчанию GOMAXPROCS? Равно runtime.NumCPU() (с Go 1.5).

  4. Что такое work stealing? Когда у P пустая локальная очередь, он “крадёт” половину очереди у другого случайного P.

  5. С какого размера стек у goroutine? С Go 1.4 — 2 KB. Растёт динамически (удваивается, копированием).

  6. Почему стек горутины может вырасти? Когда вызов функции не помещается. Runtime аллоцирует новый стек больше и копирует туда содержимое.

  7. Что произойдёт при panic в горутине без recover? Весь процесс упадёт. Recover в другой горутине не поможет.

  8. Что такое goroutine leak? Приведите пример. Горутина повисла навсегда. Пример: go func(){ ch <- v }() без receiver-а.

  9. Как обнаружить goroutine leaks? runtime.NumGoroutine(), pprof (/debug/pprof/goroutine), goleak в тестах.

  10. В каких состояниях бывает горутина? Grunnable, Grunning, Gwaiting, Gsyscall, Gdead, Gidle.

  11. Что происходит, когда горутина делает блокирующий syscall? M отвязывается от P. Runtime создаёт/берёт другой M, чтобы продолжить работу на P. Число M ≥ числу P.

  12. Что такое asynchronous preemption? С Go 1.14 — runtime может вытеснить горутину через SIGURG, даже если она в CPU-bound цикле без safe points.

  13. Что не так с for i := 0; i < 5; i++ { go func(){ println(i) }() } в Go 1.21? Переменная i была одна на цикл — все горутины видели её последнее значение. С Go 1.22 — по копии на итерацию.

  14. Что такое GOMAXPROCS и зачем automaxprocs? Число P. В контейнерах NumCPU() возвращает все ядра ноды, а не лимит контейнера → throttling. automaxprocs читает cgroup limits.

  15. Чем runtime.Gosched() отличается от time.Sleep(0)? Оба отдают P другой горутине. time.Sleep(0) идёт через таймер (чуть дороже), Gosched() напрямую дёргает планировщик.

  16. Что произойдёт, если main вернётся, а другие горутины ещё работают? Они будут прерваны (без defer cleanup!). Процесс завершится.

  17. Как корректно дождаться завершения всех горутин? sync.WaitGroup или errgroup. Никогда не time.Sleep.

  18. Можно ли запустить миллион горутин? Да, если они не голодны (например, висят на I/O). 2 KB × 1M = 2 GB памяти минимум.

  19. Почему worker pool лучше “go на каждый job”? Контроль над параллелизмом, переиспользование стеков, отсутствие burst-аллокаций.

  20. Что такое global run queue? Очередь горутин, доступная всем P. Туда попадают: горутины, которые не уместились в local queue; G, вернувшиеся из syscall, когда их P занят.

  21. Зачем нужен runnext? Слот в P для “только что созданной” горутины — она с высокой вероятностью продолжит работу того же контекста (cache locality, child-of-parent).

  22. Что такое netpoller? Не-блокирующий I/O loop runtime’а (epoll/kqueue/IOCP). Горутины, ждущие сетевой I/O, паркуются, не блокируя M. Когда I/O готов — G возвращается в run queue.

  23. Каким образом GC взаимодействует с горутинами? GC требует STW-пауз для критических фаз. Использует preemption, чтобы поставить все горутины в safe point. GC сканирует стеки всех горутин.

  24. Почему go func() { for {} }() мог повесить GC в Go 1.13? Не было async preemption — горутина никогда не доходила до safe point. STW-фаза GC не могла начаться, потому что одна G не паркуется.

  25. Имеют ли горутины свой goroutine ID? Да, внутренне есть G.goid. Но публичного API нет — разработчики Go сознательно скрыли, чтобы не плодили “thread-local storage” паттернов.


Напишите функцию 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
}
func server() {
for {
conn, _ := listener.Accept()
go func() {
buf := make([]byte, 1024)
conn.Read(buf) // без таймаута, без context
// ... process
}()
}
}

Leak: медленные/мёртвые клиенты держат горутины. Фикс: conn.SetReadDeadline() или context.WithTimeout.

Напишите safeGo(f func()) func(), который запускает f в горутине с recover и логированием. Возвращает функцию wait(), которая блокируется до завершения.

Запустите программу:

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.

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 задачи.


  1. Go Scheduler: Ms, Ps & Gs — Kavya Joshi: https://www.youtube.com/watch?v=YHRO5WQGh0k — лучшее видео-объяснение GMP.
  2. Дмитрий Вьюков, “Scalable Go Scheduler Design Doc” — оригинальный документ: https://golang.org/s/go11sched
  3. Go runtime source (proc.go)https://github.com/golang/go/blob/master/src/runtime/proc.go — для тех, кто хочет погрузиться в код.
  4. William Kennedy, “Scheduling In Go” — серия из 3 статей: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html
  5. The Go Memory Modelhttps://go.dev/ref/mem — формальная спецификация happens-before.