Menu

select в Golang: таймауты, default и каналы остановки

Как select ждёт сразу несколько операций с каналами: выбор среди готовых веток, неблокирующие отправка и получение через default, таймауты через time.After и остановка циклов через канал quit или context.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

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

select похож на switch, но каждая его ветка это отправка в канал или получение из канала. Он блокируется, пока одна ветка не сможет выполниться, и выполняет её.

С такими задержками первый select получает быстрый результат, а второй медленный. Ни одному получению не нужно ждать другого канала. Обычные <-slow и затем <-fast обработали бы их в фиксированном порядке, независимо от того, что пришло первым.

Как вычисляется select:

  1. Все выражения каналов и отправляемые значения вычисляются один раз, в порядке исходного кода, когда select начинается.
  2. Если готова одна или несколько веток, одна из них выбирается случайно.
  3. Если ни одна не готова и есть default, выполняется default.
  4. Иначе горутина блокируется, пока какая-то ветка не станет готовой.

Пустой select {} блокируется навсегда. Иногда его можно увидеть в конце main в программах, чья настоящая работа идёт в других горутинах.

Случайный выбор среди готовых веток

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

Распределение между countA и countB меняется при каждом запуске и держится около 500 на каждый. Случайный выбор сделан намеренно: он не даёт загруженному каналу заморить голодом остальные. Если нужен приоритет, смотрите шаблон ниже.

Неблокирующие операции через default

С веткой default оператор select никогда не блокируется. Это превращает отправку или получение в операцию «попробовать»:

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

Не ставьте default в select внутри цикла for только ради того, чтобы снова и снова «проверять» каналы. Когда ничего не готово, цикл крутится на 100% CPU. Лучше блокируйтесь, а если нужно периодически просыпаться, добавьте ветку с таймаутом.

Таймауты

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

Первый вызов возвращает "data". Второй возвращает ошибку таймаута через 50 мс, задолго до того, как воркер закончил бы. У канала результата буфер 1 намеренно. Когда побеждает таймаут, из result никто никогда не читает; с небуферизованным каналом горутина-воркер навсегда заблокировалась бы на отправке и утекла.

В цикле time.After на каждой итерации создаёт новый таймер, и для таймаута простоя на каждое сообщение («нет сообщений 1 секунду») это ровно то, что нужно. Для общего дедлайна на множество операций создайте один таймер или контекст до цикла. Начиная с Go 1.23 таймеры, на которые больше никто не ссылается, собираются сборщиком мусора, даже если ещё не сработали, поэтому time.After в цикле больше не держит память до срабатывания каждого таймера, как в старых версиях (для этого в go.mod нужно go 1.23 или новее).

Циклы for-select и каналы остановки

Горутина, которая работает, пока её не остановят, это цикл for вокруг select с одной веткой для работы и одной для остановки:

Идиома в том, чтобы закрывать quit, а не отправлять в него: закрытие видит каждый получатель, сейчас и позже, так что один close останавливает любое число воркеров. Канал done позволяет main дождаться, пока воркер действительно вернётся.

В реальном коде канал остановки обычно это context.Context: case <-ctx.Done():. Работает так же (Done() возвращает канал, который закрывается при отмене), но вдобавок несёт дедлайны и причину остановки. Подробности на странице о context.

break внутри select

break в ветке select выходит из select, а не из охватывающего for. Это частый источник циклов, которые никогда не заканчиваются. Используйте return или пометьте цикл:

Приоритет между каналами

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

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	select {
	case <-ctx.Done():
		return ctx.Err()
	case job := <-jobs:
		handle(job)
	}
}

Первый select сразу возвращается, если отмена уже произошла. Без него постоянный поток заданий мог бы ещё какое-то время выигрывать случайный выбор после отмены ctx.

nil-каналы отключают ветку

Отправка или получение на nil-канале никогда не готовы, так что ветка с nil-каналом фактически выключена. Присвоить закрытому входному каналу nil это способ перестать делать select по нему, продолжая работать с остальными; на странице о каналах показан построенный так цикл слияния. Тем же приёмом включают и выключают таймаут: держите var timeout <-chan time.Time равным nil, пока он не нужен, а затем присвойте time.After(d).

Частые ошибки

  • Расчёт на порядок в коде. Первая по списку ветка не имеет преимущества.
  • Холостой цикл с default. for { select { ... default: } }, когда делать нечего, сжигает ядро процессора.
  • Утечка проигравшего. Когда побеждает таймаут, горутина, которая должна была отправить результат, всё равно должна иметь возможность завершиться. Дайте её каналу буфер 1.
  • break, который выходит только из select. Используйте метку или return.
  • Один time.After на итерацию цикла в роли общего дедлайна. Он перезапускается на каждой итерации; создайте дедлайн один раз, вне цикла.

Часто задаваемые вопросы

Что делает select в Go?

select ждёт, пока одна из его операций с каналами (отправка или получение) сможет выполниться, и выполняет эту ветку. Если одновременно готовы несколько, он выбирает одну случайно. Без ветки default он блокируется, пока какая-то ветка не станет готовой; с default он не блокируется никогда.

Как добавить таймаут к получению из канала в Go?

Поместите получение и таймер в один select: select { case v := <-ch: use(v); case <-time.After(2 * time.Second): return errTimeout }. Побеждает то, что случится раньше. В цикле или когда у вызывающего кода уже есть дедлайн используйте context.Context с context.WithTimeout и делайте select по ctx.Done().

Выбирает ли select в Go ветки по порядку?

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

Почему break не выходит из моего цикла for-select?

Внутри select оператор break выходит только из инструкции select, а не из окружающего for. Используйте return или поставьте метку на цикл (loop: for { select { case <-done: break loop } }).

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ