---
title: "Горутины и планировщик"
description: "Представьте HTTP-сервер, к которому одновременно подключены десять тысяч клиентов. Большинство из них в каждый момент ничего не делает: ждёт ответа базы, дочитывает тело запроса…"
canonical: https://graphlms.ru/go/book/goroutines
course: "Конкурентность Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Горутины и планировщик

> Источник: https://graphlms.ru/go/book/goroutines — курс «Конкурентность Go», GraphLMS.

## О чём глава

Представьте HTTP-сервер, к которому одновременно подключены десять тысяч
клиентов. Большинство из них в каждый момент ничего не делает: ждёт ответа базы,
дочитывает тело запроса, держит keep-alive. Если на каждого клиента заводить поток
операционной системы, сервер упрётся в память и переключения задолго до того, как
загрузит процессор. В Go на каждого клиента заводят горутину, и десять тысяч
горутин для рантайма — обычная нагрузка.

В этой главе разберём, что именно создаёт слово `go`, почему горутина дешевле
потока, как рантайм раскладывает горутины по потокам (модель G-M-P) и что
происходит, когда горутина ждёт канал или уходит в системный вызов. Последний
вопрос самый практичный: от ответа на него зависит, сколько горутин можно себе
позволить и где они начнут мешать друг другу.

## Перед чтением

Здесь нужны функции, замыкания, цикл `for`, срезы и `defer`. Если `go`, канал
результата и `sync.WaitGroup` встречаются вам впервые, сначала прочитайте вводную
главу базового курса [«Горутины и каналы: первое знакомство»](/go-basics/book/goroutines-intro).
Эта глава начинается с того места, где та закончилась.

На первом проходе достаточно разделов про запуск, ожидание и блокировку. Устройство
G-M-P и системные вызовы можно перечитать после глав про каналы и `select`: тогда
примеры будут знакомыми, а модель сложится быстрее.

## Что делает `go`

Горутина — это функция, которая выполняется независимо от вызвавшего её кода, в том
же адресном пространстве: она видит те же глобальные переменные и те же объекты в
памяти. Запускается одним словом:

```go
go doWork()           // запустили и сразу пошли дальше
go func() {           // анонимная функция с замыканием
    fmt.Println("hi")
}()
```

`go` не ждёт. Аргументы вызова вычисляются сразу, в текущей горутине, а сама функция
ставится в очередь планировщика и начнёт выполняться когда-нибудь потом: может быть,
через микросекунду, может быть, после того как вызывающий код сделает ещё десяток
шагов. Результат функции, запущенной через `go`, теряется. Если он нужен, его
передают через канал или общую переменную под защитой.

Поэтому почти каждый `go` идёт в паре с ожиданием. Самое простое ожидание — приём из
канала:

```go play
package main

import "fmt"

func main() {
	done := make(chan string)
	go func() {
		done <- "горутина отработала"
	}()
	fmt.Println("main ждёт...")
	fmt.Println(<-done) // блокируемся, пока горутина не пришлёт результат
}
```

```text
main ждёт...
горутина отработала
```

Порядок здесь гарантирован, и причина не в скорости: вторая строка печатается только
после того, как `main` получил значение из канала, а получить его можно только после
отправки. Уберите приём `<-done`, и `main` завершится раньше горутины. Когда `main`
возвращается, программа заканчивается целиком, не дожидаясь остальных горутин.

## Почему горутина дешевле потока

У потока ОС фиксированный стек, который резервируется при создании: обычно от 1 до 8 МБ
адресного пространства. Переключение между потоками делает ядро: оно сохраняет
регистры, меняет контекст, решает, кого запустить дальше. Это занимает порядка
микросекунды.

Горутина стартует со стеком в несколько килобайт (исторически 2 КБ; с Go 1.19
рантайм подбирает стартовый размер по среднему фактическому использованию). Стек
растёт по мере надобности: когда места не хватает, рантайм выделяет область вдвое
больше и копирует туда стек. Переключение между горутинами происходит внутри
процесса, без системного вызова, и стоит в разы дешевле переключения потоков.

Посчитаем на сто тысяч ожидающих клиентов. Горутины по ~2–8 КБ займут сотни мегабайт.
Потоки по 1 МБ зарезервировали бы около сотни гигабайт адресного пространства, и
планировщик ОС пришлось бы нагружать переключениями между ними.

Отсюда идиома: горутина на соединение, на запрос, на независимую единицу работы.
Но дёшево не значит бесплатно. Горутина держит свой стек и всё, на что ссылаются её
переменные: буферы, соединения, большие структуры. Горутина, которая никогда не
завершится, держит это навсегда. Такая ошибка называется утечкой горутины, ей
посвящена глава [«Утечки горутин и гонки»](/go/book/leaks-and-races).

Тысяча горутин для Go — мелочь:

```go play
package main

import "fmt"

func main() {
	const n = 1000
	results := make(chan int, n) // буфер на n: отправка никогда не ждёт
	for i := 1; i <= n; i++ {
		go func(x int) { results <- x }(i)
	}
	sum := 0
	for i := 0; i < n; i++ {
		sum += <-results // принимаем ровно n значений
	}
	fmt.Println("сумма 1..1000 =", sum)
}
```

```text
сумма 1..1000 = 500500
```

Значения приходят в произвольном порядке, но сумма от порядка не зависит. Это первый
приём конкурентного кода, который будет встречаться постоянно: проверять результат,
который не зависит от порядка, а не сам порядок.

## Модель G-M-P

Рантайм оперирует тремя сущностями.

- **G** (goroutine) — горутина: стек, место, где она остановилась, состояние
  (выполняется, готова, ждёт).
- **M** (machine) — поток ОС. Только он физически выполняет инструкции.
- **P** (processor) — разрешение выполнять Go-код плюс локальная очередь готовых
  горутин. Число P равно `GOMAXPROCS`.

Правило одно: поток M выполняет Go-код, только пока держит P. Рабочий цикл выглядит
так: M берёт свой P, достаёт из его очереди готовую G и выполняет её. Когда G
блокируется, например на приёме из пустого канала, рантайм записывает её в список
ожидающих этого канала, а M берёт из очереди P следующую готовую G. Поток не простаивает,
пока есть кого выполнять.

`GOMAXPROCS` по умолчанию равен числу доступных процессору ядер (логических CPU).
С Go 1.25 на Linux рантайм учитывает ещё и CPU-лимит контейнера; подробности в главе
[«Планировщик глубже»](/go/book/scheduler-deep).

Из модели следует различие, которое путают даже опытные разработчики:

- **Параллелизм** — сколько горутин физически выполняется в один и тот же момент. Не
  больше числа P, а реально ещё и не больше числа ядер, которые дала ОС.
- **Конкурентность** — сколько независимых задач программа ведёт одновременно,
  продвигая их вперемешку. Это число G, и оно может быть огромным.

Конкурентная программа может работать и без параллелизма: с `GOMAXPROCS=1` горутины
по-прежнему чередуются, просто на одном потоке. Структура программы при этом та же.

## Когда горутина блокируется

Когда горутина ждёт на `<-ch`, `mu.Lock()`, `time.Sleep` или другом примитиве
рантайма, поток ОС не блокируется. Рантайм снимает горутину с потока (это называют
парковкой) и ставит на него следующую готовую. Один поток успевает обслужить тысячи
ожидающих горутин.

```go play
package main

import "fmt"

func main() {
	ch := make(chan int)
	go func() {
		ch <- 42 // горутина паркуется здесь, пока никто не читает
	}()
	v := <-ch // а здесь паркуется main, пока нет отправителя
	fmt.Println("получили:", v)
}
```

```text
получили: 42
```

Отправка в небуферизованный канал и приём из него встречаются: та сторона, что пришла
первой, паркуется и ждёт вторую. Обмен происходит в момент встречи. Пока обе ждут,
поток выполняет другие горутины или засыпает, если их нет.

С **блокирующими системными вызовами** иначе. Чтение файла с диска, вызов C-кода
через cgo, некоторые системные вызовы блокируют сам поток: ядро ОС не вернёт его, пока
вызов не закончится. Чтобы остальные горутины не стояли, рантайм отбирает у такого
потока P и отдаёт другому потоку, при необходимости создавая новый. Когда вызов
вернётся, горутина снова встанет в очередь готовых. Сетевой ввод-вывод в Go устроен
по-другому и поток не блокирует: им занимается сетевой поллер, о нём тоже в главе
[«Планировщик глубже»](/go/book/scheduler-deep).

## Частые ошибки

**`main` не ждёт горутины.**

```go
go save(data)  // main может выйти раньше, и save не успеет
```

Нужна явная синхронизация: [`sync.WaitGroup`](/go/book/sync-primitives) или канал
завершения. [context](/go/book/context) умеет передать сигнал «остановись», но сам не
ждёт завершения горутин. `time.Sleep(time.Second)` «чтобы успело» не синхронизирует
ничего: на загруженной машине секунды может не хватить, а на свободной вы просто
теряете время.

**Захват переменной цикла.** До Go 1.22 переменная цикла `for i := ...` была одна на
весь цикл, и горутины, читавшие её через замыкание, конфликтовали с изменением `i` в
цикле. С Go 1.22 (если в `go.mod` указана версия `go 1.22` или новее) переменная,
объявленная в заголовке цикла через `:=`, создаётся заново на каждой итерации. Но
цикл вида `for i = 0; ...` по заранее объявленной `i` по-прежнему использует одну
общую переменную. Передача значения аргументом, `go func(id int) {...}(i)`, работает
в любой версии и сразу показывает читателю, что горутина получает копию.

**Паника в горутине роняет весь процесс.** `recover` действует только в той горутине,
где выполняется отложенная функция. Непойманная паника в любой горутине завершает
всю программу, и `recover` в `main` её не поймает. Если код в горутине может
паниковать, `defer` с `recover` ставят внутри неё:

```go play
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		defer func() {
			if r := recover(); r != nil {
				fmt.Println("поймали панику:", r)
			}
		}()
		panic("что-то сломалось")
	}()
	wg.Wait()
	fmt.Println("main жив, процесс не упал")
}
```

```text
поймали панику: что-то сломалось
main жив, процесс не упал
```

Отложенные функции выполняются в обратном порядке: сначала `recover`, потом
`wg.Done()`. Поэтому `main` продолжает работу только после того, как паника уже
обработана.

**Рассчитывать на порядок запуска.** «Я запустил A раньше B, значит A начнёт первой» —
неверно. Если порядок важен, его задают синхронизацией: каналом, WaitGroup, мьютексом.

## Песочница и настоящий параллелизм

Примеры с кнопкой запуска выполняются прямо в браузере. Горутины там работают, но
по очереди на одном потоке: параллельного выполнения нет, race detector (детектор
гонок данных) недоступен. Для логики ожидания, каналов и отмены этого хватает. Гонки данных и поведение под нагрузкой
проверяют локально: `go run -race main.go` или `go test -race ./...`. Разбор гонок и
настоящий отчёт детектора есть в главе [«Утечки горутин и гонки»](/go/book/leaks-and-races);
дальше в книге примеры с гонками даны без кнопки запуска.

## Мысленная модель

Горутина — дешёвая задача, которую рантайм сам раскладывает по небольшому числу
потоков. Запуск дешёвый, переключение дешёвое, ожидание канала или мьютекса не занимает
поток. Но у каждой горутины должен быть понятный конец.

При каждом `go` задавайте себе вопрос: **кто и когда остановит эту горутину?** Если
ответа нет, перед вами будущая утечка. Этот вопрос будет возвращаться в каждой главе
и почти в каждой задаче тренажёра.

## Попробуйте сами

Запустите три горутины с номерами 1, 2 и 3. Каждая печатает свой номер. Через
`WaitGroup` дождитесь всех и напечатайте `готово`. `time.Sleep` не используйте. До
запуска запишите, что в выводе гарантировано, а что зависит от планировщика.

### Проверка

Гарантировано: каждый номер напечатан ровно один раз, `готово` — последняя строка.
Порядок номеров не гарантирован, и при нескольких запусках локально он будет меняться.

```go
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println(id)
    }(i)
}
wg.Wait()
fmt.Println("готово")
```

Нужны импорты `fmt` и `sync`. `wg.Add(1)` стоит до `go`: если перенести его внутрь
горутины, `Wait` может увидеть нулевой счётчик раньше, чем горутины успеют
зарегистрироваться, и вернуться сразу. Ответ на вопрос «кто остановит горутину»
здесь простой: она печатает номер, вызывает `Done` и заканчивается.

## Как читать книгу и решать задачи

Главы идут по порядку, каждая опирается на предыдущие. Если в главе о модели памяти
встретится ещё незнакомый примитив (`Mutex`, `Once`, atomic), запомните его гарантию
как ориентир и вернитесь к списку после глав про `sync` и atomic. «Планировщик глубже»
можно оставить на конец.

Задачи тренажёра удобнее решать не строго по номерам:

- после глав «Каналы» и «select» — блок 1 в порядке **02 → 03 → 04 → 01**: задача 01
  требует рекурсии, к ней лучше подойти после разминки;
- после `sync` и atomic — задачи 05–08;
- после context и паттернов — 09–12, затем 13–15;
- затем 16–20 (перед ними прочитайте разделы о лимитерах, шардировании и обходе графа
  в конце главы [«Паттерны»](/go/book/patterns));
- блок ревью: сначала 22, потом 21;
- пулы: **24 → 28 → 29 → 30 → 27 → 31 → 25 → 26 → 23 → 32**, от фиксированного пула
  к приоритетам, порядку и масштабированию.

Перед каждым решением ответьте на три вопроса: что именно проверяет тест, кто
закрывает каждый канал и как заканчивается каждая вспомогательная горутина. Затем
проверьте успешный путь, отмену и крайние случаи из условия, и только после этого
запускайте `go test -race`.

## Что дальше

Следующая глава, [«Модель памяти»](/go/book/memory-model), отвечает на вопрос, который
возникает сразу после запуска первой пары горутин: когда одна горутина гарантированно
видит то, что записала другая. Потом идут [каналы](/go/book/channels) и
[`select`](/go/book/select), на которых построены задачи блока 1.
