сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Горутины и каналы

select в Go

select: ожидание нескольких каналов

Горутина должна ждать ответа от сервиса, но не дольше секунды, и при этом мгновенно реагировать на отмену сверху. Три источника событий, ждать надо все три одновременно, а сработает один. Для этого и существует select - и почти вся асинхронная логика в Go пишется вокруг него.

Стержень: select ждёт первую готовую ветку, при нескольких готовых выбирает случайно, а default превращает ожидание в мгновенную проверку.

// Формулировки: «что выполнится, если готовы сразу две ветки?», «как сделать таймаут?», «зачем нужен nil-канал в select?»

Случайный выбор - это гарантия, а не деталь

Когда готова одна ветка, select выполняет её. Когда готовы несколько - выбирает псевдослучайно, и это документированное поведение, а не побочный эффект реализации.

Проверим на тысяче итераций с двумя всегда готовыми каналами: получилось 496 срабатываний первой ветки и 504 второй. Практически ровно пополам, и порядок написания веток на это никак не влияет.

Сделано так намеренно - чтобы не было голодания. Если бы select всегда предпочитал верхнюю ветку, канал с плотным потоком навсегда заслонил бы более редкий, и вторая ветка не выполнилась бы никогда. Случайность даёт всем источникам равные шансы.

// Отсюда следствие для кода: нельзя закладывать приоритет через порядок case. Нужен приоритет - делают вложенный select: сначала неблокирующая проверка важного канала с default, и только потом общее ожидание.

default: ожидание превращается в проверку

Ветка default выполняется, когда ни один канал не готов прямо сейчас. Это меняет саму природу конструкции: select с default никогда не блокируется, он делает мгновенный опрос и идёт дальше. Проверено на пустом канале: default срабатывает немедленно.

Отсюда два рабочих приёма. Неблокирующая отправка: попытались положить в канал, не вышло - записали метрику «очередь переполнена» и выбросили событие вместо того, чтобы застопорить обработчик. И неблокирующее чтение: забрали, если есть, иначе занялись другим делом.

Опасность у default ровно одна, зато серьёзная: select с default внутри цикла без паузы превращается в активное ожидание. Ядро загружено на сто процентов, работа не делается, метрики выглядят как «сервис под нагрузкой».

// Если тебе нужно именно ждать, default не нужен вовсе. Пустой select{} без веток блокируется навсегда - иногда это осознанно пишут в конце main, чтобы процесс не завершился, пока работают фоновые горутины.

select {
case v := <-ch:
    handle(v)
default:
    // ничего не готово прямо сейчас - идём дальше, не блокируясь
}

select {
case queue <- job:
default:
    metrics.Dropped.Inc()   // очередь полна, событие выбрасываем осознанно
}

Таймауты и отмена

Таймаут делается веткой с time.After: канал, в который значение придёт через заданное время. Проверено - срабатывает ровно по сроку. Одноразовый запрос с ограничением по времени пишется в четыре строки.

У time.After есть нюанс, о котором спрашивают: таймер живёт до срабатывания и держит память. В цикле на миллион итераций это миллион таймеров, поэтому там берут time.NewTimer с явным Stop либо контекст.

Отмена сверху приходит через контекст: ветка case <-ctx.Done() разблокируется, когда вызвали cancel или истёк дедлайн. Без этой ветки горутина остаётся висеть навсегда - про такие утечки отдельная подтема.

// Хороший обработчик обычно имеет три ветки: полезная работа, отмена по контексту и, если нужно, собственный таймаут. Ветка с ctx.Done() в долгоживущих горутинах - не необязательное украшение, а условие того, что сервис умеет останавливаться.

time.After
канал, в который придёт значение через заданное время; таймер живёт до срабатывания

nil-канал: выключатель ветки

Операции с nil-каналом блокируются навсегда - и приём, и отправка. Звучит как чистая ошибка, но внутри select это превращается в полезный инструмент: ветка с nil-каналом просто никогда не сработает, то есть выключена.

Классическое применение - конвейер с двумя источниками, каждый из которых может закончиться раньше другого. Когда один канал закрылся, ему присваивают nil, и его ветка перестаёт участвовать в выборе. Иначе закрытый канал был бы готов всегда и select крутился бы вхолостую, забирая нулевые значения миллион раз в секунду.

Вне select nil-канал - беда. Одинокий приём из него в единственной горутине даёт fatal error: all goroutines are asleep - deadlock, и рантайм честно пишет состояние горутины: chan receive (nil chan).

// Заметь разницу: тут рантайм смог заметить проблему, потому что спали вообще все горутины. Если хотя бы одна работает - например, обслуживает HTTP, - никакого сообщения не будет, и заблокированная горутина просто тихо останется висеть навсегда.

for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok { a = nil; continue }   // выключили ветку
        use(v)
    case v, ok := <-b:
        if !ok { b = nil; continue }
        use(v)
    }
}

Как отвечать: «Что выполнится, если в select готовы сразу две ветки?»

Одна из них, выбранная псевдослучайно. Это документированная гарантия, а не деталь реализации: я проверял на тысяче итераций с двумя всегда готовыми каналами, вышло примерно поровну, 496 против 504. Сделано так, чтобы не было голодания: если бы select всегда брал верхнюю ветку, плотный поток в первом канале навсегда заслонил бы редкие события во втором. Из этого следует, что приоритет через порядок case задать нельзя. Если приоритет реально нужен, делают вложенный select: сначала неблокирующая проверка важного канала с веткой default, и только если там пусто - общее ожидание. Ещё пара смежных вещей, которые обычно спрашивают следом: default выполняется, когда не готов никто, и превращает select в мгновенный опрос - удобно для неблокирующей отправки, но в цикле без паузы это активное ожидание и сожжённое ядро. А ветка с nil-каналом не срабатывает никогда, и этим пользуются, чтобы выключать источники по мере их закрытия.

Названа гарантия с проверенными числами, объяснена причина через голодание, дан рабочий способ сделать приоритет и добавлены два смежных механизма.

На чём валят

  • Закладывают приоритет через порядок веток - выбор случайный.
  • Ставят default в цикл без паузы и получают активное ожидание на сто процентов ядра.
  • Не обнуляют закрытый канал в конвейере: закрытая ветка готова всегда и крутит цикл вхолостую.
  • Плодят time.After в горячем цикле и копят таймеры вместо time.NewTimer со Stop.
  • Забывают ветку ctx.Done() в долгоживущей горутине, и сервис не умеет останавливаться.
  • Считают, что рантайм заметит любую вечную блокировку: он видит только случай, когда спят все горутины.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 5, остальные разбираются в тренажёре.

  1. #go_select1 / 5
    Зачем в select нужна ветка default?
    A)Задаёт case, который срабатывает после того, как отработают остальные
    B)Ловит панику из других case, как это делает recover
    C)Задаёт порядок проверки: сначала default, потом готовые каналы
    D)Делает операцию неблокирующей: нет готовых case — сразу default
    показать ответ и разбор
    +D)Делает операцию неблокирующей: нет готовых case — сразу default

    // разбор: Без default select блокируется, пока хотя бы один case не станет готов. Ветка default выполняется немедленно, если в момент проверки не готов ни один канал, — так делают неблокирующие приём и отправку, опросы. Если готовых case несколько, select выбирает один псевдослучайно; default вступает в игру только когда готовых нет вовсе.

  2. #go_select2 / 5
    Одна из веток select читает из nil-канала. Как это влияет на выбор?
    A)select паникует при входе из-за nil-канала в ветке
    B)Эта ветка никогда не готова — так её можно намеренно «выключить»
    C)Ветка срабатывает первой: nil читается мгновенно
    D)Весь select немедленно уходит в default, игнорируя рабочие case
    показать ответ и разбор
    +B)Эта ветка никогда не готова — так её можно намеренно «выключить»

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

  3. #go_select3 / 5
    В select готовы сразу две ветки. Какая выполнится?
    A)Первая по порядку в исходном коде
    B)Та, чей канал получил значение раньше
    C)Выбранная случайно из готовых
    D)Обе по очереди за один проход select
    показать ответ и разбор
    +C)Выбранная случайно из готовых

    // разбор: Из готовых ветвей выбирается псевдослучайная — это сделано намеренно, чтобы порядок в коде не приводил к голоданию: иначе первый канал под нагрузкой перекрывал бы остальные. Практическое следствие: на select нельзя вешать приоритет. Нужен приоритет — делают вложенный select с default либо проверяют важный канал отдельным шагом.

  4. #go_select4 / 5
    Как сделать в select таймаут ожидания?
    A)Добавить ветку с приёмом из time.After(d)
    B)Поставить default и вызвать в ней time.Sleep(d)
    C)Задать таймаут третьим аргументом select
    D)Обернуть select в горутину и убить её по времени
    показать ответ и разбор
    +A)Добавить ветку с приёмом из time.After(d)

    // разбор: time.After возвращает канал, в который значение придёт через заданный срок, — обычная ветка select. Ветка default так не работает: она срабатывает мгновенно, когда не готов никто, и превращает ожидание в busy-loop. В долгих циклах вместо time.After берут переиспользуемый time.Timer: каждый вызов создаёт новый канал и таймер, живущий до срабатывания.

  5. #go_select5 / 5
    Что делает конструкция select с несколькими ветками приёма из каналов?
    A)Ждёт, пока хоть одна операция станет готова, и выполняет её
    B)Выполняет все ветки по очереди сверху вниз
    C)Проверяет ветки и сразу выходит, если ни одна не готова
    D)Ждёт готовности всех веток и выполняет их вместе
    показать ответ и разбор
    +A)Ждёт, пока хоть одна операция станет готова, и выполняет её

    // разбор: select блокируется, пока не будет готова хотя бы одна операция с каналом, и выполняет ровно её. Немедленный выход даёт только ветка default, а если готовы сразу несколько, выбор между ними случайный.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.