Горутина ждёт ответ от сервиса. Пока ждёт, может случиться одно из трёх: ответ пришёл,
пользователь отменил запрос, истекло время. Приём из одного канала ждёт только одно
событие. Чтобы ждать несколько сразу и реагировать на то, которое наступит первым, в
Go есть select.
select блокирует горутину, пока не станет возможна хотя бы одна из перечисленных
операций с каналами, и выполняет ровно одну из них. Из этой конструкции собираются
таймауты, отмена, неблокирующие попытки, слияние потоков и приоритеты. В главе
разберём правила выбора, default, таймауты и таймеры, выключение веток через
nil-каналы и то, как подготовиться к задачам блока 1.
Нужны правила буфера, закрытия и nil-каналов из главы «Каналы».
Ветки вида case <-ctx.Done(): пока читайте как «ждём сигнала отмены»: это приём из
канала, который закрывается при отмене. Полностью контекст разобран в
главе context.
Главное слово этой главы — «готова». Ветка готова, если её операция может выполниться
прямо сейчас, без ожидания: в канале есть значение, канал закрыт, у отправки есть
получатель или место в буфере.
По форме select похож на switch, но каждая ветка — операция с каналом:
select {case v := <-in: process(v)case out <- result: // отправилиcase <-done: return}
Как select выбирает ветку:
готова ровно одна ветка — выполняется она;
готовы несколько — выбирается одна из них случайно, с равной вероятностью;
порядок записи веток в коде не важен;
не готова ни одна, но есть default — выполняется default;
не готова ни одна и default нет — горутина ждёт, пока какая-нибудь ветка не станет
готовой.
Случайный выбор нужен, чтобы ни одна ветка не получила постоянного преимущества. Но и
равенства во времени он не гарантирует: при неудачном раскладе одна ветка может
проигрывать несколько раз подряд.
Две горутины-генератора пишут в свои каналы, а цикл с select сливает оба потока.
Закончившийся источник отслеживаем по ok == false и считаем, сколько источников
ещё открыто:
package mainimport "fmt"func gen(vals ...int) <-chan int { ch := make(chan int) go func() { defer close(ch) for _, v := range vals { ch <- v } }() return ch}func main() { a, b := gen(1, 2, 3), gen(10, 20) sum := 0 open := 2 for open > 0 { select { case v, ok := <-a: if !ok { a = nil // закрыт: больше не выбираем open-- continue } sum += v case v, ok := <-b: if !ok { b = nil open-- continue } sum += v } } fmt.Println("sum =", sum)}
sum = 36
В каком порядке придут значения из a и b, заранее неизвестно, и от запуска к
запуску он меняется. Сумма от порядка не зависит. Корректные программы с select
строят именно так: проверяют то, что не зависит от порядка.
default превращает select в неблокирующую попытку. Если ни одна ветка не готова
прямо сейчас, выполняется default:
package mainimport "fmt"func main() { ch := make(chan int, 1) ch <- 42 for i := 0; i < 2; i++ { select { case v := <-ch: fmt.Println("получили:", v) default: fmt.Println("пусто") } }}
получили: 42пусто
На первой итерации в буфере лежит значение, ветка приёма готова. На второй канал пуст,
и select сразу уходит в default.
Так делают неблокирующую отправку (case ch <- v: плюс default: отправить, если есть
место, иначе отбросить), проверку «не пришёл ли сигнал» между шагами работы и
предпочтение одной ветки перед другой (об этом ниже).
Ловушка: select с default внутри цикла без другого ожидания крутится вхолостую и
загружает ядро на 100%. Если нужно «ждать данных, но реагировать на отмену», default
не нужен: добавьте ветку <-ctx.Done() или <-done, и select будет ждать сразу оба
канала, не тратя процессор.
time.After(d) возвращает канал, в который через время d придёт текущее время. Если
поставить его веткой в select, получится таймаут:
package mainimport ( "fmt" "time")func main() { ch := make(chan int) done := make(chan struct{}) defer close(done) go func() { time.Sleep(30 * time.Millisecond) // «работа» дольше таймаута select { case ch <- 7: case <-done: // получатель ушёл по таймауту, отправлять некому } }() select { case v := <-ch: fmt.Println("значение:", v) case <-time.After(10 * time.Millisecond): fmt.Println("таймаут") }}
таймаут
Работа длится 30 мс, таймаут — 10 мс, поэтому выбирается вторая ветка. Поменяйте
задержки местами, и программа напечатает значение: 7. Обратите внимание на select
внутри горутины: без ветки <-done она осталась бы ждать на ch <- 7 после того, как
main ушёл по таймауту. В этой программе это не заметно, потому что main сразу
завершается, но в долгоживущем сервисе каждый такой таймаут оставлял бы зависшую
горутину.
Различайте два вида таймаутов. Таймаут каждого ожидания («если 500 мс не было
событий, хватит») заново отсчитывается при каждом событии. Таймаут всей операции
(«на весь запрос не больше двух секунд») задают один раз, и для него удобнее
context с дедлайном.
Немного словаря пакета time. time.Duration — промежуток времени:
500*time.Millisecond — полсекунды. time.Sleep(d) усыпляет текущую горутину.
time.NewTimer(d) создаёт таймер, который сработает один раз: через d в канал t.C
придёт значение. time.NewTicker(d) создаёт тикер, который присылает значения в t.C
каждые d, пока его не остановят.
У таймера два метода управления. t.Stop() отменяет срабатывание, t.Reset(d)
назначает новый срок. Ни один из них не закрывает канал t.C, поэтому горутина,
которая ждёт <-t.C у остановленного таймера, будет ждать вечно. Если ей нужно уметь
выйти, рядом в select должна стоять ветка done или ctx.Done().
Поведение таймеров изменилось в Go 1.23, и это стоит знать, потому что старый код и
старые статьи до сих пор встречаются:
До Go 1.23 канал таймера был буферизованным. Если таймер уже сработал, а значение
никто не забрал, после Reset в канале оставалось старое значение, и следующий приём
срабатывал сразу. Поэтому перед Reset писали
if !t.Stop() { <-t.C } — остановить и вычерпать старое значение.
С Go 1.23 (если в go.mod указана версия go 1.23 или новее) после возврата из
Stop или Reset старое значение из t.C уже не придёт. Вычерпывать канал не нужно.
Кроме того, таймер, на который больше никто не ссылается, сборщик мусора убирает,
даже если его не остановили.
time.After создаёт новый таймер при каждом вызове. В цикле, который крутится тысячи
раз в секунду, это тысячи таймеров; до Go 1.23 каждый жил до своего срабатывания. С
Go 1.23 это уже не утечка памяти, но лишние выделения остаются, и в горячем цикле
удобнее один таймер с Reset.
Проверьте понимание на задаче 03 (генератор с таймаутом бездействия) до того, как её
решать. Таймаут простоя 500 мс. Генератор стартовал в момент 0, последнее значение
получатель забрал в момент 200 мс, после этого никто не читает. Когда генератор
должен остановиться? Около 700 мс: срок отсчитывается от последней успешной
отправки. Попытка отправить, которую никто не принял, срок продлевать не должна.
В главе про каналы мы видели, что операция с nil-каналом ждёт
вечно. Внутри select это означает, что ветка с nil-каналом никогда не готова.
Присвоив переменной канала nil, вы выключаете ветку, не меняя код select.
Зачем это нужно, видно на функции merge, которая сливает два потока в один:
package mainimport "fmt"func gen(vals ...int) <-chan int { ch := make(chan int) go func() { defer close(ch) for _, v := range vals { ch <- v } }() return ch}// merge сливает два потока в один и закрывает выход, когда оба исчерпаны.func merge(a, b <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for a != nil || b != nil { // пока жив хотя бы один источник select { case v, ok := <-a: if !ok { a = nil // a закрыт: выключаем ветку continue } out <- v case v, ok := <-b: if !ok { b = nil continue } out <- v } } }() return out}func main() { count := 0 for range merge(gen(1, 2, 3), gen(4, 5)) { count++ } fmt.Println("получено значений:", count)}
получено значений: 5
Без обнуления закрытый канал a был бы готов всегда, select выбирал бы его ветку
снова и снова, и цикл крутился бы вхолостую, получая нули. С обнулением ветка просто
исчезает из выбора. Этот merge ещё не умеет останавливаться, если потребитель ушёл
раньше времени; как это исправить, разобрано в главе «Паттерны».
select не знает приоритетов. Если готовы и <-done, и <-data, выбор случаен. Когда
одну ветку нужно проверять первой, это делают вложенным select: сначала неблокирующая
проверка важной ветки, потом общее ожидание.
for { select { case v := <-high: use(v) continue // high был готов: обработали и снова проверяем high default: } select { // high пуст: ждём любой из двух case v := <-high: use(v) case v := <-low: use(v) }}
Это предпочтение в момент первой проверки, а не строгий приоритет. Если оба канала
станут готовы, пока горутина ждёт во втором select, выбор снова случаен. И если
high готов постоянно, low может не получить ни одного шанса — это называется
голоданием. Задача 04 построена именно на таком цикле; закрытие каналов и выход из
цикла там нужно добавить самостоятельно.
У select без веток нет ни одной ветки, которая может стать готовой, поэтому он ждёт
вечно. Иногда так пишут в конце main, когда вся работа идёт в фоновых горутинах. Если
заблокируются вообще все горутины, рантайм завершит программу с сообщением
fatal error: all goroutines are asleep - deadlock!. Для сервисов чаще ждут сигнал
остановки (см. graceful shutdown в главе context), чтобы было как
завершиться.
Задача 01 просит функцию or, которая по любому числу сигнальных каналов возвращает
один канал, закрывающийся при срабатывании любого из входных. Для двух каналов это
один select:
func or2(a, b <-chan struct{}) <-chan struct{} { out := make(chan struct{}) go func() { defer close(out) select { case <-a: case <-b: } }() return out}
Для произвольного числа каналов select с переменным числом веток не напишешь: число
case фиксируется при компиляции. Выход — рекурсия. Рекурсивная функция решает задачу,
вызывая саму себя для задачи поменьше, и у неё обязательно есть условие остановки.
Простейший пример:
func count(n int) int { if n <= 0 { return 0 // условие остановки } return 1 + count(n-1) // та же задача, но меньше}
Для count(3) вызовы идут 3 → 2 → 1 → 0, а результаты возвращаются обратно:
0 → 1 → 2 → 3. В or уменьшается число каналов: «или» от пяти каналов можно выразить
через «или» от части каналов и select по нескольким. Подумайте, какие у рекурсии
случаи остановки (0 и 1 канал описаны в условии).
Есть тонкость, о которой легко забыть. Когда сработал один канал, select во
вспомогательных горутинах на других уровнях рекурсии всё ещё ждёт свои каналы, и если
они не сработают никогда (sig(3*time.Hour)), горутины зависнут. Каждой
вспомогательной горутине нужен способ узнать, что результат уже получен. Тесты задачи
этого не проверяют, но условие требует.
Расчёт на порядок веток. «done написан первым, значит, проверится первым» —
неверно. При готовности обеих веток выбор случаен.
Нет ветки отмены.select, который ждёт только данные, висит вечно, если данные
перестали приходить. В долгоживущем цикле почти всегда нужна ветка <-ctx.Done() или
<-done, иначе горутину нечем остановить.
default в цикле без ожидания. Цикл крутится вхолостую и загружает процессор. Если
задача — дождаться события, default не нужен.
Закрытый канал в цикле с select. Ветка закрытого канала готова всегда. Без проверки
ok и обнуления цикл будет бесконечно получать нулевые значения.
select — это «дождись первого из нескольких событий». Горутина паркуется сразу на всех
каналах из веток и просыпается, когда одна из операций может выполниться. Поведение
настраивается двумя способами: default (не ждать вообще) и nil-канал (не ждать
конкретного события). Этого хватает почти на всю управляющую логику конкурентной
программы.
Создайте два канала int с буфером 1. Положите в первый 3, во второй 7, закройте
оба. В цикле с select прочитайте все значения, выключая исчерпанные каналы через
nil, и напечатайте сумму. Можно ли рассчитывать, что первой прочитается тройка?
Сумма — 10. Какой канал прочитается первым, не определено: в начале готовы оба,
и select выбирает случайно.
sum := 0for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil continue } sum += v case v, ok := <-b: if !ok { b = nil continue } sum += v }}fmt.Println(sum)
Фрагмент ставится после создания и заполнения a и b. Без обнуления цикл никогда бы
не закончился: закрытый пустой канал всегда готов.
Ветка <-ctx.Done() в select — стандартный способ сделать операцию отменяемой; она
разобрана в главе context. Слияние потоков, конвейеры и пулы,
построенные на select, — в главе «Паттерны».
select — половина блока 1 задач (задачи 01–04): «или»-канал, отправка с выходом по
сигналу, таймаут бездействия и предпочтение одного канала. В блоках 3, 4 и 7 связка
select с <-ctx.Done() встречается почти в каждой горутине.