До сих пор наши программы делали одно дело за раз: строчка за строчкой, сверху вниз. Но реальные программы часто должны заниматься несколькими вещами как бы одновременно — обслуживать запрос, пока качается файл; считать в фоне, пока ждёшь ответа. В 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("все работники готовы")}
Обратите внимание на func(id int){...}(i) — мы передаём i аргументом, а не используем переменную цикла напрямую внутри горутины. Это привычка, которая бережёт от целого класса ошибок: каждая горутина получает свою копию числа, а не делит одну переменную на всех.
Порядок строк «работник 1/2/3» между собой не гарантирован — горутины независимы. А вот «все работники готовы» точно будет последней: wg.Wait() не пропустит main дальше, пока счётчик не обнулится.
WaitGroup отвечает на вопрос «закончились ли горутины?». Но часто нам нужно не просто дождаться, а получить результат работы. Для этого есть каналы.
Канал — это типизированная труба, в которую одна горутина кладёт значение, а другая его забирает. Создаётся через make, тип значений указывается явно:
ch := make(chan int) // канал для intch <- 42 // отправить: значение «уходит» в каналgot := <-ch // принять: значение «приходит» из канала
Стрелка <- всегда показывает, куда «течёт» значение: ch <- x кладёт в канал, <-ch достаёт. Маленькая мнемоника — стрелка указывает в сторону движения данных.
Самое важное про обычный (небуферизованный) канал: отправка и приём встречаются. Если горутина отправляет в канал, она замирает и ждёт, пока кто-то на другом конце не заберёт значение. И наоборот: приём ждёт, пока кто-то не отправит. То есть небуферизованный канал — это ещё и точка синхронизации: в момент передачи обе стороны гарантированно находятся «в одной точке».
Здесь WaitGroup даже не нужен. Строка got := <-ch сама по себе заставляет main ждать: она не продолжится, пока горутина не отправит значение. Приём из канала — это и синхронизация, и передача данных одновременно. Поэтому каналы часто оказываются изящнее, чем ручное ожидание.
Разберём чуть более «рабочий» пример: горутина считает сумму и присылает её обратно.
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)}
Заметили 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 блокируется и ждёт. В этом маленьком примере мы намеренно подготовили только один канал, чтобы результат был предсказуем.
Стоит честно сказать про границы этой главы. Примеры выше исполняются интерпретатором прямо в браузере — и там горутины работают, но по-настоящему параллельно не выполняются: всё крутится в один поток, без реального многоядерного исполнения и без race-детектора.
Поэтому код, который демонстрирует именно гонку данных (когда две горутины без синхронизации лезут к одной переменной), мы показываем статично, без кнопки «запустить»:
// Так делать НЕ нужно: две горутины пишут в один счётчик без синхронизации.// Это демонстрация гонки, а не пример для запуска — кнопки тут нет специально.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}
На настоящей машине под go run -race это выдаст предупреждение о data race, а итог окажется непредсказуемым. Правильно — защищать общие данные (sync.Mutex, sync/atomic) или вообще не делить переменную, а передавать данные через каналы. Подробно это разбирается в продвинутом курсе.
main заканчивается раньше горутин. Без WaitGroup или приёма из канала фоновые горутины могут не успеть ничего напечатать. Всегда явно дожидайтесь.
Забыли wg.Done() (или забыли Add). Если Done не вызовется — Wait() зависнет навсегда (deadlock). Надёжный приём: defer wg.Done() первой строкой горутины.
Чтение из канала, в который никто не пишет. Приём из небуферизованного канала блокируется до отправки. Если отправителя нет — горутина повиснет. Следите, чтобы у каждой отправки был получатель и наоборот.
Захват переменной цикла в горутине. Передавайте значение аргументом — go func(id int){...}(i) — чтобы каждая горутина работала со своей копией, а не с общей меняющейся переменной.
Вы увидели базовую тройку конкурентного Go: go запускает работу сбоку, каналы передают данные и синхронизируют, WaitGroup помогает дождаться. Этого уже хватает, чтобы читать чужой конкурентный код и писать простые свои примеры.
Дальше начинается интересное: закрытие каналов и range по каналу, направления и ёмкость буфера, паттерны вроде fan-in / fan-out, корректная отмена через context, мьютексы и атомики. За этим — в продвинутый курс: