JavaRush /Курси /Go SELF /Закриття каналу: close, читання v, ok := <-ch

Закриття каналу: close, читання v, ok := <-ch

Go SELF
Рівень 66 , Лекція 2
Відкрита

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"", для boolfalse, для *Tasknil і так далі. Тому без ok ви не відрізните «справжній нуль» від «кінця потоку».

Коротка таблиця нульових значень — просто щоб зняти здивування:

Тип елемента каналу Нульове значення
int
0
string
""
bool
false
*SomeStruct
nil
struct{...}
«структура з нульових значень полів»

Ось приклад поганого протоколу з використанням 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 — це про комунікацію («значень більше не буде»), а не про керування життєвим циклом горутин.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ