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 // помилка компіляції: не можна надсилати в канал лише для читання
// fmt.Println(<-out) // помилка компіляції: не можна отримувати з каналу лише для надсилання
}
Зверніть увагу: ми створили один канал 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("отримано:", v) // 1 / 2
}
}
func main() {
ch := make(chan int)
go produce(ch)
consume(ch)
}
Тут одразу видно три речі.
- По-перше, produce фізично не може «випадково» прочитати з каналу.
- По-друге, consume не зможе «випадково» надіслати туди значення й тим самим зламати протокол.
- По-третє, закриття (close(out)) живе у відправника, і це відповідає правилу «закриває відправник».
Ще один важливий момент: двонапрямний chan T можна передати туди, де очікують chan<- T або <-chan T. Це «звуження можливостей» відбувається автоматично під час присвоювання або передавання аргументу. А от назад «розширити права» не можна: у цьому й полягає захист.
3. Проєктування 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) // помилка компіляції: не можна закрити канал лише для читання
_ = in
}
func main() {}
А от відправник, який приймає chan<- int, може його закрити — і має це зробити, якщо потік завершується:
package main
func produce(out chan<- int) {
out <- 1
close(out)
}
func main() {}
У результаті виходить акуратна дисципліна: «читачі читають», «відправники пишуть і закривають». І менше шансів, що хтось «допоміг» і закрив канал передчасно.
4. Міні-рефакторинг: потік задач і підрахунок статистики
Щоб це не залишилося абстракцією про «канали з числами», давайте прив’яжемо спрямовані канали до нашого навчального застосунку. У курсі ми вже моделювали задачі (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: "прочитати Go spec", Done: false},
{ID: 2, Text: "виправити баг", Done: true},
{ID: 3, Text: "випити чаю", Done: true},
}
ch := make(chan Task)
go streamTasks(tasks, ch)
done := countDone(ch)
fmt.Println("виконано:", 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 каже: «я лише читаю». І оце «лише» — наша страховка.
5. Типові помилки під час використання chan<- і <-chan
Помилка № 1: всюди використовувати chan T «бо так простіше».
На перших кроках це здається зручним: менше думати, менше символів. Але ви платите за це тим, що втрачаєте перевірку протоколу компілятором. За кілька тижнів у кодовій базі з’являється функція-монстр, яка і читає, і пише, і закриває, і «трішки логує», а потім ви ловите зависання й починаєте підозрювати квантову механіку.
Помилка № 2: намагатися «розширити права» назад, якщо у вас <-chan T.
Іноді новачки отримують <-chan T із функції, а потім хочуть «трішки надіслати туди значення». Так не можна, і це правильно: якщо API повернув вам receive-only, значить надсилання — не ваша відповідальність. Якщо дуже хочеться надсилати, значить контракт функції має бути іншим, а не «давайте обдуримо типи».
Помилка № 3: закривати канал не там, де завершилися надсилання.
Навіть із спрямованими типами можна помилитися логічно: наприклад, закрити out до того, як завершився цикл надсилання (або помилково закрити посередині). Результат неприємний: наступне надсилання дасть panic. Лікується дисципліною: close(out) має стояти в тому місці, де ви точно знаєте, що надсилань більше не буде (зазвичай після циклу).
Помилка № 4: змішувати в одному каналі різні сенси й намагатися «керувати» протоколом через значення.
Коли з’являється потік, виникає спокуса: «а давайте передамо Task{ID:0} як ознаку кінця». Це погано, бо ви починаєте конфліктувати з нульовими значеннями і ускладнюєте читання. Канали в Go вже мають вбудований сигнал завершення: close + range + ok. Користуйтеся ним, а не вигадуйте «таємні рукостискання».
Помилка № 5: думати, що спрямовані канали — це про продуктивність.
Ні, вони не пришвидшують програму й не роблять канали «швидшими». Спрямованість — це про дизайн API та зменшення кількості помилок. Це як пасок безпеки: він не робить машину швидшою, зате робить поїздку менш драматичною.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ