select: мультиплексирование и таймауты
Перед началом: модули 1–3 (GMP, модель памяти, каналы). Инструменты: Go 1.22+,
go test -race,go test -benchmem. Трудоёмкость: ~4 ч (теория 2 ч · практика 1.5 ч · квиз 0.5 ч).
🔥 Инцидент: воркер не заметил отмену, а time.Tick потёк
Воркер обрабатывал задачи и раз в секунду отправлял heartbeat.
В цикле стоял select из двух веток — задача и heartbeat, — а отмена
жила отдельно: ctx проверялся после итерации, но итерация ждала
события. В тихую ночь select висел на пустых каналах, ctx.Done()
никто не слушал, и graceful shutdown ждал воркер до таймаута.
Ветки завершения в select просто не было.
Вторая проблема нашлась в профиле: дедлайн на шаг был сделан через
time.Tick(50 * time.Millisecond) внутри обработки. Каждый вызов
создавал тикер, остановить его было нечем, и за сутки накопились
миллионы живых таймеров: память росла, а select продолжал
выбирать между работой и давно ненужными тиками.
Этот модуль — про то, как select принимает решения, чем
time.After отличается от NewTimer, и почему default — это
не «оптимизация», а обязательство не спать.
📖 Теория
4.1 Как select выбирает ветку
select похож на switch по каналам: каждая ветка — отправка,
приём или default. Правила простые, но определяют поведение:
- Если готова одна ветка, выбирается она.
- Если готовы несколько, выбор псевдослучайный и равновероятный. Порядок исходника не даёт приоритета.
- Если не готова ни одна,
selectблокируется, пока что-то не станет готовым. Сdefault— не блокируется никогда. nil-канал не готов всегда; закрытый канал готов всегда.- Выражения каналов вычисляются один раз при входе в
select.
goselect { case job := <-jobs: process(job) case <-ctx.Done(): return // отмена — такая же ветка, как и работа case out <- result: // отправка тоже может быть веткой }
Случайность — защита от голодания: фиксированный порядок уморил
бы второй источник. Поэтому приоритет строят явно: сначала
неблокирующая проверка, затем общий select.
goselect { case <-ctx.Done(): // приоритетная проверка return default: } // затем обычный select с работой и ctx.Done()
Пустой select {} блокируется навсегда — иногда это осознанный
способ «остановить» main, но чаще признак забытой ветки.
4.2 Таймауты: After, NewTimer и цена Tick
time.After(d) возвращает канал, в который через d придёт
значение. Удобно для одноразового дедлайна в select:
goselect { case res := <-results: return res case <-time.After(200 * time.Millisecond): return ErrTimeout }
Проблема в цикле: каждый time.After создаёт таймер, живущий
до срабатывания. Миллион шагов — миллион живых таймеров, даже
если результаты приходят мгновенно. В Go 1.22 его нельзя
остановить; в Go 1.23 непривязанный таймер может собрать GC,
но аллокации в горячем цикле всё равно остаются.
Для дедлайна в часто вызываемой функции — time.NewTimer и Stop:
gotimer := time.NewTimer(timeout) defer timer.Stop() select { case res := <-results: return res case <-timer.C: return ErrTimeout }
time.Tick(d) — худший вариант для функции: возвращается только
канал, тикер не остановить. Для периодических задач берите
time.NewTicker и обязательно defer t.Stop().
Если дедлайн привязан к запросу, предпочитайте ctx.Done():
таймаут приходит из context.WithTimeout, отменяется родителем
и не требует Stop — один контекст на всю цепочку вызовов.
4.3 Закрытые и nil-каналы в select
Закрытый канал всегда готов к приёму и отдаёт нулевое значение
с ok == false. В select это означает:
- Ветка приёма из закрытого канала остаётся всегда готовой
и может выбираться снова и снова, пока канал не обнулят.
Лечится
ch = nilпослеok == false(модуль 3). - Закрытый канал — хорошее широковещание:
close(done)будит всеselect, ожидающие<-done. - Отправка в закрытый канал в
selectне безопаснее, чем вне него: если ветка выбрана — паникаsend on closed channel.
nil-канал, наоборот, не готов никогда. Поэтому пара «закрыл —
обнулил» позволяет динамически менять набор активных источников,
не перестраивая цикл:
gofor 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) } }
4.4 default: неблокирующие проверки и busy-loop
default делает select неблокирующим: если ни одна ветка
не готова, выполняется он. Это нужно для «попробовать без
ожидания»:
goselect { case sem <- struct{}{}: // взяли пропуск go work() default: // все слоты заняты — не ждём, отказываем или откладываем }
Типичные применения: try-lock, неблокирующая отправка в очередь,
опрос ctx.Done() перед тяжёлым шагом. Но у default есть цена:
цикл без блокировки превращается в busy-loop.
gofor { select { case job := <-jobs: process(job) default: continue // миллионы холостых итераций и 100 % CPU } }
Цикл не отдаёт процессор и не ждёт события. Нужна пауза —
уберите default или добавьте таймер; default уместен для
одного неблокирующего шага, а не для «крутиться и ждать».
Ещё тонкость: default не даёт приоритета отмене. Если работа
готова всегда, ctx.Done() конкурирует с ней на равных, и отмена
может ждать. Проверяйте её отдельным приоритетным select (4.1).
4.5 Сбор нескольких done
Частая задача: запустить N горутин и дождаться всех, либо первой
ошибки. «Ждать все» — это счётчик в select:
goremaining := n for remaining > 0 { select { case <-finished: // одна задача завершилась remaining-- case <-ctx.Done(): // общий дедлайн return ctx.Err() } }
После первой ошибки вызовите cancel() и дождитесь остальных:
горутины могут быть заблокированы на отправке результата, и выход
без дренажа оставит их висеть. Результаты буферизуют на размер
команды или дочитывают после отмены.
sync.WaitGroup ждёт всех, но не умеет «первую ошибку» и дедлайн.
errgroup.Group совмещает оба: WithContext отменяет сестёр,
Wait ждёт всех. Свой select нужен, когда важны промежуточные
события (heartbeat, прогресс), а не только финальный статус.
4.6 Компромиссы
time.AftervsNewTimer: первый — для одноразового дедлайна; второй обязателен в цикле: его можно остановить.time.TickvsNewTicker:Tickне остановить — утечка по построению;NewTickerтребуетStop, зато живёт ровно столько, сколько нужно.- Таймер vs
ctx.Done(): таймер локальный; контекст несёт дедлайн по цепочке вызовов и освобождает ресурсы. defaultvs блокировка:defaultснижает задержку, но при ошибке сжигает CPU; блокирующийselectбесплатен.- Один большой
selectvs несколько: в большом не выразить приоритет; двухуровневый явнее, хотя и длиннее.
💻 Практика
- Воспроизведите «отмену не слышат». Воркер с
selectизjobsиheartbeat, безctx.Done(). Через 100 мс вызовитеcancel()иwg.Wait(): воркер не завершится. Добавьте веткуcase <-ctx.Done(): return—Waitвернётся сразу. - Поймайте утечку таймеров. В цикле на 100 000 итераций
вызовите
time.After(time.Minute)вselectс готовым результатом. Снимите pprof и-benchmem, затем повторите с однимNewTimer+Resetи сравните аллокации. - Сравните busy-loop и блокировку. Счётчик итераций за
100 мс: с
default: continueи безdefault. Объясните разницу в миллионах итераций и CPU; затем добавьтеdefaultтолько для неблокирующего захвата семафора. - Запустите сниппет модуля. Ожидаемый вывод:
воркер: задач 5, heartbeat-ов 3итаймаут: ответа нет — выходим, не виснем. Первая строка — оба закрытых канала дочитаны и обнулены; вторая — выбран дедлайн, потому чтоslowпуст. Запустите с-race.
🧩 Quiz
select, если готовы сразу две ветки?default в select?case <-nilCh: в select?select выбрать отправку в закрытый канал?time.Tick в цикле считают утечкой?✅ Итоги модуля
Студент должен:
- Объяснять правила выбора ветки в
select - Добавлять ветку отмены в каждый цикл воркера
- Различать
time.After,NewTimerиNewTicker - Не использовать
time.Tickв функции - Выключать исчерпанные каналы через
nil - Применять
defaultбез busy-loop - Собирать несколько
doneи не бросать отправителей - Ответить на все вопросы Quiz
🔗 Мостик к Модулю 5
select координирует горутины, но не защищает разделяемое
состояние. Дальше — мьютексы и atomic: критическая секция,
RWMutex и голодание писателя, sync.Once, sync.Map
и sync.Pool.