Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 6 из 25 ~22 мин +50 XP

Select и таймауты

select go — это switch, у которого каждый case стоит в очереди к своему каналу: одна конструкция ждёт сразу несколько каналов и просыпается, когда готова любая из них. Разберём мультиплексирование нескольких каналов, случайный жребий между готовыми ветками, неблокирующие операции с веткой default, воркера с выходом по каналу done и таймауты через time.After — и научимся не ждать вечно. Каждый пример запускается прямо на странице.

Содержание урока · 13 разделов

Select в Go: диспетчер среди каналов#

В прошлом уроке канал умел ждать ровно одну трубу: приём <-ch блокировал горутину, пока по ней не приедет значение. Жизнь быстро ломает такую простоту: сервер одновременно читает запросы из сети и сигналы остановки, воркер — задачи из очереди и команду «хватит», телеметрия смешивает логи, метрики и ошибки из трёх разных труб. Писать по приёму на каждый канал — значит расставить горутину в очередь ожиданий, где, пока вы стоите во второй строке, первая молчит зря.

select в Go — это диспетчер, который висит сразу на нескольких каналах и просыпается на первом готовом. По запросу select go вы найдёте ровно этот механизм: одна конструкция, несколько канальных веток, выполняется та, чья операция сейчас возможна. Это то самое мультиплексирование, о котором мы обещали в конце урока про каналы, — и оно же превратит ни в чём не повинную утечку горутины из урока про горутины в решаемую задачу.

Синтаксически select — близнец switch, а по роли в арсенале — центральная техника продвинутого раздела курса: ветки выбираются не по значению выражения, а по готовности операций с каналами. Дальше — все правила этого выбора: от случайного жребия до таймаутов.

Синтаксис select: switch для каналов#

Выглядит select так: ключевое слово, фигурные скобки, внутри — ветки case, а каждая ветка — операция с каналом. Приём: case v := <-ch1:. Отправка: case ch2 <- x:. Ничего канального в ветке стоять не может — ни сравнение, ни вызов функции; компилятор отвергнет программу сразу. Есть и особая ветка default, которая срабатывает, когда не готов ни один канал, — о ней отдельный раздел ниже.

Три правила поведения. Первое: select вычисляет все свои канальные операции ровно один раз при входе и блокируется, если ни одна не готова. Второе: как только хотя бы одна операция становится возможной, select выполняет её ветку и выходит — в отличие от switch, прохода по всем веткам не бывает, и fallthrough здесь запрещён. Третье: приёмы case v := <-ch и двухзначный case v, ok := <-ch живут здесь так же, как в обычном коде, — флаг ok по-прежнему различает настоящий ноль от «канала больше нет».

Ошибиться в синтаксисе почти негде, а вот в ожиданиях — сколько угодно: поведение select при пустых, полных, закрытых и nil-каналах — это отдельная таблица истинности, которую мы разберём построчно. Начнём с главного сценария — ожидания нескольких источников.

Что может стоять в ветке case#

  • case v := <-ch: — приём со связыванием значения с переменной v, видимой внутри ветки;
  • case v, ok := <-ch: — приём с флагом закрытия: ok = false означает «канал закрыт и опустошён»;
  • case <-ch: — голый приём: значение выбрасывается, важен только факт, что канал сработал;
  • case ch <- x: — отправка: ветка готова, когда канал готов принять значение (есть место в буфере или ждущий приёмник);
  • case <-time.After(d): — таймер: канал, который «прозвенит» один раз через d — на нём строятся таймауты.

Мультиплексирование: select ждёт два канала

Запустите несколько раз: select каждый раз ловит новость — она готова через 30 мс, а тикер ещё спит. main был заблокирован на select, проснулся на первой готовой ветке и вышел; второй канал его уже не интересует. Эксперимент: поменяйте задержки местами (новостям — 500 мс, тикеру — 30) и победитель сменится. Заметьте: горутина тикера после выхода main так и останется спать — программа не ждёт никого, кто не нужен.

Вывод

Запустите код, чтобы увидеть вывод.

Как select принимает решение: блокировка и жребий#

Логика выбора трёхступенчатая. Если готова ровно одна ветка — выполняется она, и это самый частый и самый детерминированный случай. Если не готова ни одна — select блокируется и ждёт, пока что-нибудь созреет; в этом он ничем не отличается от одиночного приёма <-ch, только ждёт сразу несколько вещей. А вот третья ступень — фирменная: если готовых веток несколько, select бросает жребий и выполняет равновероятно одну из них.

Зачем придумана случайность? Чтобы ни один канал не мог раз и навсегда зажать себе все внимание. Представьте ситчаут, где два канала стабильно готовы: при выборе «первого по порядку» второй ветке доставались бы крохи, а жребий держит шансы честными — 50 на 50 при двух готовых, одна третья при трёх. Это встроенная защита от голодания, и она достаётся вам бесплатно.

Из жребия следует практический вывод: программу, где две ветки готовятся одновременно, нельзя писать «под конкретный порядок» — вывод может отличаться от запуска к запуску. Все демо этого урока поэтому построены так, чтобы готовой была ровно одна ветка: рандеву с одной стороны, задержки с запасом в десять раз — с другой. Детерминизм в конкурентном коде делается дизайном, а не молитвой о планировщике.

Два готовых канала: кто победит?

Прочитайте код и выберите, что он выведет:

package main

import "fmt"

func main() {
        a := make(chan string, 1)
        b := make(chan string, 1)
        a <- "из a"
        b <- "из b"
        select {
        case v := <-a:
                fmt.Println(v)
        case v := <-b:
                fmt.Println(v)
        }
}

Ветка default: неблокирующие операции#

select умеет и обратное — не ждать ни секунды. Если среди веток есть default, блокировка отменяется: select мгновенно проверяет каналы, выполняет готовую ветку, а когда готовых нет — выполняет default. Так из блокирующих операций получаются неблокирующие: попробовать прочитать и уйти, попробовать записать и не застрять.

Классические сценарии три. Try-receive: заглянуть в канал «а нет ли уже работы» и, если пусто, заняться чем-то полезным. Try-send: положить значение, если есть место, а если буфер полон — не стоять в очереди, а, скажем, посчитать отказ. Try-both: проверить сразу несколько направлений и сделать что-нибудь по умолчанию, когда всё молчит. Ветка default — это и есть место для «а что, если никого нет».

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

select+default: нечего читать

Прочитайте код и выберите, что он выведет:

package main

import "fmt"

func main() {
        ch := make(chan int)
        select {
        case v := <-ch:
                fmt.Println("пришло:", v)
        default:
                fmt.Println("нечего читать")
        }
}

select внутри for: воркер и канал done#

Поодиночке select выполняется один раз — а сила приходит в связке с циклом. Скелет for { select { ... } } — это стандартный воркер Go: бесконечный цикл, который в каждом обороте ждет два события — «прилетела задача» и «прилетела команда остановиться». Задачи едут по рабочему каналу, команда — по каналу done; каждая ветка case обрабатывает своё.

Канал done — это почтовый ящик для стоп-сигнала. В уроке про горутины горутины запускались и умирали сами по себе; здесь у вас появляется рычаг: main отправляет в done — воркер просыпается в ветке case <-done:, делает уборку и выходит через return. Обратите внимание на нюанс, который стоит прочувствовать: после выхода воркера main не знает, что тот закончил, — в демо ниже мы завели третий канал finished, через который воркер отчитывается, а main терпеливо принимает отчёт. Без такого рандеву main мог бы закончить работу раньше воркера и оборвать его финальную строку.

А как остановить сразу пятерых воркеров? Слать каждому по сигналу — развлечение на минуту. Каноничнее закрыть канал done: ветка приёма из закрытого канала готова всегда, поэтому close(done) срабатывает как широковещательный стоп для любого числа слушателей. Это уже мостик к разделу о закрытых каналах в select — и он подкреплён таблицей ниже. Ожидание целой бригады воркеров аккуратнее делать через WaitGroup — это тема ближайшего урока про синхронизацию, там же соберём полноценный пул workers.

Воркер: выход по done-каналу

Запустите. Три задачи уезжают по одной — каждый send из main встречает воркера прямо в его select. Затем уходит команда done: воркер печатает про стоп-сигнал, отчитывается через finished и умирает; main дожидается отчёта и закрывает программу. Порядок строк строго детерминирован — рандеву держит его за руку. Попробуйте убрать строку <-finished и запустите в основном движке несколько раз: финальная строка воркера может не успеть.

Вывод

Запустите код, чтобы увидеть вывод.

Допишите воркера

Восстановите скелет воркера: цикл с мультиплексированием задач и стоп-сигнала, выход по команде.

package main import "fmt" func main() { jobs := make(chan int) stop := make(chan bool) go func() { for { { case j := <-jobs: fmt.Println("задача", j) case <-: fmt.Println("стоп") } } }() jobs <- 1 jobs <- 2 stop <- true }

Таймауты: time.After и терпение с лимитом#

Если вы искали по запросу «таймауты golang», то почти каждый найденный рецепт — одна и та же строка внутри select: case <-time.After(d):. Функция time.After из пакета time возвращает канал <-chan time.Time и обещает: через длительность d в этот канал приедет одно значение, «будильник прозвенит». В ветке select этот канал работает как соперник: либо основная операция успеет, либо прозвенит таймер — и вы уйдёте по ветке таймаута.

Важное отличие от time.Sleep: sleep блокирует горутину целиком — время ползёт, ничего не происходит. А таймер time.After строится и начинает идти параллельно с остальными ветками select: вы всё ещё ждёте данные, просто теперь у ожидания есть предел. Это и есть канонический таймаут на операцию с каналом — две-три строки вместо целого сторожевого механизма.

У медали есть оборот. Кто не победил в select — тот продолжил жить: неуспешный поставщик так и стоит в своей отправке, а таймер доживает до звонка и мусорится только потом. Один зависший поставщик — мелочь; сотня забытых — утечка горутин, которая съест память. Честное решение этой проблемы — контекст отмены: в курсе ему посвящён отдельный урок про context, а пока запомните правило — select с таймаутом спасает ждущего, но не отправляющего. И ещё: таймер нельзя перезапустить — для переиспользуемых будильников есть time.NewTimer с методом Stop, но в этом уроке нам хватит и одноразового.

Таймаут: не ждать вечно

Запустите: поставщик спит целую секунду, таймаут прозвенел через 100 мс — победила ветка будильника, main не завис и пошёл дальше, хотя горутина поставщика так и стоит в своей отправке. Эксперимент: поставьте поставщику time.Sleep(50 * time.Millisecond) — теперь он успевает раньше будильника, и вывод меняется на «успели: данные». Оба исхода детерминированы: 50 против 100 и 100 против 1000 — запасы в два и десять раз.

Вывод

Запустите код, чтобы увидеть вывод.

Соберите программу с таймаутом

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Шпаргалка: как ведёт себя select#

СитуацияЧто делает selectЧто запомнить
Готова ровно одна веткаВыполняет её немедленноСамый частый и детерминированный случай
Готовы несколько ветокБросает жребий: равновероятно одну из готовыхЗащита от голодания каналов — бесплатно
Не готова ни одна, есть defaultВыполняет ветку defaultНеблокирующие операции: try-receive и try-send
Не готова ни одна, default нетБлокируется до первой готовойВ настоящем Go полный затык всех горутин — fatal error deadlock
Ветка с nil-каналомНикогда не готова — пропускаетсяТак «выключают» остывшие направления
Ветка чтения из закрытого каналаГотова всегдаОстаток буфера, потом нулевое значение и ok = false
Ветка case <-time.After(d)Готова один раз через dКанонический таймаут; таймер не отменяется

Закрытый канал в select: ветка, которая всегда готова#

В прошлом уроке мы выучили: читать закрытый канал можно — сначала вычитывается остаток буфера, потом приём мгновенно возвращает нулевое значение и ok = false. Перенесите это знание в select, и получится жёсткое правило: ветка чтения из закрытого канала готова всегда. Для select «готово» означает «операция может выполниться не блокируясь» — а приём из закрытого канала выполняется мгновенно по определению.

Отсюда две противоположные практики. Злодейская: select в бесконечном for, где одна из веток читает канал, который кто-то закрыл, — ветка становится вечно готовой, цикл превращается в карусель нулевых значений, крутящуюся до лимита времени, а default при этом никогда не дождётся своей очереди. Диагностика простая: программа жрёт процессор и печатает нули — ищите близкий close.

Добрая практика из того же правила — широковещательная остановка. Пять воркеров слушают один канал done в ветках case <-done:; main делает close(done) — и все пять веток становятся готовыми одновременно, все воркеры выходят в одном мгновении, без пяти отдельных сигналов и без подсчёта «кому уже отправил, кому нет». Канал закрывает та сторона, которая им владеет, — правило из прошлого урока здесь работает без исключений.

Закрытый канал в select: от буфера до карусели

GO
ch := make(chan int, 2)
ch <- 5
ch <- 6
close(ch) // канал закрыт, но в буфере ещё два значения

for i := 0; i < 3; i++ {
        select {
        case v, ok := <-ch: // закрытый канал: ветка чтения всегда готова
                fmt.Println("ветка:", v, ok) // 5 true, 6 true, 0 false
        default:
                fmt.Println("default не сработает ни разу")
        }
}
// А если такой select стоит в for без счётчика — цикл с закрытым
// каналом станет бесконечной каруселью нулей и будет есть процессор.

Три оборота цикла показывают полный сценарий: два значения из буфера с ok = true, затем ноль и ok = false — причём ветка побеждает default на каждом обороте, потому что закрытая ветка готова всегда, а default выбирается только когда готовых нет. Проверьте в песочнице: программа печатает «5 true», «6 true», «0 false» и ни разу — про default. (Блок намеренно без кнопки: в резервном движке нет close.)

Проверьте себя

Канал закрыт и полностью опустошен. Что произойдёт с веткой case v, ok := <-ch в select?

nil-канал в select: выключатель ветки#

Прошлый урок оставил интригу: nil-канал блокирует навсегда — и «в конструкциях выбора планировщик его никогда не выбирает». Время собрать выигрыш. Ветке case нужен канал, готовый к операции; nil-канал не готов никогда — значит, ветка с ним мертва на корню. То, что в обычном коде было причиной зависаний, в select становится выключателем ветки.

Паттерн: приёмник владеет переменной канала и в любой момент присваивает ей nil — secondary = nil — и ветка case v := <-secondary: мгновенно замолкает, хотя сам select не переписан ни на строку. Присвоили другой канал — ветка ожила и слушает уже нового поставщика. Так мультиплексор собирают по частям: источник потерял доверие — выключили его ветку, вместо него подключили другой канал, а когда придёт quit — ушли совсем. Ничего похожего switch вам не предложит: там ветки задаются при компиляции, здесь — в рантайме значением переменной.

Один предостерегающий аккорд. Выключатель работает, пока жива хоть одна другая ветка: select, у которого все канальные ветки nil-каналы и нет default, блокируется навсегда — в настоящем Go это fatal error deadlock, в песочнице сработает лимит времени. И не забудьте про origin: нулевое значение канала — nil, забытый make выключает канал целиком, как говорилось в прошлый раз.

Выключатель ветки: secondary = nil

GO
// primary и quit — каналы, созданные заранее
secondary := make(chan string) // запасной источник, пока слушаем
// ...в какой-то момент источник теряет доверие:
secondary = nil // ветка case <-secondary больше не выбирается никогда

for {
        select {
        case v := <-primary: // живая ветка: работает как раньше
                fmt.Println("основной поток:", v)
        case v := <-secondary: // nil-канал: ветка выключена
                fmt.Println("не сработает никогда:", v)
        case <-quit: // сигнал завершения
                return
        }
}
// Вернуть ветку к жизни можно тем же способом: secondary = другойКанал.

Присваивание nil каналу-переменной — это не ошибка, а идиома: select пересматривает готовность веток на каждом входе, и мёртвая ветка просто выпадает из жребия. Мультиплексор остаётся тем же кодом, а набор источников меняется в рантайме. (Блок без кнопки: фрагмент с внешними каналами, в резервном движке его не исполнить.)

Проверьте себя ещё раз

В select ни одна ветка не готова, а ветки default нет. Что произойдёт?

Карточки урока

Термин

select — что это и для чего

нажмите, чтобы перевернуть
Определение

Switch для каналов: каждая ветка — приём или отправка. Select ждёт несколько каналов сразу (мультиплексирование) и выполняет первую готовую ветку; fallthrough нет, в ветках только канальные операции.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • select — switch для каналов: каждая ветка — приём или отправка, select ждёт несколько каналов сразу и выполняет первую готовую.
  • Готова одна ветка — она; готовы несколько — равновероятный жребий; не готова ни одна без default — блокировка до первой готовой.
  • Ветка default делает select неблокирующим: try-receive и try-send; select с default в for — занятое ожидание, которое сжигает процессор.
  • for { select { ... case <-done: return } } — скелет воркера; канал done даёт управляемую остановку, close(done) — широковещательную.
  • case <-time.After(d) — канонический таймаут: будильник-канал идёт параллельно с ожиданием и прозвенит один раз через d.
  • Ветка с nil-каналом никогда не готова: присваиванием nil источники «выключают», а присваиванием канала — включают обратно.
  • Ветка чтения из закрытого канала готова всегда — остаток буфера, потом ноль и ok = false; в бесконечном for это карусель нулей.

Домашнее задание

Соберите fan-in: два производителя шлют в два отдельных канала по пять сообщений с разными задержками time.Sleep, а main читает оба канала одним select в цикле for, печатая, из какого канала пришло сообщение. Завершайте приём по каналу done после десяти принятых сообщений, а затем добавьте в select ветку case <-time.After(500 * time.Millisecond) — пусть программа гарантированно не зависнет, даже если вы закомментируете одного производителя. Прогоните в основном движке песочницы и убедитесь, что порядок сообщений от запуска к запуску меняется — это жребий select.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Select и таймауты)

Вопросы и ответы по уроку

Чем select отличается от switch простыми словами?

Switch выбирает ветку по значению выражения — условия известны до выполнения. Select выбирает ветку по готовности операций с каналами в момент выполнения: что созрело, то и работает. При нескольких готовых ветках switch строго упорядочен, а select бросает жребий; fallthrough в select запрещён.

Что будет, если в select нет ни готовой ветки, ни default?

Select заблокирует горутину, пока хотя бы одна ветка не станет готовой, — это норма, а не ошибка. Падает настоящий Go только когда заблокированы все горутины программы: fatal error «all goroutines are asleep». Если блокировка нежелательна — добавьте default для неблокирующего режима или таймаут case <-time.After(d).

Как сделать таймаут на чтение из канала?

Обернуть приём в select и добавить ветку case <-time.After(d): кто первый готов — тот и победил. Для сложных сценариев (отмена, дедлайн, таймаут на всю цепочку вызовов) в Go используется пакет context — ему посвящён отдельный урок курса, а ветка time.After остаётся каноническим решением в одну строку.

Почему select с default в цикле грузит процессор?

Такой цикл не ждёт никогда: он проверяет каналы миллионы раз в секунду и каждый раз уходит в default — это занятое ожидание (busy loop). Лечится по обстоятельствам: убрать default и дать select спать, вставить паузу или тикер time.After между проверками, либо пересмотреть дизайн в сторону блокирующего воркера с done-каналом.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией