JavaRush /Курси /Go SELF /Спрямованість каналів в API — chan<- і <-chan

Спрямованість каналів в API — chan<- і <-chan

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

1. Навіщо в Go є напрям каналів

Коли ви починаєте писати конкурентний код, перше бажання — дати всім функціям просто chan T, щоб вони і читали, і писали: так же універсальніше. Це схоже на ситуацію, коли ви даєте всім співробітникам майстер-ключ від офісу: зручно… до першої дивної історії зі зниклим печивом і «я взагалі не чіпав ваш прод».

Спрямованість каналів — це спосіб на рівні типів сказати: «ця функція лише надсилає» або «ця функція лише читає». У Go це цілком нормальна практика: чим менше свободи в API, тим менше випадкових помилок. І це дуже в дусі Go-філософії: спілкуємося через передавання даних, а не через спільний доступ.

Типи каналів і «права доступу»

Для нас канал — один об’єкт, але на рівні типів Go розрізняє «права доступу» до нього. Уявіть, що канал — це двері, а напрям — це табличка: «лише вхід» або «лише вихід». Двері одні й ті самі, але табличка не дає вам зробити дурницю й намагатися «вийти через вхід».

У Go є три форми:

Тип каналу Можна надсилати (ch <- v) Можна отримувати (<-ch) Можна close(ch)
chan T
так так так
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 та зменшення кількості помилок. Це як пасок безпеки: він не робить машину швидшою, зате робить поїздку менш драматичною.

1
Опитування
Конкурентність 2: канали, рівень 66, лекція 4
Недоступний
Конкурентність 2: канали
Конкурентність 2: канали
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ