Menu

Горутины в Go: как они работают, с примерами

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

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Запуск горутины

Поставьте go перед вызовом функции, и этот вызов выполнится конкурентно. Инструкция возвращается сразу; вызывающий код не ждёт.

Четыре горутины выполняют shout одновременно. Порядок их завершения не определён, но вывод всегда в исходном порядке, потому что каждая горутина пишет в свой индекс, а main читает слайс только после того, как вернулся wg.Wait().

В этом примере уже есть три вещи, которые нужны почти любой программе с горутинами: способ запустить работу (go), способ её дождаться (sync.WaitGroup) и способ получить результаты, при котором две горутины не трогают одну и ту же память (у каждой свой элемент слайса).

main не ждёт

Когда main возвращает управление, программа завершается. Горутины, которые ещё работают, останавливаются там, где были. Никто их не ждёт.

Обычно это печатает только from main. Иногда горутина успевает получить время процессора, и вы видите обе строки. Это «обычно» и есть проблема: код работает на вашей машине и ломается на нагруженном сервере.

time.Sleep в конце main заставит пример напечатать обе строки, и это неправильное исправление. Оно угадывает, сколько длится работа. Ждите саму работу, через WaitGroup (подробно на странице о WaitGroup) или через канал.

Получение результатов

Инструкция go выбрасывает возвращаемые значения функции. x := go f() не компилируется. Есть два стандартных способа вернуть данные.

Своя ячейка для каждой горутины, как в первом примере. Заранее выделите слайс, дайте каждой горутине её индекс, читайте после Wait. Порядок сохраняется, и блокировки не нужны, потому что никакие две горутины не пишут в один элемент.

Канал. Каждая горутина отправляет свой результат, а получатель их собирает. Результаты приходят в порядке завершения, а не запуска.

Получение ровно len(nums) значений служит заодно и ожиданием: main не пройдёт цикл, пока каждая горутина не отправит значение. Порядок прихода меняется от запуска к запуску, поэтому программа сортирует, прежде чем печатать что-то зависящее от порядка. Буферизованные каналы, закрытие и range по каналу разобраны на странице о каналах.

Переменные цикла и замыкания (изменение в Go 1.22)

Начиная с Go 1.22 каждая итерация цикла for получает свежую копию переменных цикла. Замыкание, запущенное в горутине, захватывает значение своей итерации, поэтому так правильно:

for i, w := range words {
	go func() {
		results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
	}()
}

До Go 1.22 все итерации делили одни i и w, и каждая горутина, как правило, видела последнее значение. Старый код обходит это, передавая значения аргументами, go func(i int, w string) { ... }(i, w), или затеняя, i := i. На Go 1.22 и новее оба приёма безвредны, и в существующем коде вы их ещё встретите. Новое поведение действует, когда в go.mod модуля указано go 1.22 или выше.

Горутины дешёвые

Горутина начинает с маленького стека (несколько килобайт), который среда выполнения увеличивает и уменьшает по мере надобности. Планировщик Go выполняет горутины на пуле потоков ОС, и одновременно код Go исполняют не более GOMAXPROCS из них; по умолчанию GOMAXPROCS равен числу процессоров. Блокировка на канале, мьютексе, sleep или сетевом вводе-выводе паркует горутину и освобождает поток для другой.

Поэтому запускать по горутине на задачу нормально даже в больших количествах:

Сто тысяч горутин завершаются за доли секунды. Сумма всегда равна 4999950000, потому что atomic.Int64 делает каждое сложение неделимым. Но дёшево не значит бесплатно: каждая горутина, которая ещё заблокирована, держит в памяти свой стек и всё, на что ссылается.

Поток ОСГорутина
Кем создаётсяядромсредой выполнения Go
Начальный стекфиксированный, часто 1 МБ и большенесколько КБ, растёт по требованию
Переключениепереключение контекста в ядрепланировщик Go в пространстве пользователя
Идентификаторесть ID потокаID, который можно прочитать, нет намеренно
Типичное количествосотниот тысяч до миллионов

Гонки данных

Когда две горутины одновременно обращаются к одной переменной и хотя бы одна из них пишет, это гонка данных. Результат непредсказуем, а не просто «слегка неточен»: обновления теряются, а гонка на строке, слайсе, мапе или значении интерфейса может уронить программу или испортить память.

На многоядерной машине это в большинстве запусков печатает разные числа меньше 10000, потому что две горутины читают одно и то же старое значение и обе записывают его плюс один. На одном ядре может напечататься 10000, и это хуже: баг проходит ваш тест и проявляется в продакшене.

В Go есть детектор гонок. Запускайте программу или тесты с -race:

go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
  main.main.func1()
      /tmp/race/main.go:16 +0x94

Previous write at 0x00c000090038 by goroutine 6:
  main.main.func1()
      /tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66

Он указывает точную строку (counter++) и обе горутины. Он сообщает только о гонках, которые действительно случились во время запуска, поэтому запускайте его на тестах, которые проходят по конкурентным путям. Он замедляет программу в несколько раз, так что это инструмент для тестов и стейджинга, а не для продакшена.

Способы исправить, от простого к самому общему:

  • Не делиться. Дайте каждой горутине свои данные и объедините в конце (шаблон «своя ячейка для каждой горутины»).
  • Используйте sync/atomic для одного счётчика или флага: var n atomic.Int64; n.Add(1).
  • Используйте sync.Mutex вокруг всего, что побольше, например мапы или структуры с несколькими полями. На странице о мьютексе разобраны также RWMutex и sync.Once.
  • Передавайте данные через канал, чтобы в каждый момент ими владела одна горутина.

Паника в горутине убивает программу

Если горутина паникует и внутри этой же горутины ничто не делает recover, падает вся программа, включая main и все остальные горутины. recover в main не поможет, потому что recover перехватывает паники только в своей горутине.

Такое восстановление имеет смысл на границе долгоживущего сервера, где один плохой запрос не должен ронять всё остальное. В обычном коде паника, как правило, означает баг, и громкое падение это правильный исход.

Утечки горутин

Горутина, которая заблокирована навсегда, никогда не завершается и никогда не освобождает память. Классическая причина: отправка, которую никто никогда не получит:

func firstResult(urls []string) string {
	ch := make(chan string) // unbuffered
	for _, u := range urls {
		go func() { ch <- fetch(u) }()
	}
	return <-ch // takes the first result; the other senders block forever
}

Каждый вызов оставляет висеть len(urls) - 1 горутин. На сервере, который обрабатывает такой запрос тысячи раз, память растёт, пока процесс не умрёт. Два способа исправить: сделать канал достаточно большим, чтобы каждый отправитель мог завершиться (make(chan string, len(urls))), или дать горутинам способ сдаться, обычно через context.Context и select по ctx.Done(). Следить за утечками в тестах можно через runtime.NumGoroutine().

Ограничение числа одновременно работающих

«Горутина на каждый элемент» подходит для 10 000 дешёвых вычислений. Для 10 000 HTTP-запросов к одному серверу или 10 000 открытых файлов не подходит. Ограничьте конкурентность буферизованным каналом в роли семафора:

Буферизованный канал вмещает не больше 3 жетонов, поэтому в любой момент строку sem <- прошли не больше 3 горутин. Пик никогда не превышает 3, а с двенадцатью задачами, каждая из которых спит, на практике достигает 3. Другая частая форма это фиксированный пул воркеров, которые читают из канала заданий; его строит страница о WaitGroup.

Вне стандартной библиотеки golang.org/x/sync/errgroup объединяет в одном типе WaitGroup, первую ошибку, отмену через контекст и ограничение конкурентности (g.SetLimit(n)). Это обычный выбор в продакшен-коде, которому нужны все четыре вещи.

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

  • Забыли дождаться. main возвращает управление, и работа молча не выполняется. У каждой инструкции go должен быть способ узнать, что она завершилась.
  • Вызов wg.Add внутри горутины. Wait может выполниться раньше Add, увидеть нулевой счётчик и вернуться досрочно. Вызывайте Add до инструкции go.
  • Общая переменная без синхронизации. Частый случай это мапы: конкурентные записи в мапу обычно обнаруживаются средой выполнения и роняют программу с fatal error: concurrent map writes, которую recover не перехватит.
  • Расчёт на порядок. Горутины выполняются в том порядке, который выберет планировщик. Если вывод должен быть упорядочен, соберите и отсортируйте или пишите в ячейки по индексам.
  • Синхронизация через time.Sleep. Тесты становятся медленными и всё равно нестабильными. Ждите событие, а не догадку.
  • Горутина без способа её остановить. Всё, что крутится в цикле или ждёт ввода-вывода, должно принимать context.Context, чтобы вызывающий код мог это отменить.

Часто задаваемые вопросы

Что такое горутина в Go?

Горутина это вызов функции, который выполняется конкурентно с остальной программой. Чтобы её запустить, поставьте go перед вызовом: go work(). Горутинами управляет среда выполнения Go, а не операционная система, и она распределяет множество горутин по небольшому числу потоков ОС, поэтому тысячи горутин это нормально.

Как дождаться завершения горутин в Go?

Используйте sync.WaitGroup: вызывайте wg.Add(1) перед каждой инструкцией go, defer wg.Done() в начале горутины и wg.Wait() там, где нужно, чтобы все они завершились. Если горутины выдают значения, ожиданием может служить и получение из канала по одному значению на горутину.

Чем горутина отличается от потока?

У потока ОС фиксированный стек (часто 1 МБ и больше), и планирует его ядро. Горутина начинает со стека в несколько килобайт, который растёт по мере надобности, а переключает горутины планировщик Go в пространстве пользователя. Среда выполнения одновременно выполняет горутины не более чем на GOMAXPROCS потоках (по умолчанию это число процессоров).

Как получить возвращаемое значение из горутины?

Инструкция go отбрасывает возвращаемые значения функции. Отправьте результат в канал (results <- compute(x)) или запишите его в свою ячейку заранее выделенного слайса (out[i] = compute(x)) и прочитайте после wg.Wait().

Почему моя программа на Go завершается раньше, чем горутина что-то напечатает?

Когда main возвращает управление, программа заканчивается, и все остальные горутины останавливаются, не выполнив оставшийся код. Ничто не ждёт горутины автоматически. Заблокируйте main, пока работа не закончится, через WaitGroup или получение из канала. time.Sleep только прячет проблему.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ