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 — не магія; він не скасовує потреби в домовленостях. Він лише робить вибір між подіями зручним. Справжня надійність зʼявляється тоді, коли ви заздалегідь можете відповісти: «яка подія гарантує вихід із кожної горутини».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ