1. Зачем нужен select: ждать одно из нескольких
Если вы до этого писали конкурентный код, вы могли думать примерно так: «Мне нужно дождаться значения из канала — ну окей, пишу <-ch». И это действительно рабочий путь… пока у вас один канал и один сценарий. Но реальная программа обычно живёт в мире «или»: либо пришла новая задача, либо надо остановиться, либо пришёл сигнал «хватит», либо появилось место в канале для отправки, либо какой-то компонент уже закрыл вход.
select — это конструкция языка Go, которая позволяет сказать: «Я готов сделать одно из нескольких действий с каналами: прочитать отсюда, отправить туда, и если ничего не готово — либо подожду, либо сделаю что-то другое». Это похоже на человека на ресепшене: он не может одновременно отвечать по телефону и принимать посетителя, но он может выбрать, что сделать в зависимости от того, кто сейчас реально пришёл.
С точки зрения архитектуры, select — это основа событийного цикла: один поток управления принимает решения на основе того, какие события уже доступны. Мы будем строить именно такую ментальную модель.
2. Синтаксис select и «готовый case»
select внешне похож на switch, но смысл у него другой: switch выбирает ветку по значению выражения, а select выбирает ветку по готовности канальной операции. Внутри select каждый case — это либо получение (<-ch), либо отправка (ch <- v). Если прямо сейчас выполнить операцию нельзя, такой case считается «не готов».
Минимальный пример: ждём, кто первый пришлёт сообщение — канал a или канал b.
package main
func main() {
select {
case <-make(chan int):
// сюда почти не попадём (канал никто не пишет)
case <-make(chan int):
// и сюда тоже
}
}
Этот пример специально «бесполезный»: оба канала никто не обслуживает, поэтому select просто заблокируется навсегда. Зато он показывает главное правило: если ни один case не готов, select блокируется (если нет default).
Чтобы понимать «готовность», полезно держать в голове простую таблицу.
| Операция | Unbuffered канал (make(chan T)) | Buffered канал (make(chan T, n)) |
|---|---|---|
| Receive: v := <-ch | готово, если кто-то уже отправляет или канал закрыт | готово, если в буфере есть хотя бы 1 элемент или канал закрыт |
| Send: 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("got:", v)
default:
fmt.Println("nothing yet") // nothing yet
}
}
Неблокирующая отправка особенно популярна в ситуациях «первый результат выигрывает»: мы хотим отправить результат, но если кто-то уже успел отправить раньше — не зависать. Этот паттерн прямо встречается в классических примерах конкурентности 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[goroutine: читает команды] -->|Task| addCh
Loop[event loop: select] -->|log msg| logCh
Logger[goroutine: печатает лог] --> Out[(stdout)]
addCh --> Loop
logCh --> Logger
Типы и каналы: что будет «событием»
Мы заведём задачу как структуру с названием. Канал addCh будет приносить новые задачи в event loop. Канал 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 буферизованный. Это маленькая «подушка безопасности», чтобы краткие всплески логов не блокировали систему.
Event loop: один 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("added:", t.Title) // added: milk
tryLog(logCh, "task added: "+t.Title)
}
}
}
Здесь есть одна важная идея владения данными: tasks находится внутри event loop. Только event loop его меняет. Значит, нам не нужен ни Mutex, ни сложные гарантии — мы просто «сериализуем» изменения через один поток обработки событий.
Неблокирующий лог: пишем, если можно
Лог — отличный кандидат на default. Часто логирование «приятно иметь», но если оно вдруг начинает тормозить бизнес-логику — вы быстро узнаете новые слова (обычно непечатные).
Сделаем tryLog:
package main
func tryLog(logCh chan<- string, msg string) {
select {
case logCh <- msg:
// записали в лог
default:
// буфер полон — пропускаем
}
}
Этот паттерн очень похож на классический приём «не блокируйся на отправке, если результат уже не нужен» из конкурентных шаблонов Go.
Да, мы «теряем» лог. Но мы теряем его по контракту: лучше потерять строчку «task added», чем заморозить всю программу.
Логгер: читает logCh и печатает
Сделаем отдельную горутину-логгер. Она будет читать канал и печатать, пока канал не закрыт. (Закрытие и завершение протокола мы подробно отточим в лекции про утечки горутин, а сегодня просто показываем базовую механику.)
package main
import "fmt"
func logger(logCh <-chan string) {
for msg := range logCh {
fmt.Println("LOG:", msg) // LOG: task added: milk
}
}
5. Пустой канал и закрытый канал: учимся отличать
Когда вы делаете неблокирующее чтение через default, мозг быстро начинает думать: «Если не прочитал — значит там ничего нет». И это верно только наполовину. Там может быть «ничего нет пока», а может быть «ничего нет уже никогда», потому что канал закрыли.
Поэтому любой код, который читает из канала и которому важно корректно завершаться, должен в нужный момент использовать форму v, ok := <-ch.
Покажем мини-кусок: event loop читает addCh и умеет выходить, когда addCh закрыли.
package main
import "fmt"
func eventLoop(addCh <-chan Task) {
for {
select {
case t, ok := <-addCh:
if !ok {
fmt.Println("addCh closed") // addCh closed
return
}
fmt.Println("added:", t.Title)
}
}
}
Здесь важно почувствовать: «готовность case» и «полезность данных» — разные вещи. case t := <-addCh может выполниться и на закрытом канале, просто t будет zero value, а 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 вы можете случайно принять zero value за нормальные данные, особенно если тип канала — int или string. В результате закрытие канала превращается в поток пустых значений, и логика едет в кювет. Лечится дисциплиной: когда вам важно корректно завершиться, читайте t, ok := <-ch и уважайте ok == false.
Ошибка №5: select используют как «костыль от зависаний», вместо того чтобы договориться о протоколе.
Если вы боитесь, что receive или send могут зависнуть, это часто значит, что у участников нет ясного протокола: кто закрывает канал, кто обязан читать результаты, что означает завершение. select — не магия, он не отменяет необходимость в договорённостях. Он лишь делает выбор между событиями удобным. Настоящая надёжность появляется, когда вы заранее можете ответить: «какое событие гарантирует выход из каждой горутины».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ