select — это место, где горутина ждёт сразу несколько канальных событий и
реагирует на то, которое случилось первым. Без него конкурентный код превращается
в кашу из флагов и вложенных проверок: «а данные пришли? а нас не отменили? а
таймаут не вышел?». select отвечает на все эти вопросы одной конструкцией.
Разберём четыре вещи, которые делают select по-настоящему мощным:
мультиплексирование нескольких каналов, default для неблокирующих операций,
таймауты и приём с nil-каналами, который позволяет на ходу «выключать» отдельные
ветки. Аудитория уже знает каналы и горутины — здесь мы смотрим, как из них
собирается управляющая логика.
По форме select похож на switch, но case'ы здесь — не значения, а
канальные операции. select блокируется до тех пор, пока хотя бы одна из них
не сможет выполниться, и тогда выполняет именно её:
select {case v := <-in: process(v)case out <- result: // отправилиcase <-done: return}
Семантику стоит запомнить дословно — на ней держатся все остальные приёмы:
Готова ровно одна ветка — выбирается она.
Готовы несколько — выбирается случайная, равновероятно. Это не «сверху
вниз»: рандомизация специально защищает от ситуации, когда один бойкий канал
вечно перебивает остальные (голодание).
Не готова ни одна, но есть default — выполняется default.
Не готова ни одна и default нет — selectблокируется, пока какая-нибудь
ветка не станет готовой.
Вот живой пример мультиплексирования: две горутины-генератора пишут в свои каналы,
а select в цикле сливает оба потока в один. Завершаемся, когда обе горутины
закрыли свои каналы — отслеживаем это по числу живых источников.
package mainimport ( "fmt" "sync")func gen(vals ...int) <-chan int { ch := make(chan int) go func() { defer close(ch) for _, v := range vals { ch <- v } }() return ch}func main() { a, b := gen(1, 2, 3), gen(10, 20) var wg sync.WaitGroup sum := 0 wg.Add(1) go func() { defer wg.Done() open := 2 for open > 0 { select { case v, ok := <-a: if !ok { a = nil; open--; continue } sum += v case v, ok := <-b: if !ok { b = nil; open--; continue } sum += v } } }() wg.Wait() fmt.Println("sum =", sum)}
Порядок, в котором приходят значения из a и b, не детерминирован — но сумма
всегда одна и та же. На этом и строят корректные select-программы: не полагаемся
на порядок, полагаемся на инвариант.
default превращает select в неблокирующую попытку. Если ни одна ветка не
готова прямо сейчас — управление сразу уходит в default, без ожидания:
package mainimport "fmt"func main() { ch := make(chan int, 1) ch <- 42 // Первое чтение успевает — в буфере есть значение. select { case v := <-ch: fmt.Println("получили:", v) default: fmt.Println("пусто") } // Второе — канал уже пуст, не ждём, идём в default. select { case v := <-ch: fmt.Println("получили:", v) default: fmt.Println("пусто") }}
Так делают неблокирующую отправку (case ch <- v: плюс default — «положим, если
влезает»), опрос состояния без зависания, «сбросить значение, если успеем».
Но есть ловушка: default в цикле без паузы — это busy loop, который крутится
вхолостую и жжёт CPU на 100%. default означает «не ждать ни секунды», поэтому
такой цикл проверяет канал миллионы раз в секунду. Если вам нужно «ждать, но с
реакцией на отмену» — это не работа для default. Вместо него добавьте отдельную
ветку <-ctx.Done(): тогда select спокойно паркуется на двух каналах сразу и
просыпается на первом готовом, не нагружая процессор.
select вместе с time.After навешивает таймаут на любую канальную операцию.
time.After(d) возвращает канал, в который через d придёт значение — если за это
время основная ветка не сработала, выбирается ветка таймаута:
package mainimport ( "fmt" "time")func main() { ch := make(chan int) go func() { time.Sleep(30 * time.Millisecond) ch <- 7 // слишком поздно }() select { case v := <-ch: fmt.Println("значение:", v) case <-time.After(10 * time.Millisecond): fmt.Println("таймаут") }}
Здесь горутина «опаздывает», и срабатывает ветка таймаута. Поменяйте задержки
местами — и сработает ветка с данными. Логика читается прямо из структуры
select: кто первый, того и ветка.
Важный нюанс: time.After создаёт таймер, который живёт до самого срабатывания,
даже если основная ветка уже выиграла гонку. В горячем цикле, где select
крутится тысячи раз, это копит таймеры и нагружает GC. Лекарства два:
переиспользовать time.NewTimer с Reset, либо — что почти всегда правильнее —
передавать дедлайн через context и слушать ctx.Done() вместо
time.After. Контекст создаётся один раз на всю операцию и отменяется ровно один
раз.
Это самый недооценённый приём select. Вспомним из главы про
каналы: любая операция на nil-канале блокируется навсегда.
В обычном коде это источник дедлоков, но внутри select это превращается в
выключатель: ветка с nil-каналом просто никогда не выберется. Присвоив
переменную канала в nil, вы динамически гасите ветку, не трогая структуру
select.
Зачем это нужно? Классическая проблема: когда канал закрывается, <-ch перестаёт
блокироваться и начинает мгновенно отдавать нулевые значения с ok == false. Если
такая ветка стоит в цикле, select будет крутиться вхолостую на закрытом канале.
Зануление ветки решает это чисто:
package mainimport "fmt"func gen(vals ...int) <-chan int { ch := make(chan int) go func() { defer close(ch) for _, v := range vals { ch <- v } }() return ch}// merge сливает два потока в один, выключая ветки по мере их закрытия.func merge(a, b <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for a != nil || b != nil { // пока есть хоть один живой источник select { case v, ok := <-a: if !ok { a = nil; continue } // a закрыт — гасим ветку out <- v case v, ok := <-b: if !ok { b = nil; continue } out <- v } } }() return out}func main() { count := 0 for range merge(gen(1, 2, 3), gen(4, 5)) { count++ } fmt.Println("получено значений:", count)}
Когда a закрывается, мы зануляем переменную a, и её ветка молча выпадает из
выбора — select дальше работает только с b. Цикл for a != nil || b != nil
завершится, когда оба источника станут nil. Без этого трюка пришлось бы тащить
булевы флаги и вкладывать select'ы друг в друга. Сравните с примером
мультиплексирования выше: там мы считали живые источники счётчиком, здесь —
проверяем сами переменные на nil. Оба способа рабочие; nil-вариант не требует
отдельного счётчика.
Пустой select блокируется навечно — у него нет ни одной ветки, которая могла бы
стать готовой. Это идиома «уснуть навсегда»: например, в main сервиса, вся
полезная работа которого идёт в фоновых горутинах, а main просто не должен
завершаться. Приятный бонус: если вдруг все горутины тоже заблокируются и
прогресса нет ни у кого, рантайм обнаружит deadlock и аварийно завершит программу
с понятным сообщением, а не зависнет молча.
Расчёт на порядок веток.select не приоритезирует. Нельзя рассчитывать, что
<-done проверится «раньше» <-data — при готовности обеих выбор случаен. Если
приоритет действительно нужен, его делают явно через вложенный select: сначала
неблокирующая проверка приоритетной ветки с default, и только потом — общее
ожидание.
select {case <-done: returndefault: select { case <-done: return case v := <-data: use(v) }}
Забыли ветку отмены.select, который слушает только данные, виснет навсегда,
если данные перестали приходить, а отменить операцию нечем. Почти в любом
долгоживущем select нужна ветка <-ctx.Done() — иначе горутина становится
неубиваемой.
time.After в горячем цикле. Аллокация нового таймера на каждой итерации
нагружает GC, и каждый таймер живёт до срабатывания, даже если ветка уже
проиграла. В циклах — time.NewTimer + Reset или дедлайн через context.
busy loop на default.default в цикле без ожидания крутит CPU вхолостую.
Если задача — «ждать события», ветка default тут лишняя; ждите на самих каналах.
Учебный playground исполняет код через yaegi в одном потоке: горутины
кооперативно переключаются, но настоящего параллелизма нет, а race-детектор
не работает. Поэтому примеры про гонки данных и параллельное исполнение здесь
даём статичным блоком — запускать их в браузере было бы обманом.
Вот типичная гонка вокруг select: общий счётчик без синхронизации. На реальной
машине с go run -race детектор немедленно поймает конфликтный доступ:
// НЕ запускать в playground — настоящей гонки тут не видно.// На реальной машине: go run -race main.go поймает data race.func collect(in <-chan int, done <-chan struct{}) { total := 0 // читается и пишется из нескольких горутин без защиты for i := 0; i < 4; i++ { go func() { for { select { case v := <-in: total += v // ← гонка: незащищённая запись case <-done: return } } }() }}
Исправление — не делить total между горутинами, а собирать частичные суммы и
складывать их в конце, либо защитить доступ через sync.Mutex/sync/atomic. Но
сам факт гонки в браузере вы не «пощупаете» — это особенность среды, держите её в
голове.
select — это «жди первого из событий». Представляйте, что горутина паркуется
сразу на нескольких каналах и просыпается на том, который сработал первым. У вас
два рычага управления: default (не ждать вовсе) и nil-каналы (выключать
отдельные события, не меняя код select). Этих двух рычагов хватает почти на всю
управляющую логику конкурентных программ.
select — половина топика 1 (1–4): задача 01 «Or-Channel» строит дерево
select'ов, чтобы поймать первый сигнал и не утечь горутинами. В топике 3
(9–12) через select собираются fan-in и слияние потоков
(паттерн с nil-каналами выше — прямо оттуда). В топиках 4 (13–15) и 7
(23–32) связка select с <-ctx.Done() — стандартный способ сделать операцию
отменяемой (см. context).