2.3select
Разберём готовность веток select, поведение nil-канала и цену default в polling loop.
Что такое select на самом деле
Синтаксически select похож на switch, но общего между ними ровно
ноль. switch сравнивает по цепочке сверху вниз. select зовёт
рантайм-функцию runtime.selectgo, которой компилятор передаёт два массива:
scases (описание каждой ветки: канал, направление, куда положить или откуда
взять значение) и order (рабочий буфер под перестановки). selectgo
возвращает индекс выигравшего case и флаг recvOK, а дальше компилятор выбирает тело ветки цепочкой обычных сравнений индекса с номером ветки, как в if/else if.
Два случая компилятор оптимизирует, не доходя до selectgo: select
с одним case превращается в обычную операцию с каналом, а select с одним case и
default сворачивается в selectnbsend/selectnbrecv, то
есть в chansend/chanrecv с флагом block = false.
Полный алгоритм включается с двух и более каналов.
select обязан жить в рантайме:
библиотечными средствами нельзя атомарно встать в несколько очередей ожидания и гарантировать,
что разбудит нас ровно один.Почему выбор случайный
В спецификации сказано прямо: если готовы несколько коммуникаций, выбирается одна псевдослучайно, равновероятно. Это гарантия языка, а не деталь реализации, и на порядок написания веток полагаться нельзя.
- От голодания. При проверке сверху вниз первый постоянно готовый канал заглушил бы все
остальные навсегда. Достаточно поставить
case <-ticker.Cпервым, аcase <-workвторым: работа не выполнялась бы никогда. - От неявной семантики порядка. Если бы порядок веток значил приоритет, перестановка двух case (безобидный рефакторинг) меняла бы поведение программы. Случайность делает порядок веток заведомо неважным.
- От синхронизации фаз. Несколько одинаковых воркеров с одинаковым
selectпри детерминированном выборе выстроились бы в одну и ту же последовательность и стабильно дрались бы за один канал. Случайность их «расфазирует».
Это легко проверить: когда оба канала готовы на каждой итерации, счёт делится пополам с точностью до десятых долей процента.
a := make(chan int, 1)
b := make(chan int, 1)
var na, nb int
for i := 0; i < 100_000; i++ {
a <- 1 // оба канала готовы одновременно
b <- 1
select {
case <-a:
na++
<-b // дренаж, чтобы следующая итерация началась с пустых каналов
case <-b:
nb++
<-a
}
}
fmt.Println(na, nb)
// три запуска подряд на go1.27:
// 50149 49851
// 50068 49932
// 49986 50014
// Ровно 50/50 не бывает: выбор псевдослучайный, а не по очереди.
Обратная сторона: приоритет между каналами приходится писать руками, вложенным
select с default (разобрано в вопросе главы 2.2). И ещё:
default в случайной перестановке не участвует, его черёд наступает
строго после того, как ни один канал не оказался готов.
select с default, пустой select и nil-канал
| Форма | Что делает | Когда применяют |
|---|---|---|
select { case ... } | блокируется, пока хоть один case не станет готов | основной режим: ожидание любого из событий |
select { case ...; default: } | никогда не блокируется | неблокирующие приём/отправка, дренаж, «попробовать без ожидания» |
select {} | блокирует горутину навсегда | «повиснуть» в main после запуска фоновых сервисов |
| case с nil-каналом | ветка никогда не сработает | динамическое отключение ветки |
| все каналы nil, default нет | вечная блокировка | обычно баг: забыли инициализировать канал |
// На select{} горутина паркуется навсегда и больше не проснётся.
func main() {
go server.ListenAndServe()
go metrics.Serve()
select {} // main не завершится; но если уснут все горутины -> fatal deadlock
}
select {} компилируется в прямой вызов runtime.block(): тот делает
gopark с причиной select (no cases), и проснуться горутина уже не
сможет. В реальном коде это почти всегда хуже, чем <-ctx.Done() или
http.Server.ListenAndServe() прямо в main: не даёт ни graceful
shutdown, ни возможности вернуть код выхода.
select компилятор фиксирует намертво,
так что управлять им на ходу можно единственным способом: подменять сам канал на
nil. Ради этого приёма чтение и запись в nil-канал блокируют, а не паникуют.Типовые конструкции, которые просят написать
// 1. Таймаут на ожидание значения
select {
case v := <-ch:
use(v)
case <-time.After(2 * time.Second):
return errTimeout
}
// 2. Отмена по контексту (в проде почти всегда вместо time.After)
select {
case v := <-ch:
use(v)
case <-ctx.Done():
return ctx.Err()
}
// 3. Graceful-цикл воркера: работа, периодический такт и отмена
func worker(ctx context.Context, jobs <-chan Job, out chan<- Result) error {
tick := time.NewTicker(30 * time.Second)
defer tick.Stop()
for {
select {
case j, ok := <-jobs:
if !ok {
return nil // продюсер закрыл канал, завершаемся штатно
}
r, err := handle(ctx, j)
if err != nil {
return err
}
select { // отправка результата тоже отменяема
case out <- r:
case <-ctx.Done():
return ctx.Err()
}
case <-tick.C:
reportHealth()
case <-ctx.Done():
return ctx.Err() // внешняя отмена
}
}
}
// 4. Неблокирующее чтение и запись
select {
case v := <-ch: use(v)
default: // пусто, идём дальше
}
// 5. Ждём первый из нескольких источников (гонка реплик)
select {
case r := <-replicaA:
return r
case r := <-replicaB:
return r
case <-ctx.Done():
return zero, ctx.Err()
}
В пункте 3 всё держится на вложенном select при отправке результата. Начинающие пишут
out <- r напрямую и получают воркер, который прекрасно реагирует на отмену,
пока ждёт задачу, и наглухо виснет, когда пытается отдать результат никем не читаемому
каналу. Правило: любая блокирующая операция в отменяемом цикле должна стоять в
select с ctx.Done(), включая отправку.
- Есть ветка
<-ctx.Done(), если цикл вообще должен уметь останавливаться. - Принимаешь с
, ok, если канал могут закрыть, и выходишь по явной ветке на!ok. - Никаких
time.Afterв теле цикла: только заранее созданныйTimer/TickerсоStop. defaultесть, только если ты сознательно готов потерять значение;defaultвнутриforбез блокирующей ветки даёт busy-loop на 100% CPU.- Наружу отправляешь тоже через
selectс отменой.
Вопросы
6select - вызов runtime.selectgo:
залочить все каналы в порядке адресов, обойти case в случайном порядке, при готовом -
выполнить и выйти; иначе default; иначе встать sudogом во все
очереди и заснуть, а после пробуждения сняться с остальных.Три прохода
- Проход 1 - пробуем без блокировки. Каналы залочены в
lockorder(сортировка по адресу), обход идёт вpollorder(случайная перестановка). Готовый case - это непустая противоположная очередь ожидающих, либо место/данные в буфере, либо закрытый канал на приёме. Нашли: обмен, разлочить всё, вернуть индекс. - Проход 2 - встаём в очереди. Если готовых нет и
defaultнет, на каждый case заводится свойsudogи встаёт вsendqилиrecvqнужного канала. Потомgopark: горутина уходит в_Gwaiting, все локи отпускаются, поток ОС продолжает работать с другими горутинами. - Проход 3 - уборка. Кто-то пришёл на один из каналов, выиграл гонку через CAS
на
g.selectDone, передал значение и вызвалgoready. Проснувшись,selectgoснова берёт все локи и снимает своиsudogсо всех очередей, возвращая их в кэш.
Две неочевидные детали
- Зачем сортировка по адресу. Два
selectв разных горутинах могут пересекаться по каналам. Если бы один брал локи в порядке A, B, а другой в порядке B, A, вышел бы классический дедлок на внутренних локах рантайма. Глобальный порядок «по возрастанию адреса» это исключает. - Зачем CAS на selectDone. Горутина стоит в нескольких очередях, и два разных канала могут одновременно решить, что партнёр именно она. CAS 0→1 определяет единственного победителя; проигравший видит, что горутина уже занята, и идёт искать следующего в очереди.
select с одним case компилируется в обычную операцию с каналом.
select с одним case и default уходит в
selectnbrecv/selectnbsend (то есть chanrecv или
chansend с block=false). Пустой select {} становится
runtime.block(). Полный selectgo зовётся только с двух и более
каналов, и стоит он порядка 50–150 нс на быстром пути.
Что было бы при проверке сверху вниз
for {
select {
case <-ticker.C: // если бы порядок задавал приоритет,
report() // тикер раз в 10 мс всегда был бы готов,
case j := <-jobs: // и вот эта ветка не выполнилась бы никогда
handle(j)
}
}
Со случайным выбором обе ветки в среднем берут по половине готовых итераций, и порядок, в котором разработчик написал case, ни на что не влияет.
Три эффекта, ради которых это сделано
- Нет голодания. Ни одну ветку не заглушит соседняя, какой бы «горячей» она ни была.
- Перестановка веток не меняет поведение. Значит, рефакторинг безопасен, а ревьюер не должен держать в голове приоритеты.
- Расфазировка одинаковых воркеров. Пять горутин с одинаковым
selectне выстраиваются в одну и ту же последовательность решений.
Как именно реализована случайность
В selectgo массив индексов перемешивает алгоритм Фишера-Йетса, а случайные
числа даёт быстрый потоковый генератор рантайма (cheaprandn, состояние живёт в
m, без блокировок и без обращения к math/rand). Перемешивается
только pollorder, порядок опроса; локи (lockorder) берутся строго по
возрастанию адреса.
Тесты, которые полагаются на то, какая ветка сработает при двух готовых каналах, писать
нельзя. Приоритет «просто поставь важную ветку первой» не работает. И не строй логику на
предположении «ctx.Done() сработает раньше, чем придёт последнее значение»:
если оба готовы, с вероятностью 50% получишь значение, а не отмену. Нужен абсолютный
приоритет отмены, проверяй её отдельным неблокирующим select перед основным.
default выполняется, если ни один case не
готов прямо сейчас. Это единственный способ выполнить операцию с каналом атомарно-или-никак,
не рискуя заблокироваться.// Неблокирующий приём
select {
case v := <-ch: use(v)
default: // канал пуст
}
// Неблокирующая отправка
select {
case ch <- v:
default: // буфер полон / получателя нет
}
// Неблокирующая проверка отмены
select {
case <-ctx.Done(): return ctx.Err()
default: // не отменён, считаем дальше
}
Ключевое свойство: атомарность
Проверка «готов ли канал» и сама операция идут внутри одной критической секции под локом
канала. Поэтому select/default нельзя заменить на if len(ch) > 0 { v
:= <-ch }: между if и чтением значение успеет забрать другая
горутина, и ты заблокируешься.
Где применяют по делу
- Телеметрия и логи: лучше потерять сэмпл метрики, чем притормозить обработку запроса.
- Сигнальные каналы с буфером 1: «есть новая работа», и если сигнал уже лежит, второй не нужен.
- Дренаж: забрать всё, что уже накопилось, и уйти.
- Периодическая проверка отмены внутри долгого вычисления.
- busy-loop.
for { select { case ...: ...; default: } }без всякой паузы и без блокирующей ветки крутит 100% ядра впустую. Если по логике нужно «ждать», тоdefaultздесь лишний. defaultна небуферизированном канале. Отправка сработает, только если получатель уже стоит в очереди. Промах почти гарантирован, и данные тихо теряются. Для «не потерять, но не ждать вечно» нужен буфер или таймаут.
runtime.block() → gopark с причиной
«select (no cases)».
Такая горутина не потребляет CPU и не занимает поток ОС, она просто выпадает из планировщика
насовсем. Разбудить её нечем: у неё нет ни одного sudog ни в одной очереди.
Единственное осмысленное применение
func main() {
go worker()
go metricsServer()
select {} // «главная горутина, повиси, пока работают фоновые»
}
Логика такая: main завершится и утащит за собой весь процесс вместе со всеми
горутинами, а фоновые должны работать дальше. В реальном сервисе это всё равно почти всегда
плохое решение.
- Нет graceful shutdown: по SIGTERM процесс умрёт мгновенно, не дав закрыть соединения и дописать буферы.
- Нет кода возврата: программа не может завершиться с ошибкой.
- Если все остальные горутины тоже уснут на каналах, рантайм напечатает
fatal error: all goroutines are asleep - deadlock!, и указывать на проблему будет твойselect {}. - Идиоматичная замена:
<-ctx.Done(), гдеctxсоздан черезsignal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM), либо просто блокирующийsrv.ListenAndServe()прямо вmain.
«А чем select {} отличается от for {}?» Принципиально: for
{} это busy-loop, он жжёт 100% ядра, и до Go 1.14 такой цикл без вызовов функций
вообще нельзя было вытеснить, из-за чего он мог подвесить GC. select {}
паркуется честно и не ест ничего.
select фиксирован
компилятором, менять можно только сами каналы.
В selectgo case с nil-каналом просто пропускается при опросе и не
получает sudog. Поэтому он не мешает остальным, не тратит ресурсов и никогда не
выигрывает. Если все каналы nil и default нет, горутина заблокируется
навсегда, ровно как на select {}.
Паттерн 1: отправлять, только когда есть что
var out chan Result // nil, пока результата нет
var pending Result
for {
select {
case v := <-in:
pending = compute(v)
out = results // включаем ветку отправки
case out <- pending:
out = nil // выключаем: отправлять больше нечего
case <-ctx.Done():
return
}
}
Без этого приёма пришлось бы либо крутить default-цикл, либо отправлять нулевые
значения, либо разводить два разных select и дублировать в каждом логику
отмены.
Паттерн 2: аккуратное завершение при закрытии одного из источников
// merge двух каналов: закрывшийся источник обнуляем, чтобы не крутиться на нём вечно
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok { a = nil; continue } // закрытый канал всегда готов, поэтому обнуляем
out <- v
case v, ok := <-b:
if !ok { b = nil; continue }
out <- v
}
}
close(out)
Закрытый канал в select всегда готов на приём: он мгновенно отдаёт
zero, false. Если после !ok не присвоить a = nil,
select будет бесконечно выбирать эту ветку, крутя цикл на полной скорости и не
давая второму каналу ни единого шанса. В самописном merge это ошибка номер
один, её и ищут, когда просят написать fan-in руками.
Таймаут
// Разово можно и time.After
select {
case v := <-ch:
return v, nil
case <-time.After(5 * time.Second):
return zero, errTimeout
}
// В цикле только переиспользуемый таймер
t := time.NewTimer(5 * time.Second)
defer t.Stop()
Отмена по контексту
select {
case v := <-ch:
use(v)
case <-ctx.Done():
return ctx.Err() // context.Canceled или context.DeadlineExceeded
}
Так лучше, чем time.After: дедлайн приходит сверху, его наследуют все вложенные
вызовы, снимает его явный cancel(), и лишних таймеров не плодится.
Graceful-цикл воркера
func worker(ctx context.Context, jobs <-chan Job, out chan<- Result) error {
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
select {
case j, ok := <-jobs:
if !ok {
return nil // 1) продюсер закрыл вход, выходим штатно
}
r, err := handle(ctx, j)
if err != nil {
return err
}
select { // 2) отправка результата тоже отменяема
case out <- r:
case <-ctx.Done():
return ctx.Err()
}
case <-tick.C:
heartbeat()
case <-ctx.Done():
return ctx.Err() // 3) внешняя отмена
}
}
}
- Два независимых способа завершиться: закрытие входного канала (данные кончились) и отмена контекста (нас просят прекратить). Их нельзя смешивать.
- Вложенный
selectна отправке. Без него отмена работает ровно до того момента, пока воркер не соберётся отдать результат, а дальше он висит намертво. defer tick.Stop(): до Go 1.23 неостановленныйTickerтикал и держал ресурсы вечно; сейчас его соберёт GC, но код соStopкорректен на любой версии.- Возврат
error, а не молчаливыйreturn: вызывающий должен уметь отличить «доработал» от «отменили» и от «упал».
«А если handle длинная и не смотрит на ctx?» Тогда воркер не
остановится в разумное время, сколько бы select ты ни написал снаружи.
Отменяемость обязана быть сквозной: ctx прокидывают внутрь и проверяют в самой
длинной операции, в цикле по батчу, в запросе к БД, в HTTP-вызове. Внешний
select лишь решает, ждать ли результат; остановить чужую работу он не может.
Что стоит запомнить
- Операнды case вычисляются при входе в select.
- Из нескольких готовых case выбирается псевдослучайно.
- nil channel отключает соответствующую ветку.
Частые ошибки
- default может создать busy loop.
- select не гарантирует справедливость бизнес-обработки.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи выполнение
В канале уже лежит одно значение, default также готов. Предскажи выбранную ветку; затем замени буфер на nil channel.
ch := make(chan int, 1); ch <- 3
select { case v := <-ch: fmt.Println(v); default: fmt.Println("empty") }
Ответ и разбор
При первом запуске receive готов, поэтому он выбирается и печатается 3. Если ch заменить на nil, receive навсегда не готов, и выбирается default; без default select будет ждать другие case.
Код-ревью
2. Найди проблему и исправь
В select участвуют необязательный источник, обязательный источник и отмена. Optional иногда отсутствует и равен nil, а иногда закрывается после последних данных. После закрытия начинается busy loop с нулевыми значениями. Как выключить закрытый optional и завершиться при закрытии required или отмене context?
for {
select {
case v := <-optional:
handleOptional(v)
case v := <-required:
handleRequired(v)
case <-ctx.Done():
return ctx.Err()
}
}
Ответ и разбор
Nil-channel автоматически выключает ветку, но закрытый канал постоянно готов и возвращает zero value. Добавь проверку ok и отключи optional после завершения; закрытие required по условию завершает функцию.
for {
select {
case v, ok := <-optional:
if !ok { optional = nil; continue }
handleOptional(v)
case v, ok := <-required:
if !ok { return nil }
handleRequired(v)
case <-ctx.Done():
return ctx.Err()
}
}
Применение
3. Разбери рабочий сценарий
Polling loop выполняет select с default и сразу повторяется, когда нет входящих данных. CPU постоянно занят. Предложи правку, которая ждёт данные и позволяет остановиться.
Ответ и разбор
Убери default, добавь case на канал данных и case на ctx.Done(). Тогда select блокируется до события, вместо busy loop; при отмене функция выходит. Если нужен периодический опрос, добавь явный timer/ticker с корректным Stop.
Применение
4. Разбери граничный сценарий
Нужно немедленно обработать вход, если он уже готов, иначе продолжить обычный путь без ожидания. Какой select уместен и как избежать постоянного опроса?
Ответ и разбор
Один select с default подходит для разовой неблокирующей проверки. Если проверка повторяется циклом, добавь ожидание события, таймер или отмену вместо немедленного повторения, иначе цикл займёт CPU.