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

Горутины и каналы: первое знакомство

14 мин чтенияКонтент v0.12.0

О чём глава

До сих пор наши программы делали одно дело за раз: строчка за строчкой, сверху вниз. Но реальные программы часто должны заниматься несколькими вещами как бы одновременно — обслуживать запрос, пока качается файл; считать в фоне, пока ждёшь ответа. В Go для этого есть две идеи, которые работают в паре: горутины (дешёвые независимые «ниточки» выполнения) и каналы (способ безопасно передавать данные между ними).

Это вводная глава перед курсом «Конкурентность Go». В тонкости не углубляемся. Цель — три вещи: запустить горутину легко; каналы нужны, чтобы передавать данные и дожидаться друг друга; горутины нужно дождаться, иначе программа закончится раньше, чем они успеют сработать.

Что такое горутина

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

go doWork()      // запустили и сразу пошли дальше
go send(msg, to) // аргументы вычисляются сразу, а сама функция — отдельно

Главная интуиция: go f() не означает «сделай f() и верни результат». Это означает «начни делать f() где-то рядом, а я тем временем продолжу со следующей строки». Результат вызова go мы не получаем — горутина живёт сама по себе.

Почему это удобно? Горутины дешёвые. Поток операционной системы занимает заметный объём памяти, и держать их десятки тысяч накладно. Горутина стартует с маленьким стеком в несколько килобайт, а среда выполнения Go сама раскладывает множество горутин по небольшому числу потоков. Поэтому сервер на Go спокойно держит по горутине на каждый входящий запрос, даже когда их десятки тысяч.

Но у дешевизны есть оборотная сторона, на которой часто спотыкаются новички. Смотрите:

func main() {
    go fmt.Println("привет из горутины")
    fmt.Println("привет из main")
}

Очень вероятно, что напечатается только «привет из main». Почему? Когда main доходит до конца, программа завершается целиком — и Go не ждёт, пока фоновые горутины доделают свои дела. Горутина просто не успела дойти до печати. Запомните это как главное правило главы: горутины нужно дождаться. Чем именно — разберём дальше.

Ждём горутины через WaitGroup

Самый прямой способ сказать «подожди, пока эти горутины закончат» — это sync.WaitGroup. Идея как у счётчика дел: добавили N дел, каждая горутина по завершении отмечает «-1», а главная функция ждёт, пока счётчик не обнулится.

Три метода и всё:

  • wg.Add(n) — «появилось n дел», вызываем до запуска горутин.
  • wg.Done() — «одно дело сделано», вызываем внутри горутины (удобно через defer).
  • wg.Wait() — «жду, пока все дела не закончатся», блокирует, пока счётчик не дойдёт до нуля.
package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	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("все работники готовы")
}

Один из возможных выводов:

работник 3 закончил
работник 1 закончил
работник 2 закончил
все работники готовы

Порядок строк «работник 1/2/3» между собой не гарантирован: горутины независимы, и при каждом запуске он может быть другим. А вот «все работники готовы» всегда будет последней: wg.Wait() не пропустит main дальше, пока счётчик не обнулится.

Обратите внимание на func(id int){...}(i): мы передаём i аргументом. Так каждая горутина получает собственную копию числа. Начиная с Go 1.22 переменная цикла и так создаётся заново на каждой итерации, и можно было бы написать просто fmt.Println("работник", i, ...) внутри замыкания. Но в старом коде вы часто встретите передачу аргументом, а явная копия работает в любой версии.

В Go 1.25 у WaitGroup появился метод wg.Go(f): он сам делает Add(1), запускает f в горутине и вызывает Done() после неё. Тот же пример с ним короче:

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	var wg sync.WaitGroup
 
	for i := 1; i <= 3; i++ {
		wg.Go(func() {
			fmt.Println("работник", i, "закончил")
		})
	}
 
	wg.Wait()
	fmt.Println("все работники готовы")
}

Порядок строк «работник» и здесь не гарантирован. Классическую тройку Add/Done/Wait вы всё равно встретите в учебниках и чужом коде, поэтому в курсе дальше используется она.

Каналы: как горутины общаются

WaitGroup отвечает на вопрос «закончились ли горутины?». Но часто нам нужно не просто дождаться, а получить результат работы. Для этого есть каналы.

Канал — это типизированная труба, в которую одна горутина кладёт значение, а другая его забирает. Создаётся через make, тип значений указывается явно:

ch := make(chan int)   // канал для int
ch <- 42               // отправить: значение «уходит» в канал
got := <-ch            // принять: значение «приходит» из канала

Стрелка <- всегда показывает, куда «течёт» значение: ch <- x кладёт в канал, <-ch достаёт. Маленькая мнемоника — стрелка указывает в сторону движения данных.

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

Вот передача одного значения из горутины в main:

package main
 
import "fmt"
 
func main() {
	ch := make(chan string)
 
	go func() {
		ch <- "посылка доставлена"
	}()
 
	got := <-ch
	fmt.Println("получили:", got)
}

Вывод: получили: посылка доставлена. Здесь WaitGroup не нужен: строка got := <-ch сама заставляет main ждать, пока горутина не отправит значение. Приём из канала — это и передача данных, и ожидание. Когда горутина должна вернуть результат, канал обычно проще, чем WaitGroup плюс общая переменная.

Разберём чуть более «рабочий» пример: горутина считает сумму и присылает её обратно.

package main
 
import "fmt"
 
func sum(nums []int, out chan<- int) {
	total := 0
	for _, n := range nums {
		total += n
	}
	out <- total
}
 
func main() {
	out := make(chan int)
	go sum([]int{1, 2, 3, 4, 5}, out)
 
	result := <-out
	fmt.Println("сумма:", result)
}

Вывод: сумма: 15. Заметили chan<- int в сигнатуре sum? Это канал, в который функции разрешено только отправлять. Так мы прямо в типе фиксируем намерение: функция-счётчик отдаёт результат, но не читает из канала. Компилятор за этим проследит, а читателю кода сразу понятна роль функции. Бывает и зеркальный вариант <-chan int — «только для приёма».

Крошечный select

Бывает, что горутина ждёт сразу от нескольких каналов — кто первый пришлёт, того и обрабатываем. Для этого есть select. Он похож на switch, но ветки — это операции с каналами, и срабатывает та, что готова первой:

package main
 
import "fmt"
 
func main() {
	a := make(chan string, 1)
	b := make(chan string, 1)
 
	a <- "из канала A"
 
	select {
	case msg := <-a:
		fmt.Println("пришло:", msg)
	case msg := <-b:
		fmt.Println("пришло:", msg)
	}
}

Здесь каналы буферизованные — make(chan string, 1) создаёт канал с буфером на одно значение. В буферизованный канал можно положить значение и не ждать получателя сразу (пока в буфере есть место). Поэтому a <- "из канала A" в main не блокируется: значение легло в буфер. Дальше select смотрит: канал a готов отдать значение, b пуст — выбирается первая ветка. Вывод детерминирован: «пришло: из канала A».

Когда готовы сразу несколько веток, select выбирает одну равновероятно и псевдослучайно. Это не задаёт строгого приоритета и не гарантирует отсутствия голодания. Если же не готова ни одна и нет ветки default, select блокируется и ждёт. В этом маленьком примере мы намеренно подготовили только один канал, чтобы результат был предсказуем.

Почему гонки здесь не запускаются

Примеры выше выполняются в браузере. Горутины там работают, но по-настоящему параллельно не выполняются: всё идёт в одном потоке, без нескольких ядер процессора и без детектора гонок.

Поэтому код, который демонстрирует именно гонку данных (когда две горутины без синхронизации лезут к одной переменной), мы показываем статично, без кнопки «запустить»:

// Так делать НЕ нужно: две горутины пишут в один счётчик без синхронизации.
// Это демонстрация гонки, а не пример для запуска — кнопки тут нет специально.
package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	var counter int
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // гонка: чтение+запись без защиты
		}()
	}
	wg.Wait()
	fmt.Println(counter) // НЕ обязательно 1000
}

Это гонка данных (data race): две горутины одновременно обращаются к одной переменной, и хотя бы одна из них пишет. counter++ — это три шага: прочитать, прибавить, записать. Две горутины могут прочитать одно и то же 41, обе запишут 42, и одно увеличение потеряется. На настоящей машине программа будет печатать то 1000, то 958, то 991. Запуск через go run -race main.go покажет предупреждение WARNING: DATA RACE с указанием строк. Правильно — защищать общие данные (sync.Mutex, sync/atomic) или вообще не делить переменную, а передавать данные через каналы. Подробно это разбирается в продвинутом курсе.

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

  • main заканчивается раньше горутин. Без WaitGroup или приёма из канала фоновые горутины могут не успеть ничего напечатать. Всегда явно дожидайтесь.
  • Забыли wg.Done() (или забыли Add). Если Done не вызовется — Wait() зависнет навсегда (deadlock). Надёжный приём: defer wg.Done() первой строкой горутины.
  • Чтение из канала, в который никто не пишет. Приём из небуферизованного канала блокируется до отправки. Если отправителя нет — горутина повиснет. Следите, чтобы у каждой отправки был получатель и наоборот.
  • Захват общей переменной цикла в горутине. Для переменных, объявленных циклом через :=, версия языка Go 1.22 создаёт отдельную переменную на итерацию; при = переменная остаётся общей. Передавайте значение аргументом — go func(id int){...}(i) — чтобы каждая горутина работала со своей копией, а не с общей меняющейся переменной.

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

Напишите sumAsync(nums []int) <-chan int: функция создаёт канал, запускает одну горутину, считает сумму и передаёт результат. В main вызовите её для []int{2, 4, 6}, примите число и напечатайте его. Затем проверьте пустой срез. Не используйте time.Sleep.

Проверка

Для первого входа результат 12, для пустого — 0:

func sumAsync(nums []int) <-chan int {
    out := make(chan int)
    go func() {
        total := 0
        for _, n := range nums {
            total += n
        }
        out <- total
    }()
    return out
}

В main достаточно fmt.Println(<-sumAsync([]int{2, 4, 6})). Для пустого среза цикл не выполнится ни разу, и горутина отправит 0. Канал передаёт ровно одно значение, поэтому закрывать его не нужно.

Обратите внимание: приём здесь обязателен. Если main не прочитает из канала, горутина навсегда застрянет на строке out <- total. Такая «застрявшая» горутина называется утечкой, и ей посвящена одна из первых задач курса по конкурентности. И ещё: пока горутина считает, вызывающий код не должен менять элементы nums — иначе получится гонка данных из раздела выше.

Готовы ли вы идти дальше

Проверьте себя без подсказок: напишите функцию с несколькими результатами и ошибкой, пройдите по срезу и соберите карту, измените поле структуры через указатель, проверьте error через errors.Is, напишите табличный тест. Если что-то из этого не получается, вернитесь к соответствующей главе: в курсе по конкурентности всё это используется без объяснений. Особенно часто там встречаются замыкания, указатели и общий массив среза; дженерики и встраивание можно освежить позже.

Порядок в курсе «Конкурентность Go» такой. Сначала главы о горутинах, каналах и select; устройство планировщика (G-M-P) можно пропустить при первом чтении и вернуться к нему потом. Из задач начните с 02, затем 03 и 04: они разогревают на утечках горутин, таймаутах и select. Задача 01 требует рекурсивной сборки каналов, её лучше решать после них.

Задачи там проверяются тестами с флагом -race. Он ловит гонки только на тех путях, которые тест реально выполнил, поэтому сначала добейтесь правильного результата, а потом смотрите на гонки.

Что дальше

В курсе по конкурентности вас ждут закрытие каналов и range по каналу, ёмкость буфера, паттерны вроде fan-in и fan-out, отмена через context, мьютексы и атомики:

Продолжить

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