1. Зачем в Go есть направление каналов
Когда начинаешь писать конкурентный код, первое желание — «пусть все функции получают chan T, чтобы могли и читать, и писать: так универсальнее». Это похоже на ситуацию, когда вы даёте всем сотрудникам мастер‑ключ от офиса: удобно… до первой странной истории с пропавшими печеньками и “я вообще не трогал ваш прод”.
Направление каналов — это способ на уровне типов сказать: «эта функция только отправляет» или «эта функция только читает». В Go это считается нормальной практикой: меньше свободы в API — меньше случайных ошибок. И это очень в духе Go‑философии про “общаемся через передачу данных, а не через общий доступ”.
Типы каналов и “права доступа”
На человеческом уровне канал — это один объект, но на уровне типов Go различает «права доступа» к нему. Представьте, что канал — это дверь, а направление — это табличка: «только вход» или «только выход». Дверь одна и та же, но табличка не даёт вам сделать глупость и пытаться “выйти через вход”.
В Go есть три формы:
| Тип канала | Можно отправлять (ch <- v) | Можно получать (<-ch) | Можно close(ch) |
|---|---|---|---|
|
да | да | да |
| chan<- T (send-only) | да | нет | да |
| <-chan T (receive-only) | нет | да | нет |
Ключевой момент: направленность — это часть типа, а не «режим работы канала в рантайме». Рантайм не хранит “флаг направления”; направление нужно компилятору, чтобы заранее запретить неправильные операции.
Давайте посмотрим на микро‑пример: компилятор реально вас защищает.
package main
import "fmt"
func main() {
ch := make(chan int, 1)
var out chan<- int = ch
var in <-chan int = ch
out <- 10
fmt.Println(<-in) // 10
// in <- 20 // compile error: cannot send to receive-only channel
// fmt.Println(<-out) // compile error: cannot receive from send-only channel
}
Обратите внимание: мы создали один канал ch, но можем «раздать» его разным частям программы с разными правами. И это уже похоже на нормальный контракт, а не на «берём всё и надеемся, что никто не нажмёт красную кнопку».
2. Сужение прав в сигнатурах функций
В реальном коде направленные каналы чаще всего живут в сигнатурах функций. Смысл простой: если функция по задумке только отправляет, пусть она принимает chan<- T. Если только читает, пусть принимает <-chan T.
Почему это работает хорошо? Потому что сигнатура становится мини‑документацией: вы видите её и сразу понимаете роль функции, даже не читая тело. Это особенно приятно через пару недель, когда вы открыли свой код и думаете: «кто написал это чудо, и почему оно ещё работает?». Спойлер: написали вы.
Вот базовый шаблон «производитель → потребитель».
package main
import "fmt"
func produce(out chan<- int) {
out <- 1
out <- 2
close(out)
}
func consume(in <-chan int) {
for v := range in {
fmt.Println("got:", v) // got: 1 / got: 2
}
}
func main() {
ch := make(chan int)
go produce(ch)
consume(ch)
}
Здесь сразу видно три вещи.
- Во‑первых, produce физически не может «случайно» прочитать из канала.
- Во‑вторых, consume не сможет «случайно» отправить туда значение и тем самым поломать протокол.
- В‑третьих, закрытие (close(out)) живёт у отправителя, и это соответствует правилу «закрывает отправитель».
Ещё один важный момент: двунаправленный chan T можно передать туда, где ожидают chan<- T или <-chan T. Это “сужение возможностей” происходит автоматически при присваивании/передаче аргумента. А вот обратно «расширить права» нельзя: это и есть защита.
4. Проектирование API: возвращаем поток и закрываем канал
Возвращаем receive-only канал
Иногда хочется сделать API, где наружу отдаётся «поток значений», но наружный код не должен туда ничего отправлять и точно не должен закрывать канал. В таких случаях очень удобно возвращать из функции receive-only канал <-chan T.
На уровне ощущений это похоже на автомат с кофе: вы можете получать кофе (читать из канала), но вы не можете “положить внутрь” свой чай (послать в канал) и вы не можете закрыть автомат на ключ (close). Контракт простой и понятный.
Пример: функция создаёт канал, запускает горутину и возвращает <-chan int.
package main
import "fmt"
func numbers(n int) <-chan int {
ch := make(chan int)
go func() {
for i := 1; i <= n; i++ {
ch <- i
}
close(ch)
}()
return ch
}
func main() {
for v := range numbers(3) {
fmt.Println(v) // 1 потом 2 потом 3
}
}
Почему это классный стиль?
- Потому что снаружи невозможно сломать протокол: у вас нет доступа на отправку.
- Плюс такой API читается как обещание: “я возвращаю поток значений, читайте его до конца”.
Кстати, такой паттерн регулярно встречается в примерах и обсуждениях Go‑подходов к потокам данных: фильтры/пайплайны часто делают именно так — возвращают <-chan как результат.
Кто может close, и почему это важно
С close начинаются самые драматичные серии в «Санта‑Барбаре конкурентности», потому что close — это не “убрать канал”, а “объявить, что отправок больше не будет”. Если закрывает “не тот”, то отправитель может попробовать отправить ещё одно значение — и получить panic. Это уже не зависание, это прямо взрыв с дымом.
Направленные типы помогают и тут: если функция принимает <-chan T, она физически не сможет закрыть канал — компилятор запретит. Это приятно, потому что закрытие — ответственность отправителя, и типы подсказывают правильную архитектуру.
Посмотрим на маленький пример, где компилятор не даёт вам нарушить договор.
package main
func consume(in <-chan int) {
// close(in) // compile error: cannot close receive-only channel
_ = in
}
func main() {}
А вот отправитель, который принимает chan<- int, закрывать может (и должен, если это поток с окончанием):
package main
func produce(out chan<- int) {
out <- 1
close(out)
}
func main() {}
В результате получается аккуратная дисциплина: “читатели читают”, “писатели пишут и закрывают”. И меньше шансов, что кто-то «помог» и закрыл канал раньше времени.
5. Мини‑рефакторинг: поток задач и подсчёт статистики
Чтобы это не осталось абстракцией про «каналы с числами», давайте привяжем направленные каналы к нашему учебному приложению. По курсу мы уже моделировали задачи (task/todo), хранили их, выводили в CLI/HTTP, делали сортировки, работали с ошибками. Теперь представим маленькую внутреннюю потребность: быстро посчитать статистику по задачам, не ломая остальной код.
Идея будет такой: у нас есть функция, которая «стримит» задачи в канал (производитель), и есть функция, которая этот поток читает и считает, сколько задач выполнено (потребитель). Никаких select, никаких таймаутов — только чистый канал + range.
Начнём с модели задачи (упрощённо):
package main
type Task struct {
ID int
Text string
Done bool
}
Теперь сделаем “производителя”: он получает список задач и отправляет их в канал. Обратите внимание на сигнатуру: out chan<- Task.
package main
func streamTasks(tasks []Task, out chan<- Task) {
for _, t := range tasks {
out <- t
}
close(out)
}
И “потребителя”: он только читает <-chan Task и считает done.
package main
func countDone(in <-chan Task) int {
done := 0
for t := range in {
if t.Done {
done++
}
}
return done
}
Склеим это в main, чтобы увидеть общую картинку:
package main
import "fmt"
func main() {
tasks := []Task{
{ID: 1, Text: "read Go spec", Done: false},
{ID: 2, Text: "fix bug", Done: true},
{ID: 3, Text: "drink tea", Done: true},
}
ch := make(chan Task)
go streamTasks(tasks, ch)
done := countDone(ch)
fmt.Println("done:", done) // done: 2
}
С точки зрения протокола здесь всё красиво: отправитель (в горутине) отправляет все значения и закрывает канал; получатель читает range и завершается автоматически. И самое важное для нашей темы: сигнатуры фиксируют роли. Даже если через месяц вы вынесете streamTasks и countDone в разные файлы/пакеты, по типам уже видно, кто что делает.
Для наглядности — маленькая схема (не “магия”, просто визуализация того, что мы уже написали):
flowchart LR
A["streamTasks(tasks, out chan<- Task)"] -->|out <- Task| CH[(chan Task)]
CH -->|range in <-chan Task| B["countDone(in <-chan Task)"]
A -->|"close(out)"| CH
Если вы сейчас думаете: «а почему бы countDone не принимать chan Task, оно же тоже работает?» — да, будет работать. Но chan Task говорит “я могу и писать, и читать”, а <-chan Task говорит “я только читаю”. И вот это “только” — наша страховка.
6. Типичные ошибки при использовании chan<- и <-chan
Ошибка №1: везде использовать chan T “потому что так проще”.
На первых шагах это кажется удобным: меньше думать, меньше символов. Но вы платите за это тем, что теряете проверку протокола компилятором. Через неделю в кодовой базе появляется функция‑монстр, которая и читает, и пишет, и закрывает, и «чуть‑чуть логирует», а потом вы ловите зависание и начинаете подозревать квантовую механику.
Ошибка №2: пытаться “расширить права” обратно, если у вас <-chan T.
Иногда новички получают <-chan T из функции, а потом хотят “чуть‑чуть отправить туда значение”. Так нельзя, и это правильно: если API вернул вам receive-only, значит отправка — не ваша ответственность. Если очень хочется отправлять, значит контракт функции должен быть другим, а не “давайте обманем типы”.
Ошибка №3: закрывать канал не там, где закончились отправки.
Даже с направленными типами можно ошибиться логически: например, закрыть out до того, как закончился цикл отправки (или закрыть в середине по ошибке). Результат неприятный: следующая отправка даст panic. Лечится дисциплиной: close(out) должен стоять в том месте, где вы точно знаете, что отправок больше не будет (обычно после цикла).
Ошибка №4: смешивать в одном канале разные смыслы и пытаться “рулить” протоколом через значения.
Когда появляется поток, возникает соблазн: “а давайте передадим Task{ID:0} как признак конца”. Это плохо, потому что вы начинаете конфликтовать с zero values и усложняете чтение. Каналы в Go уже имеют встроенный сигнал завершения: close + range + ok. Пользуйтесь этим, а не изобретайте «тайные рукопожатия».
Ошибка №5: думать, что направленные каналы — это про производительность.
Нет, они не ускоряют программу и не делают каналы “быстрее”. Направление — это про дизайн API и снижение числа ошибок. Это как ремень безопасности: он не делает машину быстрее, зато делает поездку менее драматичной.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ