Модуль: Concurrency · Уровень: Senior

TL;DR#

select ждёт готовности одного из нескольких канальных кейсов; при нескольких готовых выбирает псевдослучайно. default делает select неблокирующим. nil-каналы исключают кейс из выбора — основа для динамического включения/выключения и таймаутов/отмены через time.After/ctx.Done().

Теория#

Семантика#

  • Если готов один кейс — он выполняется.
  • Если готовы несколько — выбирается случайный (равномерно, через fastrandn), чтобы избежать голодания.
  • Если ни один не готов и есть default — выполняется default (неблокирующий select).
  • Если ни один не готов и default нет — горутина блокируется, пока какой-то кейс не станет готов.
  • Пустой select{} блокирует навсегда.
select {
case v := <-in:
    use(v)
case out <- x:
    // отправили
case <-time.After(time.Second):
    // таймаут
default:
    // ничего не готово прямо сейчас
}

Под капотом (runtime.selectgo)#

  1. Кейсы перемешиваются в случайном pollorder (для честного выбора).
  2. Каналы блокируются в фиксированном lockorder по адресам — чтобы исключить дедлок при множественной блокировке.
  3. Проход 1: ищем уже готовый кейс (есть данные/место/закрыт). Нашли — выполняем.
  4. Если есть default и готовых нет — выполняем default.
  5. Иначе: регистрируем sudog во всех каналах (в sendq/recvq), паркуемся.
  6. При пробуждении одним из каналов — снимаем регистрацию с остальных, выполняем выигравший кейс.

То есть в худшем случае select дороже одиночной операции: O(числа кейсов) на регистрацию/дерегистрацию.

nil-каналы в select#

Кейс с nil-каналом никогда не готов и не блокирует select целиком — он просто игнорируется. Это идиома «выключить» ветку:

var in <-chan int = source
var out chan<- int // nil пока нет данных
var pending int
for {
    select {
    case v, ok := <-in:
        if !ok { in = nil; continue } // источник иссяк → выключаем чтение
        pending = v; out = realOut    // включаем запись
        in = nil                      // выключаем чтение пока не отдадим
    case out <- pending:
        out = nil; in = source        // отдали → снова читаем
    }
}

Таймауты#

select {
case res := <-work:
    return res
case <-time.After(timeout): // ВНИМАНИЕ: таймер не освобождается до срабатывания
    return ErrTimeout
}

time.After создаёт таймер, который не собирается, пока не сработает. В цикле это утечка таймеров — используйте time.NewTimer + Stop() или (с Go 1.23 GC таймеров улучшен, но в цикле всё равно предпочтителен явный таймер) context.WithTimeout.

t := time.NewTimer(timeout)
defer t.Stop()
select {
case res := <-work:
    return res
case <-t.C:
    return ErrTimeout
}

Отмена#

select {
case res := <-work:
    return res, nil
case <-ctx.Done():
    return zero, ctx.Err() // Canceled или DeadlineExceeded
}

Каждая блокирующая канальная операция в долгоживущей горутине должна иметь ветку отмены, иначе — утечка.

Подводные камни / gotchas#

  • time.After в цикле = утечка таймеров и лишние аллокации. В горячем цикле — NewTimer/Reset или ctx.
  • default в цикле опроса = busy-loop, сжигающий CPU. Не делайте polling-цикл for { select { default: } } без паузы.
  • Псевдослучайный выбор означает: нельзя полагаться на приоритет кейсов. Для приоритета нужен вложенный select (сначала high-priority с default, потом общий).
  • Отправка в select тоже подчиняется правилам: case ch <- v готов только если есть получатель/место; иначе блокируется.
  • Закрытый канал всегда «готов» на чтение — кейс <-closedCh будет постоянно выигрывать, превращая select в busy-loop. После обнаружения закрытия обнуляйте канал (ch = nil).
  • Один и тот же канал в нескольких кейсах допустим, но усложняет рассуждения; выбор честный между всеми.

Приоритетный select#

// сначала пытаемся high, потом обычный выбор
select {
case v := <-high:
    handle(v)
default:
    select {
    case v := <-high:
        handle(v)
    case v := <-low:
        handle(v)
    case <-ctx.Done():
        return
    }
}

Вопросы на собеседовании#

В: Как select выбирает кейс, если готовы несколько? О: Псевдослучайно и равномерно (рантайм перемешивает порядок опроса). Это сделано намеренно, чтобы избежать систематического голодания одного из каналов.

В: Как сделать select неблокирующим? О: Добавить default. Если ни один канал не готов мгновенно, выполнится default. Полезно для «попробовать без ожидания», но в цикле без паузы создаёт busy-loop.

В: Зачем нужны nil-каналы в select? О: Кейс с nil-каналом никогда не готов и не мешает остальным — это способ динамически включать/выключать ветки (например, переключаться между чтением источника и записью в приёмник), не меняя структуру select.

В: Чем опасен time.After в цикле? О: Каждый вызов создаёт новый таймер, который держится до срабатывания, даже если кейс не выбран. В цикле это накапливает таймеры и память. Решение — time.NewTimer/Reset со Stop, либо context.WithTimeout.

В: Как реализовать таймаут и отмену операции? О: Добавить в select ветки <-t.C (явный таймер) или <-ctx.Done(). Возвращать ctx.Err() для различения Canceled/DeadlineExceeded. Каждая блокирующая операция в долгоживущей горутине должна иметь такую ветку.

В: Что делает пустой select{}? О: Блокирует горутину навсегда без потребления CPU (вечная парковка). Иногда используют в main, чтобы не выходить, пока живут фоновые горутины (хотя лучше явная синхронизация).

В: Как реализовать приоритет между каналами? О: Вложенный select: сначала отдельный select по высокоприоритетному каналу с default; если он не готов — общий select по всем. Прямого приоритета в одном select нет из-за случайного выбора.

В: Почему select может превратиться в busy-loop? О: Если в одном из кейсов закрытый канал (всегда готов на чтение) либо есть default в тесном цикле без паузы. Обнуляйте закрытые каналы (ch=nil) и не злоупотребляйте default.

В: Дороже ли select обычной операции с каналом? О: Да, в случае блокировки: selectgo регистрирует горутину во всех каналах и снимает регистрацию при пробуждении — O(числа кейсов), плюс упорядоченная блокировка каналов во избежание дедлоков.

На что копают на senior+#

  • Двухфазный протокол selectgo: pollorder vs lockorder и почему блокировка по адресам предотвращает дедлок.
  • Семантика отправки в select и взаимодействие с sendq/recvq.
  • Управление таймерами: устройство таймерного колеса/heap на P, изменения GC таймеров (1.23) и почему явный Stop всё ещё важен.
  • Идиомы выключения кейсов через nil и проектирование state-machine на одном select.
  • Голодание и честность: как случайный выбор влияет на латентность под перекосом нагрузки.