JavaRush /Курси /Go SELF /select: мультиплекс...

select: мультиплексування каналів і гілка default

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

1. Навіщо потрібен select: чекати на одне з кількох

Якщо ви вже писали конкурентний код, то, мабуть, міркували так: «Мені потрібно дочекатися значення з каналу — гаразд, пишу <-ch». Це справді працює, доки у вас один канал і один сценарій. Але реальна програма зазвичай живе у світі «або»: або надійшло нове завдання, або треба зупинитися, або прийшов сигнал «досить», або зʼявилося місце в каналі для надсилання, або якийсь компонент уже закрив вхід.

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

З погляду архітектури, select — це основа циклу подій: один потік керування ухвалює рішення залежно від того, які події вже доступні. Саме таку ментальну модель ми й побудуємо.

2. Синтаксис select і «готовий case»

select зовні схожий на switch, але сенс у нього інший: switch обирає гілку за значенням виразу, а select — за готовністю операції з каналом. Усередині select кожен case — це або отримання (<-ch), або надсилання (ch <- v). Якщо прямо зараз операцію виконати не можна, такий case вважається «не готовим».

Мінімальний приклад: чекаємо, який із двох каналів спрацює першим.

package main

func main() {
	select {
	case <-make(chan int):
		// сюди майже не потрапимо (у канал ніхто не пише)
	case <-make(chan int):
		// і сюди теж
	}
}

Цей приклад спеціально майже беззмістовний: обидва канали ніхто не обслуговує, тому select просто заблокується назавжди. Зате він показує головне правило: якщо жоден case не готовий, select блокується (якщо немає default).

Щоб розуміти «готовність», корисно тримати в голові просту таблицю.

Операція Небуферизований канал (make(chan T)) Буферизований канал (make(chan T, n))
Отримання: v := <-ch готово, якщо хтось уже надсилає або канал закритий готово, якщо в буфері є хоча б 1 елемент або канал закритий
Надсилання: ch <- v готово, якщо хтось уже чекає на читання готово, якщо в буфері є вільне місце

І тут важлива дрібниця: закритий канал робить receive «готовим» — він не блокує, — але повертає вам ok == false. Тобто «готово» не завжди означає «отримали корисні дані».

Чому порядок case не дає пріоритету

Дуже хочеться, особливо після if/else, думати так: «Ну я ж написав угорі найважливіший case, отже він матиме пріоритет». Так от… ні. І це не баг, а важлива частина дизайну.

Правило звучить так: якщо готовий рівно один case — виконується він. Якщо готові кілька — Go обирає один із них недетерміновано; на практиці це виглядає як псевдовипадковість. Це зроблено, щоб не було прихованого «пріоритету» через порядок рядків у коді; інакше конкурентні програми перетворювалися б на мінне поле з неочевидних багів голодування (starvation).

Покажемо це на маленькому й чесному прикладі: два буферизовані канали вже містять значення, отже обидва case готові.

package main

import "fmt"

func main() {
	a := make(chan string, 1)
	b := make(chan string, 1)

	a <- "A"
	b <- "B"

	select {
	case v := <-a:
		fmt.Println(v) // інколи A
	case v := <-b:
		fmt.Println(v) // інколи B
	}
}

Якщо ви запустите це кілька разів, побачите різний вивід. І це нормально. Мораль проста: коректність програми не можна будувати на припущенні, що select «спершу перевірить верхній case».

Якщо вам потрібен пріоритет, його роблять явним — наприклад, окремою логікою, окремими каналами або протоколом, а не «магією» порядку рядків.

3. default: неблокувальна спроба

default у select — це не «гілка про всяк випадок», як у switch. У select default означає: «Якщо прямо зараз не можна виконати жоден case, не чекати, а виконати ось це».

Тобто default перетворює select з «очікування події» на «спробу зробити щось, якщо пощастило».

Неблокувальне читання з каналу:

package main

import "fmt"

func main() {
	ch := make(chan int)

	select {
	case v := <-ch:
		fmt.Println("отримано:", v)
	default:
		fmt.Println("ще нічого")
	}
}

Неблокувальне надсилання особливо корисне в ситуаціях «перший результат перемагає»: ми хочемо надіслати результат, але якщо хтось уже встиг надіслати раніше — не зависати. Цей патерн напряму трапляється в класичних прикладах конкурентності Go: надсилання роблять через select { case ch <- v: default: }, щоб горутини, що програли, не висіли назавжди.

package main

func trySend(ch chan<- int, v int) bool {
	select {
	case ch <- v:
		return true
	default:
		return false
	}
}

Тут важливо проговорити філософію: default — це дозвіл втратити операцію (або відкласти її), тому що ми свідомо вирішили «не чекати». Якщо втрата неприпустима, default — не ваш інструмент.

Невеликий історичний факт: у дуже ранніх версіях Go існували окремі «булеві» форми неблокувальних операцій із каналами, але їх прибрали, тому що select розв’язує задачу універсально й робить мову простішою.

4. Мінізастосунок: цикл подій TaskBox на select

Зараз зберемо невеликий фрагмент нашого навчального застосунку. Припустімо, що протягом курсу ми створюємо консольний застосунок TaskBox: він приймає команди, додає завдання й час від часу пише «службові повідомлення» в лог-канал. Сьогодні наша мета — не «зробити ідеальний таск-трекер», а показати, як select стає серцем програми: один цикл слухає події й реагує.

Намалюємо схему, щоб було відчуття «хто з ким розмовляє»:

flowchart LR
    Input[горутина: читає команди] -->|завдання| addCh
    Loop[цикл подій: select] -->|лог-повідомлення| logCh
    Logger[горутина: друкує лог] --> Out[(стандартний вивід)]
    addCh --> Loop
    logCh --> Logger

Типи й канали: що буде «подією»

Ми заведемо завдання як структуру з назвою. Канал addCh приноситиме нові завдання в цикл подій. Канал logCh прийматиме лог-повідомлення, і ми спеціально зробимо лог неблокувальним, щоб не зависнути на повільному логері.

package main

type Task struct {
	Title string
}

Канали в main:

package main

func main() {
	addCh := make(chan Task)      // нові завдання
	logCh := make(chan string, 3) // невеликий буфер під лог
	_, _ = addCh, logCh
}

Зверніть увагу: logCh буферизований. Це маленька «подушка безпеки», щоб короткі сплески логів не блокували систему.

Цикл подій: один for + один select

Тепер серце системи: цикл, який живе доти, доки працює програма, і вирішує, що робити, коли приходить подія.

package main

import "fmt"

func eventLoop(addCh <-chan Task, logCh chan<- string) {
	tasks := make([]Task, 0)

	for {
		select {
		case t := <-addCh:
			tasks = append(tasks, t)
			fmt.Println("додано:", t.Title) // додано: молоко
			tryLog(logCh, "завдання додано: "+t.Title)
		}
	}
}

Тут є одна важлива ідея володіння даними: tasks живе всередині циклу подій. Змінює її тільки цей цикл. Отже, нам не потрібні ні Mutex, ні складні гарантії — ми просто серіалізуємо зміни через один потік обробки подій.

Неблокувальний лог: пишемо, якщо можна

Лог — чудовий кандидат для default. Часто логування — це «добре мати», але якщо воно раптом починає гальмувати бізнес-логіку, ви швидко почуєте нові слова — зазвичай не дуже друковані.

Зробімо tryLog:

package main

func tryLog(logCh chan<- string, msg string) {
	select {
	case logCh <- msg:
		// записали в лог
	default:
		// буфер повний — пропускаємо
	}
}

Цей патерн дуже схожий на класичний прийом «не блокуйтеся на надсиланні, якщо результат уже не потрібен» із конкурентних шаблонів Go.

Так, ми «втрачаємо» лог. Але робимо це за контрактом: краще втратити рядок «завдання додано», ніж заморозити всю програму.

Логер: читає logCh і друкує

Зробімо окрему горутину-логер. Вона читатиме канал і друкуватиме, доки канал не буде закрито. (Закриття й завершення протоколу ми детально відшліфуємо в лекції про витоки горутин, а сьогодні просто показуємо базову механіку.)

package main

import "fmt"

func logger(logCh <-chan string) {
	for msg := range logCh {
		fmt.Println("ЛОГ:", msg) // ЛОГ: завдання додано: молоко
	}
}

5. Порожній канал і закритий канал: вчимося розрізняти

Коли ви робите неблокувальне читання через default, мозок швидко починає думати: «Якщо не прочитав — значить там нічого немає». І це правильно лише наполовину. Там може бути «нічого немає поки що», а може бути «нічого немає вже ніколи», тому що канал закрили.

Тому будь-який код, який читає з каналу і якому важливо коректно завершуватися, має в потрібний момент використовувати форму v, ok := <-ch.

Покажемо мініфрагмент: цикл подій читає addCh і вміє виходити, коли addCh закрили.

package main

import "fmt"

func eventLoop(addCh <-chan Task) {
	for {
		select {
		case t, ok := <-addCh:
			if !ok {
				fmt.Println("addCh закрито") // addCh закрито
				return
			}
			fmt.Println("додано:", t.Title)
		}
	}
}

Тут важливо відчути: «готовність case» і «корисність даних» — різні речі. case t := <-addCh може виконатися і на закритому каналі, просто t буде нульовим значенням, а ok скаже правду.

6. default у циклі: ризик busy loop

default здається порятунком: «О, я не блокуюся, можу робити ще щось». І це правда. Але є пастка, у яку потрапляє майже кожен новачок: він пише for { select { default: } } — і випадково створює маленький вічний двигун, який перетворює процесор на обігрівач.

Busy loop виглядає так: цикл крутиться мільйони разів на секунду, тому що default виконується миттєво, а в тілі циклу немає нічого, що справді чекає на подію.

package main

func main() {
	for {
		select {
		default:
			// немає блокувальних case — цикл «горить» на CPU
		}
	}
}

Чому це погано? Тому що ви не «перевіряєте час від часу», а перевіряєте постійно. Навіть якщо рантайм Go вміє витісняти горутини (і загалом із роками став кращим), ваш код усе одно витрачатиме CPU без користі, а на реальному сервері це перетворюється на «чому в нас рахунок за залізо як за космічний корабель?». До речі, в історичних релізах Go окремо поліпшували планувальник саме тому, що «зайняті горутини» можуть заважати іншим.

Як мислити правильно: якщо вам потрібно чекати на подію, у вашому select має бути хоча б один блокувальний case (наприклад, читання з каналу). default додають не замість очікування, а щоб зробити «швидку спробу», коли очікування не обовʼязкове. Якщо вам здається, що default потрібен «щоб програма не зависла», зазвичай проблема в дизайні протоколу каналів, а не у відсутності default.

7. Типові помилки під час роботи з select

Помилка №1: будувати логіку на порядку case.
Порядок гілок у select візуально справді натякає на «зверху важливіше», і рука сама тягнеться зробити верхній case пріоритетним. Але якщо готові кілька гілок, вибір недетермінований, і програма почне поводитися по-різному залежно від планувальника, навантаження й фази Місяця. Коректний підхід — сприймати select як чесну лотерею та проєктувати протокол так, щоб будь-який вибір залишався правильним.

Помилка №2: default використовується як «трохи почекати».
default не чекає ані мілісекунди. Він означає «не чекати взагалі». Тому код «якщо немає даних — піду в default і зроблю щось корисне» у циклі часто перетворюється на busy loop. Якщо ви справді хочете чекати на дані — default не потрібен. Якщо ви хочете інколи робити побічну роботу — потрібно так побудувати цикл, щоб у нього було реальне очікування події, а default був рідкісною гілкою, а не основним маршрутом.

Помилка №3: неблокувальне надсилання мовчки втрачає дані, і ніхто не помітив.
select { case ch <- v: default: } — потужний інструмент, але він по суті каже: «якщо не вдалося надіслати — ну й гаразд». Це допустимо для логів, метрик, «перший результат перемагає» або схожих сценаріїв, де втрата за контрактом дозволена. Але якщо так надсилати реальні дані (наприклад, завдання на обробку), ви отримаєте загадкові «зникнення» і підозрюватимете все, окрім власного default.

Помилка №4: плутати «канал порожній» і «канал закритий».
Під час читання в select без перевірки ok ви можете випадково прийняти нульове значення за нормальні дані, особливо якщо тип каналу — int або string. У результаті закриття каналу перетворюється на потік порожніх значень, і логіка зʼїжджає в кювет. Лікується дисципліною: коли вам важливо коректно завершитися, читайте t, ok := <-ch і поважайте ok == false.

Помилка №5: select використовують як «милицю від зависань», замість того щоб домовитися про протокол.
Якщо ви боїтеся, що receive або send можуть зависнути, це часто означає, що в учасників немає чіткого протоколу: хто закриває канал, хто зобовʼязаний читати результати, що означає завершення. select — не магія; він не скасовує потреби в домовленостях. Він лише робить вибір між подіями зручним. Справжня надійність зʼявляється тоді, коли ви заздалегідь можете відповісти: «яка подія гарантує вихід із кожної горутини».

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