select в Go
Горутина должна ждать ответа от сервиса, но не дольше секунды, и при этом мгновенно реагировать на отмену сверху. Три источника событий, ждать надо все три одновременно, а сработает один. Для этого и существует 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, остальные разбираются в тренажёре.
- Зачем в select нужна ветка default?A)Задаёт case, который срабатывает после того, как отработают остальныеB)Ловит панику из других case, как это делает recoverC)Задаёт порядок проверки: сначала default, потом готовые каналыD)Делает операцию неблокирующей: нет готовых case — сразу default
показать ответ и разбор
+D)Делает операцию неблокирующей: нет готовых case — сразу default// разбор: Без default select блокируется, пока хотя бы один case не станет готов. Ветка default выполняется немедленно, если в момент проверки не готов ни один канал, — так делают неблокирующие приём и отправку, опросы. Если готовых case несколько, select выбирает один псевдослучайно; default вступает в игру только когда готовых нет вовсе.
- Одна из веток select читает из nil-канала. Как это влияет на выбор?A)select паникует при входе из-за nil-канала в веткеB)Эта ветка никогда не готова — так её можно намеренно «выключить»C)Ветка срабатывает первой: nil читается мгновенноD)Весь select немедленно уходит в default, игнорируя рабочие case
показать ответ и разбор
+B)Эта ветка никогда не готова — так её можно намеренно «выключить»// разбор: Операции с nil-каналом блокируются вечно, поэтому в select ветка с nil-каналом просто никогда не готова и остальным не мешает. Это идиома: присвоив каналу nil, динамически отключаешь его ветку (например, приостановить чтение источника), а вернув реальный канал — включаешь обратно. Рабочие ветки при этом выбираются как обычно.
- В select готовы сразу две ветки. Какая выполнится?A)Первая по порядку в исходном кодеB)Та, чей канал получил значение раньшеC)Выбранная случайно из готовыхD)Обе по очереди за один проход select
показать ответ и разбор
+C)Выбранная случайно из готовых// разбор: Из готовых ветвей выбирается псевдослучайная — это сделано намеренно, чтобы порядок в коде не приводил к голоданию: иначе первый канал под нагрузкой перекрывал бы остальные. Практическое следствие: на select нельзя вешать приоритет. Нужен приоритет — делают вложенный select с default либо проверяют важный канал отдельным шагом.
- Как сделать в select таймаут ожидания?A)Добавить ветку с приёмом из time.After(d)B)Поставить default и вызвать в ней time.Sleep(d)C)Задать таймаут третьим аргументом selectD)Обернуть select в горутину и убить её по времени
показать ответ и разбор
+A)Добавить ветку с приёмом из time.After(d)// разбор: time.After возвращает канал, в который значение придёт через заданный срок, — обычная ветка select. Ветка default так не работает: она срабатывает мгновенно, когда не готов никто, и превращает ожидание в busy-loop. В долгих циклах вместо time.After берут переиспользуемый time.Timer: каждый вызов создаёт новый канал и таймер, живущий до срабатывания.
- Что делает конструкция select с несколькими ветками приёма из каналов?A)Ждёт, пока хоть одна операция станет готова, и выполняет еёB)Выполняет все ветки по очереди сверху внизC)Проверяет ветки и сразу выходит, если ни одна не готоваD)Ждёт готовности всех веток и выполняет их вместе
показать ответ и разбор
+A)Ждёт, пока хоть одна операция станет готова, и выполняет её// разбор: select блокируется, пока не будет готова хотя бы одна операция с каналом, и выполняет ровно её. Немедленный выход даёт только ветка default, а если готовы сразу несколько, выбор между ними случайный.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.