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.
go
select {
case job := <-jobs:
    process(job)
case <-ctx.Done():
    return // отмена — такая же ветка, как и работа
case out <- result:
    // отправка тоже может быть веткой
}

Случайность — защита от голодания: фиксированный порядок уморил бы второй источник. Поэтому приоритет строят явно: сначала неблокирующая проверка, затем общий select.

go
select {
case <-ctx.Done(): // приоритетная проверка
    return
default:
}
// затем обычный select с работой и ctx.Done()

Пустой select {} блокируется навсегда — иногда это осознанный способ «остановить» main, но чаще признак забытой ветки.

4.2 Таймауты: After, NewTimer и цена Tick

time.After(d) возвращает канал, в который через d придёт значение. Удобно для одноразового дедлайна в select:

go
select {
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:

go
timer := 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-канал, наоборот, не готов никогда. Поэтому пара «закрыл — обнулил» позволяет динамически менять набор активных источников, не перестраивая цикл:

go
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)
    }
}

4.4 default: неблокирующие проверки и busy-loop

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

go
select {
case sem <- struct{}{}: // взяли пропуск
    go work()
default:
    // все слоты заняты — не ждём, отказываем или откладываем
}

Типичные применения: try-lock, неблокирующая отправка в очередь, опрос ctx.Done() перед тяжёлым шагом. Но у default есть цена: цикл без блокировки превращается в busy-loop.

go
for {
    select {
    case job := <-jobs:
        process(job)
    default:
        continue // миллионы холостых итераций и 100 % CPU
    }
}

Цикл не отдаёт процессор и не ждёт события. Нужна пауза — уберите default или добавьте таймер; default уместен для одного неблокирующего шага, а не для «крутиться и ждать».

Ещё тонкость: default не даёт приоритета отмене. Если работа готова всегда, ctx.Done() конкурирует с ней на равных, и отмена может ждать. Проверяйте её отдельным приоритетным select (4.1).

4.5 Сбор нескольких done

Частая задача: запустить N горутин и дождаться всех, либо первой ошибки. «Ждать все» — это счётчик в select:

go
remaining := 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.After vs NewTimer: первый — для одноразового дедлайна; второй обязателен в цикле: его можно остановить.
  • time.Tick vs NewTicker: Tick не остановить — утечка по построению; NewTicker требует Stop, зато живёт ровно столько, сколько нужно.
  • Таймер vs ctx.Done(): таймер локальный; контекст несёт дедлайн по цепочке вызовов и освобождает ресурсы.
  • default vs блокировка: default снижает задержку, но при ошибке сжигает CPU; блокирующий select бесплатен.
  • Один большой select vs несколько: в большом не выразить приоритет; двухуровневый явнее, хотя и длиннее.

💻 Практика

  1. Воспроизведите «отмену не слышат». Воркер с select из jobs и heartbeat, без ctx.Done(). Через 100 мс вызовите cancel() и wg.Wait(): воркер не завершится. Добавьте ветку case <-ctx.Done(): return — Wait вернётся сразу.
  2. Поймайте утечку таймеров. В цикле на 100 000 итераций вызовите time.After(time.Minute) в select с готовым результатом. Снимите pprof и -benchmem, затем повторите с одним NewTimer + Reset и сравните аллокации.
  3. Сравните busy-loop и блокировку. Счётчик итераций за 100 мс: с default: continue и без default. Объясните разницу в миллионах итераций и CPU; затем добавьте default только для неблокирующего захвата семафора.
  4. Запустите сниппет модуля. Ожидаемый вывод: воркер: задач 5, heartbeat-ов 3 и таймаут: ответа нет — выходим, не виснем. Первая строка — оба закрытых канала дочитаны и обнулены; вторая — выбран дедлайн, потому что slow пуст. Запустите с -race.

🧩 Quiz

1. Что выберет select, если готовы сразу две ветки?
2. Зачем нужен default в select?
3. Что делает ветка case <-nilCh: в select?
4. Что произойдёт, если в select выбрать отправку в закрытый канал?
5. Почему 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.