1. Закриття каналу: close(ch)
Коли ви пишете однопотокову програму, «кінець даних» часто очевидний сам собою: наприклад, ви дійшли до кінця масиву або цикл for i := 0; i < n; i++ завершився. У конкурентному коді все інакше: дані можуть з’явитися не зараз, а через 5 мілісекунд. І саме тут починається філософія рівня «очікування як спосіб життя»: якщо не домовитися, коли дані справді закінчилися, отримувач може чекати нескінченно.
Уявіть, що у вас є горутина-виробник, яка надсилає в канал результати роботи. Інша горутина-споживач читає їх і друкує. Усе добре, доки результати є. А коли вони закінчуються, споживачеві треба якось зрозуміти: це ще тимчасова тиша чи вже кінець потоку.
Погана, але дуже поширена серед новачків ідея — послати «спеціальне значення», наприклад 0, як ознаку завершення. Це ламається миттєво, щойно 0 стає легальним значенням даних. Тому в каналів є вбудований офіційний механізм: закриття каналу.
Що робить close (і чого не робить)
Важливо почати з чесного уточнення: close(ch) — це не «знищити канал» і не «вбити горутину». У Go закриття каналу — це сигнал протоколу, який означає: «У цей канал більше не надсилатимуться значення».
Формулювання в дусі специфікації звучить приблизно так: вбудована функція close(ch) для каналу ch “records that no more values will be sent on the channel” (фіксує, що більше значень надсилатися не буде).
Чого не робить close:
- Він не очищає буфер магічним чином. Якщо в буферизованому каналі вже лежать значення, вони там і залишаються та читатимуться як зазвичай.
- Він не зупиняє горутини. Якщо у вас горутина в паніці або нескінченно крутиться в циклі, close її не заспокоїть.
- Він не є командою завершитися для отримувача. Отримувач сам вирішує, що робити, побачивши факт закриття: завершити цикл, дописати звіт, закрити файли, заварити каву.
Мініприклад — просто щоб побачити синтаксис і переконатися, що читання після close узагалі можливе:
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // 0 false
}
Тут v став 0, а ok став false. Це й є головний інструмент, який ми сьогодні опануємо.
Хто має закривати канал
Із закриттям каналу пов’язане одне правило, яке зберігає нерви краще за відпустку: канал закриває відправник (або той, хто відповідає за відправлення). Причина проста: тільки відправник точно знає, що більше нічого не надішле. Отримувач цього знати не може — він бачить лише: «зараз порожньо».
Якщо канал закриває отримувач, легко отримати гонку: відправник ще намагається надіслати значення, а канал уже закритий — і програма падає з panic. У Go це не «дрібна помилка», а прямий стоп-сигнал: «ви порушили протокол».
Щоб відчути небезпеку, можна подивитися на такий антиприклад (код спеціально поганий; не використовуйте його як зразок):
package main
func main() {
ch := make(chan int)
go func() {
ch <- 1 // може запанікувати, якщо канал закриють раніше
}()
close(ch) // закриває отримувач (main), це погана ідея
}
Це якби ви зачинили двері ліфта, поки люди ще намагаються зайти. Формально дія зрозуміла, але далі буде боляче й гучно.
3. Читання з каналу після його закриття
Чотири стани під час читання
Тепер найкорисніше: розкладемо поведінку читання на зрозумілі випадки. Канал для отримувача можна уявити як дві осі: «закритий / не закритий» і «є значення / немає значень». У результаті маємо чотири ситуації.
Нижче — таблиця; її варто перечитати кілька разів, бо це фундамент.
| Стан каналу | Що робить v := <-ch | Що робить v, ok := <-ch |
|---|---|---|
| Канал не закритий, значень немає (зараз порожньо) | Блокується (чекає на надходження значення) | Блокується (так само чекає) |
| Канал не закритий, значення є | Повертає чергове значення | Повертає значення й ok=true |
| Канал закритий, значення є (наприклад, у буфері) | Повертає значення, що лишилися | Повертає значення й ok=true |
| Канал закритий, значень немає | Повертає нульове значення (zero value) одразу | Повертає нульове значення й ok=false |
Ключова думка: закриття каналу робить читання передбачуваним і таким, що завершується. Отримувач більше не ризикує зависнути назавжди — він може побачити ok=false і вийти.
Двозначне читання: v, ok := <-ch
Найважливіша навичка сьогодні — читати з каналу в двозначній формі.
Форма така:
v, ok := <-ch
Зміст:
- ok == true означає: ви отримали справжнє значення, усе гаразд.
- ok == false означає: канал закрито і порожній, даних більше не буде.
Чому повертається саме «нульове значення»? Бо Go зобов’язаний повернути значення типу T (адже v має тип елемента каналу). Для int це 0, для string — "", для bool — false, для *Task — nil і так далі. Тому без ok ви не відрізните «справжній нуль» від «кінця потоку».
Коротка таблиця нульових значень — просто щоб зняти здивування:
| Тип елемента каналу | Нульове значення |
|---|---|
|
|
|
|
|
|
|
|
|
«структура з нульових значень полів» |
Ось приклад поганого протоколу з використанням 0 як сигналу кінця. Він поганий саме тому, що 0 може бути легальним значенням:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 0 // легальне значення
ch <- 5 // ще одне значення
// «Сигнал кінця = 0» ламається просто тут:
v := <-ch
if v == 0 {
fmt.Println("кінець") // кінець (хоча дані ще є!)
return
}
fmt.Println(v)
}
Правильний підхід — не вигадувати «магічних чисел», а закривати канал і під час читання використовувати ok.
Цикл читання «до закриття» без range
Є дуже зручна форма for range ch, але ми її поки відкладемо: зараз нам важливо навчитися розуміти механіку через ok, вручну, без «автопілота». Це як спочатку навчитися кермувати без круїз-контролю, а вже потім ним користуватися.
Ось ручний цикл читання:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
for {
v, ok := <-ch
if !ok {
break
}
fmt.Println(v) // спочатку 10, потім 20
}
}
Тут важливі одразу три речі.
- Перша: close не знищує вже надіслані значення — вони дочитуються.
- Друга: коли значення закінчуються, отримуємо ok=false і виходимо.
- Третя: цикл не залежить від того, буферизований канал чи ні — протокол однаковий.
4. Практика та просунуті сценарії
Потік задач у TaskCLI
Щоб приклади не зависали у вакуумі, продовжимо думку на навчальному застосунку. Нехай у нас є консольна утиліта TaskCLI, яка працює зі списком задач. Раніше ми зберігали задачі в слайсах, фільтрували, сортували й друкували їх. Тепер додамо конкурентну частину: одну горутину, яка «виробляє задачі» в канал, і main, яка читає задачі та друкує.
Почнемо з моделі (припустімо, що структура Task нам уже знайома з попередніх тем про struct):
package main
type Task struct {
ID int
Title string
Done bool
}
Тепер зробимо виробника: він проходить по слайсу і надсилає задачі, а потім закриває канал. Зверніть увагу: закриття відбувається всередині виробника, тобто там, де й відбувається відправлення.
package main
func produceTasks(ch chan Task, tasks []Task) {
for _, t := range tasks {
ch <- t
}
close(ch) // важливо: закриває відправник
}
Тепер у main створимо канал, запустимо горутину-виробник і читатимемо до закриття через v, ok.
package main
import "fmt"
func main() {
tasks := []Task{
{ID: 1, Title: "вивчити Go", Done: false},
{ID: 2, Title: "попити чаю", Done: true},
}
ch := make(chan Task)
go produceTasks(ch, tasks)
for {
t, ok := <-ch
if !ok {
break
}
fmt.Println(t.ID, t.Title, t.Done)
// 1 вивчити Go false
// 2 попити чаю true
}
}
Цей код уже демонструє правильне завершення: програма не зависне, навіть якщо задач більше не буде, бо канал закривається.
Кілька відправників і один close
З одним відправником усе просто: він надсилає — він же й закриває. Але як бути, якщо відправників кілька? Наприклад, ви розпаралелили читання задач із різних джерел (поки без select, просто концептуально) і кожен надсилає в один спільний канал.
У цьому разі не можна, щоб кожен викликав close(ch) — хтось закриє канал раніше, а решта спробують щось надіслати й упадуть.
Тут з’являється класичний трюк: канал закриває окремий «координатор», який чекає, доки завершаться всі відправники, через WaitGroup, а потім закриває канал рівно один раз. Це продовження того, що ми раніше вчили про WaitGroup.
Ось мінімальна схема (усе ще без складних конструкцій):
package main
import "sync"
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ch <- 1 }()
go func() { defer wg.Done(); ch <- 2 }()
go func() {
wg.Wait()
close(ch) // закриваємо один раз після всіх відправлень
}()
_ = ch
}
Мораль тут така: «закриває той, хто точно знає, що відправлення завершилися». Іноді це реальний відправник, іноді — координатор, який керує протоколом.
panic: що заборонено робити з close
Із close є дві заборони, і обидві дуже «фізичні», як закони гравітації: порушите — впадете.
Перша заборона: не можна надсилати в закритий канал.
Друга заборона: не можна закривати канал двічі.
Покажемо обидві у вигляді маленьких прикладів (рядки, які викличуть panic, залишимо закоментованими).
package main
func main() {
ch := make(chan int, 1)
ch <- 1
close(ch)
// ch <- 2 // panic: send on closed channel
// close(ch) // panic: close of closed channel
_ = ch
}
Чому Go викликає panic, а не «мовчки ігнорує»? Бо це майже завжди логічна помилка у вашому протоколі. Якби це ігнорувалося, баги були б набагато тихішими й підступнішими.
До речі, у Go колись були експериментальні «булеві форми» неблокувальних операцій із каналами, але їх прибрали, щоб не множити двозначність і не ускладнювати мову. І це добре: у конкурентності нам важливіша ясність правил, ніж безліч «зручних» варіантів, які потім ніхто не може налагодити.
5. Типові помилки під час закриття каналів і читання v, ok
Помилка №1: канал закриває отримувач, щоб зупинити відправника.
Таке рішення виглядає логічним, доки не виникне гонка: відправник ще працює і виконує ch <- v, а канал уже закритий — і ви ловите panic: send on closed channel. Отримувач не повинен «командувати закриттям» через close. Якщо потрібно керувати зупинкою, це окремий протокол, а close лишається сигналом: «відправлень більше не буде».
Помилка №2: закриття каналу відбувається у двох місцях.
Зазвичай це трапляється під час рефакторингу: ви винесли відправлення у функцію, залишили close(ch) усередині, а потім «про всяк випадок» додали ще один close(ch) у main. Результат передбачуваний: panic: close of closed channel. Лікується дисципліною володіння: у каналу має бути один відповідальний за закриття.
Помилка №3: замість close використовують «магічне значення» (наприклад, 0 або порожній рядок).
Поки у вашому потоці даних випадково немає 0 — усе здається робочим. Але щойно 0 стане легальним значенням, отримувач почне завершуватися передчасно або, навпаки, не зможе зрозуміти, де кінець. У Go кінець потоку позначають close, а факт завершення розпізнають через ok.
Помилка №4: читають v := <-ch і намагаються вгадати, чи це кінець, чи дані.
Якщо ви читаєте без ok, то після закриття каналу отримаєте нульове значення і можете прийняти його за справжнє значення. Це особливо неприємно для типів на кшталт int або string, де 0 і "" часто цілком легальні. Правильна форма для читання «до кінця» — саме v, ok := <-ch і перевірка if !ok { ... }.
Помилка №5: канал закрили, але очікують, що це «зупинить усе».
Закриття каналу — це лише зміна поведінки операцій читання й запису, а не рубильник для горутин. Якщо горутина робить ще щось (наприклад, крутить цикл, пише в інший канал або чекає на щось), close її не завершить. Тому корисно тримати в голові модель: close — це про комунікацію («значень більше не буде»), а не про керування життєвим циклом горутин.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ