До сих пор наши программы делали одно дело за раз: строчка за строчкой, сверху вниз. Но реальные программы часто должны заниматься несколькими вещами как бы одновременно — обслуживать запрос, пока качается файл; считать в фоне, пока ждёшь ответа. В 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 не ждёт, пока фоновые горутины доделают свои дела. Горутина просто не успела дойти до печати. Запомните это как главное правило главы: горутины нужно дождаться. Чем именно — разберём дальше.
Самый прямой способ сказать «подожди, пока эти горутины закончат» — это sync.WaitGroup. Идея как у счётчика дел: добавили N дел, каждая горутина по завершении отмечает «-1», а главная функция ждёт, пока счётчик не обнулится.
Три метода и всё:
wg.Add(n) — «появилось n дел», вызываем до запуска горутин.
wg.Done() — «одно дело сделано», вызываем внутри горутины (удобно через defer).
wg.Wait() — «жду, пока все дела не закончатся», блокирует, пока счётчик не дойдёт до нуля.
package mainimport ( "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("все работники готовы")}
Порядок строк «работник 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 mainimport ( "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) // канал для intch <- 42 // отправить: значение «уходит» в каналgot := <-ch // принять: значение «приходит» из канала
Стрелка <- всегда показывает, куда «течёт» значение: ch <- x кладёт в канал, <-ch достаёт. Маленькая мнемоника — стрелка указывает в сторону движения данных.
Самое важное про обычный (небуферизованный) канал: отправка и приём встречаются. Горутина, которая отправляет в канал, замирает и ждёт, пока кто-то на другом конце не заберёт значение. И наоборот: приём ждёт, пока кто-то не отправит. Поэтому такой канал не только передаёт данные, но и синхронизирует: после приёма получатель точно знает, что отправитель дошёл до строки с отправкой.
Вывод: получили: посылка доставлена. Здесь WaitGroup не нужен: строка got := <-ch сама заставляет main ждать, пока горутина не отправит значение. Приём из канала — это и передача данных, и ожидание. Когда горутина должна вернуть результат, канал обычно проще, чем WaitGroup плюс общая переменная.
Разберём чуть более «рабочий» пример: горутина считает сумму и присылает её обратно.
package mainimport "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. Он похож на switch, но ветки — это операции с каналами, и срабатывает та, что готова первой:
package mainimport "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 mainimport ( "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.
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, мьютексы и атомики: