Все главы учебника
Содержание учебника

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

13 мин чтенияКонтент v0.12.1

О чём глава

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

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

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

Здесь нужны функции, замыкания, цикл for, срезы и defer. Если go, канал результата и sync.WaitGroup встречаются вам впервые, сначала прочитайте вводную главу базового курса «Горутины и каналы: первое знакомство». Эта глава начинается с того места, где та закончилась.

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

Что делает go

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

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

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

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

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

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

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

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

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

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

Отсюда идиома: горутина на соединение, на запрос, на независимую единицу работы. Но дёшево не значит бесплатно. Горутина держит свой стек и всё, на что ссылаются её переменные: буферы, соединения, большие структуры. Горутина, которая никогда не завершится, держит это навсегда. Такая ошибка называется утечкой горутины, ей посвящена глава «Утечки горутин и гонки».

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

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)
}
сумма 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-лимит контейнера; подробности в главе «Планировщик глубже».

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

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

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

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

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

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

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

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

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

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

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

Нужна явная синхронизация: sync.WaitGroup или канал завершения. 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 ставят внутри неё:

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 жив, процесс не упал")
}
поймали панику: что-то сломалось
main жив, процесс не упал

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

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

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

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

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

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

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

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

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

Проверка

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

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 (перед ними прочитайте разделы о лимитерах, шардировании и обходе графа в конце главы «Паттерны»);
  • блок ревью: сначала 22, потом 21;
  • пулы: 24 → 28 → 29 → 30 → 27 → 31 → 25 → 26 → 23 → 32, от фиксированного пула к приоритетам, порядку и масштабированию.

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

Что дальше

Следующая глава, «Модель памяти», отвечает на вопрос, который возникает сразу после запуска первой пары горутин: когда одна горутина гарантированно видит то, что записала другая. Потом идут каналы и select, на которых построены задачи блока 1.

Продолжить

Остались вопросы?