voidlearn
 

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. Полный алгоритм включается с двух и более каналов.

1 Собрать scases: канал, направление, адрес значения для каждой ветки, включая ветки с nil-каналом nil-канал остаётся в массиве, но он никогда не станет готовым 2 pollorder = случайная перестановка индексов lockorder = сортировка каналов по адресу в памяти перестановка - алгоритм Фишера-Йетса на быстром PRNG, без блокировок 3 Взять локи ВСЕХ каналов в порядке lockorder одинаковые адреса - берём один раз сортировка по адресу - защита от дедлока между двумя select 4 Проход 1: обойти case в порядке pollorder нашёлся готовый - выполнить обмен, снять локи, выйти быстрый путь: парковки не будет именно здесь возникает случайность 5 Есть ветка default? Снять локи и выполнить её это и делает select неблокирующим default проверяется ПОСЛЕ всех каналов, а не наравне с ними default нет - придётся ждать 6 Проход 2: на каждый case - свой sudog в очередь канала одна горутина одновременно стоит в N очередях sudog берутся из кэша P, аллокации обычно нет 7 gopark: статус _Gwaiting, все локи отпущены M забирает следующую горутину и работает дальше поток ОС не блокируется ни на секунду 8 Кто-то пришёл: CAS на g.selectDone 0 в 1 выигрывает ровно один канал, остальные получают отказ без этого CAS два канала могли бы разбудить нас дважды и оба передать 9 Проход 3: снять свои sudog со ВСЕХ остальных очередей снова под локами в lockorder, sudog вернуть в кэш пропустить этот шаг нельзя: иначе очереди каналов протухнут 10 Вернуть индекс выигравшего case, выполнить его тело значение уже лежит там, куда его положил передающий стоимость select с 2 case без парковки - примерно 50-150 нс
selectgo. Три прохода: попробовать без блокировки, встать во все очереди, после пробуждения сняться со всех. Случайность появляется на шаге 2 и срабатывает на шаге 4; сортировка по адресу на шаге 3 нужна, чтобы два select с пересекающимися каналами не устроили дедлок на самих локах.
Горутина в select sudog на каждый case Очереди каналов g (наша) _Gwaiting selectDone = 0 waiting: список sudog sudog A elem указывает в наш стек sudog B elem указывает в наш стек sudog C elem указывает в наш стек chA: recvq наш sudog стоит в очереди не выиграл - снимаем на шаге 9 chB: пришёл отправитель CAS selectDone 0 в 1 - успех значение скопировано, goready(g) chC: recvq наш sudog стоит в очереди не выиграл - снимаем на шаге 9 Если бы CAS не было: отправитель в chA и отправитель в chC могли бы одновременно решить, что мы их получатель, и оба скопировали бы значение - одно из них потерялось бы.
Одна горутина - N очередей. Вот почему 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, ни возможности вернуть код выхода.

Фаза 1: работают обе ветки select { case v, ok := <-in: case out <- head: } in активен out активен Фаза 2: очередь пуста, отправлять нечего out = nil select { case <-in: ; case out <- head: } вторая ветка выключена: nil никогда не готов in активен out выключен Зачем так делают: без выключения ветка out отправляла бы мусор или крутила бы busy-loop. Альтернатив нет: удалить case из select во время выполнения невозможно, набор веток статичен. nil-канал - это runtime-switch.
nil-канал как выключатель. Набор веток 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(), включая отправку.

Чек-лист «правильного» select в проде
  1. Есть ветка <-ctx.Done(), если цикл вообще должен уметь останавливаться.
  2. Принимаешь с , ok, если канал могут закрыть, и выходишь по явной ветке на !ok.
  3. Никаких time.After в теле цикла: только заранее созданный Timer/Ticker со Stop.
  4. default есть, только если ты сознательно готов потерять значение; default внутри for без блокирующей ветки даёт busy-loop на 100% CPU.
  5. Наружу отправляешь тоже через select с отменой.

Вопросы

6
Суть: select - вызов runtime.selectgo: залочить все каналы в порядке адресов, обойти case в случайном порядке, при готовом - выполнить и выйти; иначе default; иначе встать sudogом во все очереди и заснуть, а после пробуждения сняться с остальных.

Три прохода

  1. Проход 1 - пробуем без блокировки. Каналы залочены в lockorder (сортировка по адресу), обход идёт в pollorder (случайная перестановка). Готовый case - это непустая противоположная очередь ожидающих, либо место/данные в буфере, либо закрытый канал на приёме. Нашли: обмен, разлочить всё, вернуть индекс.
  2. Проход 2 - встаём в очереди. Если готовых нет и default нет, на каждый case заводится свой sudog и встаёт в sendq или recvq нужного канала. Потом gopark: горутина уходит в _Gwaiting, все локи отпускаются, поток ОС продолжает работать с другими горутинами.
  3. Проход 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 нс на быстром пути.

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

Что было бы при проверке сверху вниз

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 завершится и утащит за собой весь процесс вместе со всеми горутинами, а фоновые должны работать дальше. В реальном сервисе это всё равно почти всегда плохое решение.

Почему select{} в проде - красный флаг
  • Нет 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 {} паркуется честно и не ест ничего.

Суть: ветка с nil-каналом никогда не станет готовой - это штатный способ «выключить» case во время выполнения. Набор веток 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)
Почему без обнуления это busy-loop

Закрытый канал в 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) внешняя отмена
        }
    }
}
Четыре вещи, за которые здесь ставят плюс
  1. Два независимых способа завершиться: закрытие входного канала (данные кончились) и отмена контекста (нас просят прекратить). Их нельзя смешивать.
  2. Вложенный select на отправке. Без него отмена работает ровно до того момента, пока воркер не соберётся отдать результат, а дальше он висит намертво.
  3. defer tick.Stop(): до Go 1.23 неостановленный Ticker тикал и держал ресурсы вечно; сейчас его соберёт GC, но код со Stop корректен на любой версии.
  4. Возврат 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.