{}const=>[]async()letfn</>var
РазработкаBackend

Каналы в Go без боли: как горутины передают данные друг другу

Разбираемся, что такое каналы в Go, зачем нужны chan, буфер, close и select, почему возникает deadlock и как горутины обмениваются данными на реальных примерах.

К

Кодик

Автор

8 мин чтения

🎮 Представим Go как кооперативную игру

У нас есть несколько персонажей — горутины.

Одна получает данные из API.

Вторая обрабатывает картинки.

Третья сохраняет результат.

Четвёртая пытается понять, почему прод снова лёг.

Чтобы все эти персонажи не таскали один объект одновременно, Go предлагает им каналы.

Горутина A
    │
    │ данные
    ▼
 [ channel ]
    │
    │ данные
    ▼
Горутина B

То есть канал — это буквально точка передачи данных между параллельно работающими частями программы.

Именно поэтому каналы особенно часто встречаются рядом с:

go someFunction()

Горутины запускают работу параллельно, а каналы помогают этой работой управлять.

🔥 100 000+ учеников уже с нами

Устал читать теорию?
Пора кодить!

Кодик — приложение, где ты учишься программировать через практику. AI-наставник, интерактивные уроки, реальные проекты.

🤖 AI 24/7
🎓 Сертификаты
💰 Бесплатно
🚀 Начать учиться
Присоединились сегодня

Первый канал за 30 секунд

Канал создаётся через make:

messages := make(chan string)

Здесь мы создали канал, через который можно передавать строки.

Отправка:

messages <- "готово"

Получение:

msg := <-messages

Стрелка показывает направление движения данных.

Можно запомнить визуально:

channel <- value

Значение летит в канал.

А здесь:

value := <-channel

Значение вылетает из канала.

Довольно приятно, когда синтаксис буквально рисует происходящее 😎

Почему нельзя написать всё просто подряд?

Можно.

result := download()
process(result)
save(result)

И для огромного количества программ именно так и нужно делать.

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

Они начинают быть полезными, когда несколько задач должны выполняться независимо или одновременно.

Например:

go downloadImage()
go sendEmail()
go generateReport()

Теперь возникает вопрос: как узнать, когда задача завершилась и какой результат она получила?

Вот тут канал уже выглядит весьма кстати.

func loadData(ch chan string) {
    ch <- "Данные загружены"
}
func main() {
    ch := make(chan string)
    go loadData(ch)
    result := <-ch
    fmt.Println(result)
}

main запускает горутину и затем ждёт сообщение из канала.

Горутина заканчивает работу и отправляет результат.

Никакой телепатии.

⚠️ Но есть подвох: обычный канал умеет ждать

Вот это одна из самых важных вещей для понимания каналов.

Создадим канал:

ch := make(chan int)

Если выполнить:

ch <- 10

Go попытается отправить 10.

Но если никто сейчас не готов его получить, отправляющая горутина остановится и будет ждать.

Получение работает аналогично:

value := <-ch

Если данных пока нет — получатель ждёт.

Это называется блокировкой.

И это не баг. Это одна из главных возможностей каналов.

Диалог выглядит примерно так:

Отправитель: — Держи данные.

Получатель: — Я ещё не пришёл.

Отправитель: — Окей, жду.

...

Получатель: — Пришёл.

Отправитель: — Наконец-то 😐

После этого значение передаётся, и выполнение продолжается.

И вот тут новички встречают deadlock 💀

Классика:

func main() {
    ch := make(chan int)
    ch <- 5
    fmt.Println(<-ch)
}

На первый взгляд:

  1. положили 5;

  2. достали 5;

  3. радуемся.

Но программа зависнет.

Почему?

Отправка:

ch <- 5

ждёт получателя.

А до строки:

fmt.Println(<-ch)

программа никогда не доберётся.

Я продолжу работу, когда кто-нибудь прочитает канал.

И одновременно: кто-нибудь прочитает канал, когда я продолжу работу.

Прекрасный корпоративный процесс.

Решение — например, выполнить отправку в отдельной горутине:

func main() {
    ch := make(chan int)
    go func() {
        ch <- 5
    }()
    fmt.Println(<-ch)
}

Теперь одна горутина отправляет данные, а другая принимает.

А теперь появляется буфер 📦

Обычный канал похож на передачу коробки из рук в руки.

ch := make(chan int)

Отправитель не отпустит коробку, пока получатель её не возьмёт.

Но можно создать буферизированный канал:

ch := make(chan int, 3)

Теперь внутри есть место для трёх значений.

ch <- 10
ch <- 20
ch <- 30

И только затем:

fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)

Получим:

10
20
30

Буфер можно представить как небольшую полку между двумя работниками.

Без буфера:

A 🤝 B

Передача происходит напрямую.

С буфером:

A → [📦 📦 📦] → B

Отправитель может положить несколько значений и заниматься дальше своими делами.

Но полка не бесконечная.

Если канал рассчитан на три значения:

make(chan int, 3)

и все три места заняты, следующая отправка снова будет ждать.

Канал — не очередь сообщений из микросервисов

На этом месте важно не уехать мыслью куда-нибудь в RabbitMQ.

Go-каналы существуют внутри процесса вашей программы.

goroutine
    ↓
 channel
    ↓
goroutine

Это механизм конкурентного программирования внутри приложения.

Если приложение завершилось — ваш канал не останется где-нибудь в облаке бережно хранить сообщения до понедельника.

Закрываем канал 🚪

Допустим, одна горутина генерирует числа:

func generate(ch chan int) {
    for i := 1; i <= 5; i++ {
        ch <- i
    }
    close(ch)
}

После последнего числа она вызывает:

close(ch)

Тем самым сообщает:

Всё. Новых значений больше не будет.

Получатель может спокойно читать канал через range:

for value := range ch {
    fmt.Println(value)
}

Получим:

1
2
3
4
5

После закрытия канала цикл закончится.

Без close(ch) получатель может продолжить ждать данные, которые никогда не появятся.

Кто должен закрывать канал?

Обычно — тот, кто отправляет данные.

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

Всё, больше ничего отправлять не собираюсь.

Закрывать канал со стороны получателя — примерно как сотруднику склада самостоятельно объявить:

— Поставок больше не будет.

Поставщик:

— Вообще-то машина уже выехала.

А что произойдёт после close()?

Получать значения из закрытого канала можно.

Отправлять новые — нельзя.

close(ch)
ch <- 10

Результат:

panic: send on closed channel

Go довольно недвусмысленно объясняет, что вечеринка закончилась.

Проверить, открыт ли канал, можно так:

value, ok := <-ch

Если:

ok == true

значение успешно получено.

Если:

ok == false

канал закрыт и значений больше нет.

Самая интересная штука — select 🎛️

Представьте, что ваша программа ждёт данные сразу из нескольких каналов.

Например:

resultCh
errorCh

Можно написать:

select {
case result := <-resultCh:
    fmt.Println("Результат:", result)
case err := <-errorCh:
    fmt.Println("Ошибка:", err)
}

select ждёт, какой канал будет готов первым.

Это немного похоже на диспетчера:

Канал №1, есть что-нибудь?

Нет.

Канал №2?

Есть ошибка!

Берём ошибку.

select + timeout = уже очень полезно

Например, мы не хотим ждать операцию вечность.

select {
case result := <-ch:
    fmt.Println(result)
case <-time.After(2 * time.Second):
    fmt.Println("Слишком долго")
}

Если значение придёт раньше двух секунд — обработаем его.

Если нет:

Слишком долго

И приложение не будет сидеть вечно в ожидании ответа от функции, которая мысленно уже уехала в отпуск.

Реальный пример: запускаем несколько задач

Теперь соберём маленький пример, который больше похож на настоящую программу.

package main
import (
    "fmt"
    "time"
)
func worker(id int, ch chan string) {
    time.Sleep(time.Second)
    ch <- fmt.Sprintf("Worker %d завершил работу", id)
}
func main() {
    ch := make(chan string)
    for i := 1; i <= 3; i++ {
        go worker(i, ch)
    }
    for i := 1; i <= 3; i++ {
        fmt.Println(<-ch)
    }
}

Мы запускаем три горутины:

go worker(1, ch)
go worker(2, ch)
go worker(3, ch)

Каждая работает независимо.

Когда задача выполнена, результат отправляется в один канал:

ch <- result

А main собирает три результата.

Вот здесь становится понятно, зачем вообще придумали каналы.

Они позволяют строить схемы вроде:

             → worker 1 →
задачи →     → worker 2 →     → результаты
             → worker 3 →

Это уже база для worker pool, параллельной обработки файлов, сетевых запросов, фоновых задач и множества других сценариев.

Маленькая проверка: вы уже понимаете каналы?

Есть код:

ch := make(chan string, 2)
ch <- "A"
ch <- "B"
fmt.Println(<-ch)

Что будет выведено?

Да:

A

Почему программа не зависла на первых двух отправках?

Потому что размер буфера:

2

А если написать:

ch <- "A"
ch <- "B"
ch <- "C"

без дополнительной горутины?

На третьей отправке программа будет ждать свободное место.

Если этот момент понятен — поздравляем, базовую механику каналов вы уже поймали 🎉

Каналы или mutex?

Вот здесь начинаются вопросы уровня:

А зачем мне каналы, если существует sync.Mutex?

Потому что это инструменты для разных сценариев.

Mutex защищает совместно используемые данные:

mu.Lock()
counter++
mu.Unlock()

Идея:

У нас есть общая штука. Давайте не будем менять её одновременно.

Каналы чаще строятся вокруг другой идеи:

Давайте вообще передавать данные между участниками вместо того, чтобы всем одновременно лазить в одно место.

Но это не значит: Mutex плохо. Channel хорошо.

Иногда Mutex — идеальное решение.

Иногда канал.

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

Когда каналы реально стоит использовать?

Канал хорошо ложится на задачу, если нужно:

  • получать результаты от горутин;

  • распределять задачи между workers;

  • строить pipeline обработки данных;

  • ожидать несколько конкурентных событий;

  • отправлять сигналы между частями программы;

  • ограничивать количество одновременно выполняемых операций;

  • организовывать producer/consumer-сценарии.

Например:

URL
 ↓
download
 ↓
channel
 ↓
parse
 ↓
channel
 ↓
save

Каждый этап может выполняться отдельными горутинами.

Получается настоящий pipeline.

Три ошибки, после которых начинаешь уважать каналы 😅

1. Чтение из канала, куда никто ничего не отправит

ch := make(chan int)
fmt.Println(<-ch)

Получатель ждёт. Отправителя нет. Финал очевиден.

2. Отправка без получателя

ch := make(chan int)
ch <- 42

Для небуферизированного канала опять нужна другая сторона.

3. Отправка после закрытия

close(ch)
ch <- 42

Получаем panic.

Каналы в Go на пальцах

Если вся статья уже начала смешиваться в один большой chan, оставьте в голове вот эту модель:

Горутина
   │
   │ ch <- data
   ▼
┌─────────┐
│ channel │
└─────────┘
   │
   │ data := <-ch
   ▼
Горутина

Обычный канал синхронизирует отправителя и получателя.

Буферизированный канал умеет временно хранить несколько значений.

make(chan int)

— без буфера.

make(chan int, 10)

— буфер на десять элементов.

close(ch)

— сообщаем, что новых данных больше не будет.

for value := range ch

— читаем значения до закрытия.

select

— ждём несколько каналов одновременно.

Вот и практически вся база. Остальное — уже комбинации этих механизмов.

Хочется не просто читать Go, а реально писать код? 🧑‍💻

Каналы — отличный пример темы, которую можно десять раз прочитать и всё равно окончательно понять только после:

fatal error: all goroutines are asleep - deadlock!

Поэтому при изучении Go особенно важна практика.

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

Так гораздо проще закреплять и синтаксис Go, и горутины, и каналы, и другие темы, которые в одной теории выглядят страшнее, чем они есть на самом деле.

А ещё у Кодика есть Telegram-сообщество, где выходят полезные посты, разборы и материалы по программированию. Удобный вариант, чтобы периодически повторять изученное даже тогда, когда полноценный урок открывать не хочется 📱

Итог

Каналы в Go нужны не потому, что разработчикам было мало необычного синтаксиса.

Они решают вполне конкретную проблему: помогают горутинам обмениваться данными и координировать работу.

Запомните:

ch <- value

— отправили.

value := <-ch

— получили.

make(chan int, 5)

— добавили буфер.

close(ch)

— закрыли.

select

— начали слушать несколько каналов.

А потом вы открываете настоящий Go-проект, видите:

jobs := make(chan Job)
results := make(chan Result)

и вместо:

Что это за чёрная магия?

уже думаете:

Ага. Здесь workers получают задачи, а сюда складывают результаты.

Вот с этого момента каналы перестают быть страшными.

🎯Хватит откладывать

Понравилась статья?
Пора применять на практике!

В Кодик ты не просто читаешь — ты сразу пишешь код. Теория + практика = реальный скилл.

Мгновенная практика
🧠AI объяснит код
🏆Сертификат

Без регистрации • Без карты