Конкурентность встроена в Go на уровне языка. Чтобы запустить функцию параллельно, достаточно написать перед вызовом слово go. Такие параллельные функции называются горутинами. Общаются они через каналы. Это две базовые конструкции, на них держится весь конкурентный код в Go. Разберём обе с нуля и на работающих примерах: как запустить горутину, как дождаться её завершения, как передать данные и что такое select. И на чём тут спотыкаются почти все новички.
Это один из разборов в серии для начинающих. Общий маршрут изучения языка собран в статье «Go для начинающих: дорожная карта».
Что такое горутина
Горутина это функция, которую рантайм Go выполняет конкурентно с остальным кодом. Чтобы запустить её, перед вызовом ставится ключевое слово go:
func say(s string) {
fmt.Println(s)
}
func main() {
go say("привет из горутины")
say("привет из main")
}
Горутина похожа на поток операционной системы, но дешевле. Её начальный стек занимает около 2 КБ и растёт по мере необходимости. Планировщик Go сам распределяет тысячи горутин по нескольким системным потокам. Поэтому сотня тысяч горутин здесь это норма. А сотня тысяч потоков ОС положила бы систему.
Важная деталь: go say(...) не ждёт завершения функции. Он только планирует её запуск и сразу идёт дальше. Поэтому у примера выше есть проблема.
Главная ловушка: main не ждёт
Запустите код выше несколько раз, и результат окажется непредсказуемым. Иногда печатаются обе строки, иногда только «привет из main», иногда строки меняются местами.
Причина простая. Программа на Go завершается, как только заканчивается функция main. Запущенных горутин она не дожидается. Если main дошёл до конца раньше, чем горутина успела выполниться, эту горутину просто обрывают на полуслове.
Наивное решение «подождать немного» через time.Sleep не годится. Вы не знаете, сколько займёт горутина. Угадывать тайминг это путь к плавающим багам. Нужен явный механизм синхронизации.
Как дождаться горутины: WaitGroup
Самый прямой способ дождаться горутин это sync.WaitGroup. По сути это счётчик. Вы увеличиваете его перед запуском горутины. Горутина уменьшает его, когда закончила. Wait блокирует выполнение, пока счётчик не дойдёт до нуля.
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // зарегистрировали ещё одну горутину
go func(id int) {
defer wg.Done() // уменьшаем счётчик при выходе
fmt.Printf("горутина %d отработала\n", id)
}(i)
}
wg.Wait() // ждём, пока все три не вызовут Done
fmt.Println("все горутины завершились")
}
Здесь три правила, которые стоит запомнить сразу:
wg.Add(1)вызывается доgo, а не внутри горутины. ИначеWaitможет проскочить раньше, чем горутина успеет добавить себя в счётчик.wg.Done()ставится черезdefer, чтобы счётчик уменьшился при любом выходе из функции, включая панику.- Параметр
idпередаётся в горутину аргументом. Это защищает от классической ошибки с захватом переменной цикла (в Go до версии 1.22 все горутины могли увидеть одно и то же финальное значениеi).
Подробный разбор WaitGroup, включая метод Go из Go 1.25, есть в отдельной статье про WaitGroup.
WaitGroup решает задачу «дождаться». Но горутинам почти всегда нужно ещё и обмениваться данными. Для этого в Go есть каналы.
Каналы: как горутины общаются
Канал это типизированная труба, через которую одна горутина отправляет значения, а другая их принимает. Создаётся канал через make, отправка и приём обозначаются стрелкой <-:
ch := make(chan int) // канал для значений типа int
ch <- 42 // отправить значение в канал
x := <-ch // принять значение из канала
Каналы передают данные и заодно синхронизируют горутины. Философия Go формулируется так: «не общайтесь, разделяя память; разделяйте память, общаясь». То есть вместо мьютекса вокруг общей переменной вы передаёте данные по каналу. Владение ими переходит от одной горутины к другой.
Вот полный пример: горутина считает сумму и отправляет результат обратно в main.
func main() {
ch := make(chan int)
go func() {
sum := 0
for i := 1; i <= 100; i++ {
sum += i
}
ch <- sum // отправляем результат
}()
result := <-ch // main блокируется здесь, пока не придёт значение
fmt.Println(result) // 5050
}
Обратите внимание: здесь нет ни WaitGroup, ни Sleep. Строка result := <-ch блокирует main до тех пор, пока горутина не отправит значение. Канал сам выступает точкой синхронизации.
Небуферизованные и буферизованные каналы
Канал из примера выше небуферизованный: у него нет места для хранения. Отправка в такой канал блокируется, пока кто-то не будет готов принять, и наоборот. Это рандеву: отправитель и получатель встречаются в одной точке.
Буферизованный канал создаётся с указанием размера буфера вторым аргументом make. В него можно положить значения, пока буфер не заполнен, не дожидаясь получателя:
ch := make(chan int, 2) // буфер на 2 значения
ch <- 1 // не блокирует, в буфере есть место
ch <- 2 // не блокирует, буфер заполнен
// ch <- 3 заблокировался бы: буфер полон, получателя нет
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
Правила простые:
| Канал | Отправка блокируется | Приём блокируется |
|---|---|---|
| Небуферизованный | пока получатель не готов | пока отправитель не готов |
| Буферизованный | когда буфер полон | когда буфер пуст |
По умолчанию берите небуферизованный канал. Буфер это оптимизация под конкретный сценарий (например, сгладить рывки в скорости отправителя и получателя), а не значение по умолчанию.
Закрытие канала и range
Когда отправитель закончил слать данные, он закрывает канал через close. Получатель может перебирать канал циклом for range, который завершится автоматически, как только канал закроют:
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 3; i++ {
ch <- i
}
close(ch) // больше отправлять нечего
}()
for v := range ch { // цикл закончится, когда канал закроют
fmt.Println(v) // 1, 2, 3
}
}
Два правила про закрытие, которые избавят от целого класса паник:
- Закрывает канал только отправитель, и только тогда, когда отправлять больше нечего. Получатель закрывать канал не должен.
- Приём из закрытого канала не блокируется и сразу отдаёт нулевое значение. Чтобы отличить «пришло настоящее значение» от «канал закрыт», используют форму с двумя переменными:
v, ok := <-ch
// ok == true — пришло реальное значение
// ok == false — канал закрыт и опустошён, v == нулевое значение
select: ожидание нескольких каналов
Когда горутине нужно работать сразу с несколькими каналами, используется select. Он похож на switch, но каждый case это операция с каналом. select блокируется, пока хотя бы один из case не будет готов, и выполняет именно его:
select {
case v := <-ch1:
fmt.Println("из ch1:", v)
case v := <-ch2:
fmt.Println("из ch2:", v)
}
Если готовы сразу несколько case, select выбирает один из них случайно. Это сделано специально, чтобы один канал не «забивал» остальные.
Частый приём это таймаут через time.After. Функция возвращает канал, в который значение придёт через заданное время. Так select не зависнет навсегда:
select {
case v := <-ch:
fmt.Println("получили:", v)
case <-time.After(time.Second):
fmt.Println("истёк таймаут в 1 секунду")
}
Ещё одна форма это select с веткой default. Она делает операцию неблокирующей: если ни один канал не готов прямо сейчас, выполняется default и горутина идёт дальше, не дожидаясь.
В реальном коде для отмены и таймаутов чаще берут не time.After, а context.Context. Это стандартный механизм отмены горутин, ему посвящён отдельный разбор про context.
Типичные ошибки новичков
Deadlock
Если все горутины заблокированы и ждут друг друга, рантайм останавливает программу с ошибкой:
func main() {
ch := make(chan int)
ch <- 1 // некому принять, main блокируется навсегда
}
$ go run main.go
fatal error: all goroutines are asleep - deadlock!
Отправка в небуферизованный канал блокируется, а получателя нет: main это единственная горутина, и она же застряла на отправке. Go распознаёт такую ситуацию и падает с понятным сообщением.
Утечка горутин
Горутину никто не останавливает принудительно. Если она навсегда зависла на приёме из канала, в который уже никто не отправит, она будет висеть до конца жизни программы и держать память:
func leak() {
ch := make(chan int)
go func() {
<-ch // никто никогда не отправит, горутина повиснет навсегда
}()
// функция вышла, а горутина осталась висеть
}
Утечка горутины не падает сразу, потому и опасна. На проде она проявляется медленным ростом потребления памяти. Лечится одним правилом: всегда предусматривайте горутине выход. Через закрытие канала или отмену по context.
Отправка в закрытый канал
Отправить значение в закрытый канал нельзя, это вызывает панику:
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Поэтому и существует правило «закрывает только отправитель»: пока за закрытие отвечает тот, кто пишет в канал, такой паники не возникает.
Заключение
Горутины и каналы это фундамент конкурентности в Go:
go f()запускает функцию конкурентно, но не ждёт её. Дождаться завершения можно черезsync.WaitGroupили через получение результата из канала.- Каналы передают данные между горутинами и одновременно синхронизируют их. По умолчанию берите небуферизованный канал; буфер это осознанная оптимизация.
- Закрывает канал только отправитель;
for rangeпо каналу завершается при его закрытии;selectпозволяет ждать несколько каналов сразу. - Три главные ошибки это deadlock, утечка горутин и отправка в закрытый канал. Все три лечатся тем, что у каждой горутины предусмотрен явный выход.
Это базовые кирпичики. Из них в продакшене собирают повторяющиеся схемы: конвейеры, пулы воркеров, fan-out/fan-in. Как это делать правильно, как ограничивать параллелизм и обрабатывать ошибки, разобрано в статье «Concurrency в Go: worker pool, fan-out/fan-in и pipeline». Следующие шаги в изучении языка собраны в дорожной карте для начинающих.
FAQ
Чем горутина отличается от потока ОС?
Когда нужен буферизованный канал, а когда небуферизованный?
Кто должен закрывать канал?
Что такое deadlock и почему программа падает?
fatal error: all goroutines are asleep - deadlock!.Как остановить горутину?
context.Context. Если этого не сделать, горутина, зависшая на приёме из канала, утечёт и будет держать память до конца работы программы.Теги: