Содержание урока · 13 разделов
- Select в Go: диспетчер среди каналов
- Синтаксис select: switch для каналов
- Что может стоять в ветке case
- Как select принимает решение: блокировка и жребий
- Ветка default: неблокирующие операции
- select внутри for: воркер и канал done
- Таймауты: time.After и терпение с лимитом
- Шпаргалка: как ведёт себя select
- Закрытый канал в select: ветка, которая всегда готова
- nil-канал в select: выключатель ветки
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 и запустите в основном движке несколько раз: финальная строка воркера может не успеть.
Запустите код, чтобы увидеть вывод.
Допишите воркера
Восстановите скелет воркера: цикл с мультиплексированием задач и стоп-сигнала, выход по команде.
Таймауты: 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: от буфера до карусели
GOch := 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 пересматривает готовность веток на каждом входе, и мёртвая ветка просто выпадает из жребия. Мультиплексор остаётся тем же кодом, а набор источников меняется в рантайме. (Блок без кнопки: фрагмент с внешними каналами, в резервном движке его не исполнить.)
Кнопка «Запустить» работает на основном движке — настоящем Go (yaegi, собран в WASM): select, close и time.After исполняются там как в локальном go run, включая случайный жребий между готовыми ветками. Если WASM не загрузился, песочница включает резервный упрощённый движок, и в нём: select есть, но жребий не бросается — всегда выбирается первая готовая ветка по порядку записи; встроенной close нет, поэтому закрытый канал в select не воспроизвести; time.After отсутствует («у пакета time нет члена After») — таймаут-примеры не запустятся вовсе; time.Sleep урезан до 50 мс, так что задержки демо не соблюдаются; а select без готовых веток и без default резерв отвечает ошибкой «select заблокирован навсегда: deadlock» вместо честного ожидания. Таймауты, predict про жребий и всё про закрытые каналы проверяйте в основном движке.
Проверьте себя ещё раз
В select ни одна ветка не готова, а ветки default нет. Что произойдёт?
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем 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-каналом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией