JavaRush /Курсы /Go SELF /select: мультиплекси...

select: мультиплексирование каналов и default-ветка

Go SELF
67 уровень , 0 лекция
Открыта

1. Зачем нужен select: ждать одно из нескольких

Если вы до этого писали конкурентный код, вы могли думать примерно так: «Мне нужно дождаться значения из канала — ну окей, пишу <-ch». И это действительно рабочий путь… пока у вас один канал и один сценарий. Но реальная программа обычно живёт в мире «или»: либо пришла новая задача, либо надо остановиться, либо пришёл сигнал «хватит», либо появилось место в канале для отправки, либо какой-то компонент уже закрыл вход.

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

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

2. Синтаксис select и «готовый case»

select внешне похож на switch, но смысл у него другой: switch выбирает ветку по значению выражения, а select выбирает ветку по готовности канальной операции. Внутри select каждый case — это либо получение (<-ch), либо отправка (ch <- v). Если прямо сейчас выполнить операцию нельзя, такой case считается «не готов».

Минимальный пример: ждём, кто первый пришлёт сообщение — канал a или канал b.

package main

func main() {
	select {
	case <-make(chan int):
		// сюда почти не попадём (канал никто не пишет)
	case <-make(chan int):
		// и сюда тоже
	}
}

Этот пример специально «бесполезный»: оба канала никто не обслуживает, поэтому select просто заблокируется навсегда. Зато он показывает главное правило: если ни один case не готов, select блокируется (если нет default).

Чтобы понимать «готовность», полезно держать в голове простую таблицу.

Операция Unbuffered канал (make(chan T)) Buffered канал (make(chan T, n))
Receive: v := <-ch готово, если кто-то уже отправляет или канал закрыт готово, если в буфере есть хотя бы 1 элемент или канал закрыт
Send: ch <- v готово, если кто-то уже ждёт чтения готово, если в буфере есть свободное место

И тут важная мелочь: закрытый канал делает receive «готовым» (он не блокирует), но даёт вам ok == false. То есть «готово» не всегда значит «получили полезные данные».

Почему порядок case не даёт приоритета

Очень хочется (особенно после if/else) думать так: «Ну я же написал сверху самый важный case, значит он будет иметь приоритет». Так вот… нет. И это не баг, а важная часть дизайна.

Правило звучит так: если готов ровно один case — он и выполняется. Если готовы несколько — Go выбирает один из них недетерминированно (на практике это выглядит как псевдослучайность). Это сделано, чтобы не было скрытого «приоритета» от порядка строк в коде, иначе конкурентные программы превращались бы в минное поле из неочевидных starvation-багов.

Покажем это на маленьком и честном примере: два буферизованных канала уже содержат значения, значит оба case готовы.

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) // иногда A
	case v := <-b:
		fmt.Println(v) // иногда B
	}
}

Если вы запустите это несколько раз, вы увидите разный вывод. И это нормально. Мораль простая: корректность программы нельзя строить на том, что select «сначала проверит верхний case».

Если вам нужен приоритет, его делают явным (например, отдельной логикой, отдельными каналами или протоколом), а не «магией порядка строк».

3. default: неблокирующая попытка

default в select — это не «ветка на всякий случай», как в switch. В select default означает: «Если прямо сейчас нельзя выполнить ни один case, не ждать, а выполнить вот это».

То есть default превращает select из «ожидания события» в «попытку сделать что-то, если повезло».

Неблокирующее чтение из канала:

package main

import "fmt"

func main() {
	ch := make(chan int)

	select {
	case v := <-ch:
		fmt.Println("got:", v)
	default:
		fmt.Println("nothing yet") // nothing yet
	}
}

Неблокирующая отправка особенно популярна в ситуациях «первый результат выигрывает»: мы хотим отправить результат, но если кто-то уже успел отправить раньше — не зависать. Этот паттерн прямо встречается в классических примерах конкурентности Go: отправка делается через select { case ch <- v: default: }, чтобы проигравшие горутины не повисли навечно.

package main

func trySend(ch chan<- int, v int) bool {
	select {
	case ch <- v:
		return true
	default:
		return false
	}
}

Здесь важно проговорить философию: default — это разрешение потерять операцию (или отложить её), потому что мы сознательно решили «не ждать». Если потеря недопустима, default — не ваш инструмент.

Небольшой исторический факт: в очень ранних версиях Go существовали отдельные «булевые» формы неблокирующих операций с каналами, но их убрали, потому что select решает задачу универсально и делает язык проще.

4. Мини-приложение: событийный цикл TaskBox на select

Сейчас соберём небольшой кусок нашего учебного приложения. Допустим, по ходу курса у нас формируется консольная программа TaskBox: она принимает команды, добавляет задачи и иногда пишет «служебные сообщения» в лог-канал. Сегодня наша цель не «сделать идеальный таск-трекер», а показать, как select становится сердцем программы: один цикл слушает события и реагирует.

Нарисуем схему, чтобы было ощущение «кто с кем разговаривает»:

flowchart LR
    Input[goroutine: читает команды] -->|Task| addCh
    Loop[event loop: select] -->|log msg| logCh
    Logger[goroutine: печатает лог] --> Out[(stdout)]
    addCh --> Loop
    logCh --> Logger

Типы и каналы: что будет «событием»

Мы заведём задачу как структуру с названием. Канал addCh будет приносить новые задачи в event loop. Канал logCh будет принимать лог-сообщения (и мы специально сделаем лог неблокирующим, чтобы не зависнуть на медленном логгере).

package main

type Task struct {
	Title string
}

Каналы в main:

package main

func main() {
	addCh := make(chan Task)      // новые задачи
	logCh := make(chan string, 3) // небольшой буфер под лог
	_, _ = addCh, logCh
}

Обратите внимание: logCh буферизованный. Это маленькая «подушка безопасности», чтобы краткие всплески логов не блокировали систему.

Event loop: один for + один select

Теперь сердце: цикл, который живёт «пока программа работает» и выбирает, что делать, когда приходит событие.

package main

import "fmt"

func eventLoop(addCh <-chan Task, logCh chan<- string) {
	tasks := make([]Task, 0)

	for {
		select {
		case t := <-addCh:
			tasks = append(tasks, t)
			fmt.Println("added:", t.Title) // added: milk
			tryLog(logCh, "task added: "+t.Title)
		}
	}
}

Здесь есть одна важная идея владения данными: tasks находится внутри event loop. Только event loop его меняет. Значит, нам не нужен ни Mutex, ни сложные гарантии — мы просто «сериализуем» изменения через один поток обработки событий.

Неблокирующий лог: пишем, если можно

Лог — отличный кандидат на default. Часто логирование «приятно иметь», но если оно вдруг начинает тормозить бизнес-логику — вы быстро узнаете новые слова (обычно непечатные).

Сделаем tryLog:

package main

func tryLog(logCh chan<- string, msg string) {
	select {
	case logCh <- msg:
		// записали в лог
	default:
		// буфер полон — пропускаем
	}
}

Этот паттерн очень похож на классический приём «не блокируйся на отправке, если результат уже не нужен» из конкурентных шаблонов Go.

Да, мы «теряем» лог. Но мы теряем его по контракту: лучше потерять строчку «task added», чем заморозить всю программу.

Логгер: читает logCh и печатает

Сделаем отдельную горутину-логгер. Она будет читать канал и печатать, пока канал не закрыт. (Закрытие и завершение протокола мы подробно отточим в лекции про утечки горутин, а сегодня просто показываем базовую механику.)

package main

import "fmt"

func logger(logCh <-chan string) {
	for msg := range logCh {
		fmt.Println("LOG:", msg) // LOG: task added: milk
	}
}

5. Пустой канал и закрытый канал: учимся отличать

Когда вы делаете неблокирующее чтение через default, мозг быстро начинает думать: «Если не прочитал — значит там ничего нет». И это верно только наполовину. Там может быть «ничего нет пока», а может быть «ничего нет уже никогда», потому что канал закрыли.

Поэтому любой код, который читает из канала и которому важно корректно завершаться, должен в нужный момент использовать форму v, ok := <-ch.

Покажем мини-кусок: event loop читает addCh и умеет выходить, когда addCh закрыли.

package main

import "fmt"

func eventLoop(addCh <-chan Task) {
	for {
		select {
		case t, ok := <-addCh:
			if !ok {
				fmt.Println("addCh closed") // addCh closed
				return
			}
			fmt.Println("added:", t.Title)
		}
	}
}

Здесь важно почувствовать: «готовность case» и «полезность данных» — разные вещи. case t := <-addCh может выполниться и на закрытом канале, просто t будет zero value, а ok скажет правду.

6. default в цикле: риск busy loop

default кажется спасением: «О, я не блокируюсь, могу делать что-то ещё». И это правда. Но есть ловушка, в которую попадает почти каждый новичок: он пишет for { select { default: } } — и случайно создаёт маленький вечный двигатель, который превращает процессор в обогреватель.

Busy loop выглядит так: цикл крутится миллионы раз в секунду, потому что default выполняется мгновенно, и в теле цикла нет ничего, что реально ждёт события.

package main

func main() {
	for {
		select {
		default:
			// нет блокирующих case -> цикл "горит" на CPU
		}
	}
}

Почему это плохо? Потому что вы не «проверяете иногда», вы проверяете постоянно. Даже если рантайм Go умеет вытеснять горутины (и в целом стал лучше с годами), ваш код всё равно будет расходовать CPU без пользы, а на реальном сервере это превращается в «почему у нас счёт за железо как за космический корабль?». Кстати, в исторических релизах Go отдельно улучшали планировщик именно потому, что «занятые горутины» могут мешать остальным.

Как мыслить правильно: если вам нужно ждать события, в вашем select должен быть хотя бы один блокирующий case (например, чтение из канала). default добавляют не вместо ожидания, а чтобы сделать «быструю попытку», когда ожидание не обязательно. Если вам кажется, что default нужен «чтобы программа не зависла», обычно проблема в дизайне протокола каналов, а не в отсутствии default.

7. Типичные ошибки при работе с select

Ошибка №1: строить логику на порядке case.
Порядок веток в select визуально выглядит как «сверху важнее», и рука сама тянется сделать верхний case «приоритетным». Но если готовы несколько веток, выбор недетерминированный, и программа начнёт вести себя по-разному в зависимости от планировщика, нагрузки и фазы луны. Корректный подход — считать select честной лотереей и проектировать протокол так, чтобы любой выбор оставался правильным.

Ошибка №2: default используется как «чуть-чуть подождать».
default не ждёт ни миллисекунды. Он означает «не ждать вообще». Поэтому код «если нет данных — пойду в default и сделаю что-то полезное» в цикле часто превращается в busy loop. Если вы действительно хотите ждать данных — default не нужен. Если вы хотите иногда делать побочную работу — нужно так построить цикл, чтобы у него было реальное ожидание события, а default был редкой веткой, а не основным маршрутом.

Ошибка №3: неблокирующая отправка теряет данные молча, и никто не заметил.
select { case ch <- v: default: } — мощный инструмент, но он по сути говорит: «если не получилось отправить — ну и ладно». Это допустимо для логов, метрик, «первый результат выигрывает» или похожих сценариев, где потеря по контракту разрешена. Но если так отправлять реальные данные (например, задачи на обработку), вы получите загадочные «пропажи» и будете подозревать всё, кроме собственного default.

Ошибка №4: путать «канал пуст» и «канал закрыт».
При чтении в select без проверки ok вы можете случайно принять zero value за нормальные данные, особенно если тип канала — int или string. В результате закрытие канала превращается в поток пустых значений, и логика едет в кювет. Лечится дисциплиной: когда вам важно корректно завершиться, читайте t, ok := <-ch и уважайте ok == false.

Ошибка №5: select используют как «костыль от зависаний», вместо того чтобы договориться о протоколе.
Если вы боитесь, что receive или send могут зависнуть, это часто значит, что у участников нет ясного протокола: кто закрывает канал, кто обязан читать результаты, что означает завершение. select — не магия, он не отменяет необходимость в договорённостях. Он лишь делает выбор между событиями удобным. Настоящая надёжность появляется, когда вы заранее можете ответить: «какое событие гарантирует выход из каждой горутины».

1
Задача
Go SELF, 67 уровень, 0 лекция
Недоступна
Два платежа
Два платежа
1
Задача
Go SELF, 67 уровень, 0 лекция
Недоступна
Быстрый датчик
Быстрый датчик
1
Задача
Go SELF, 67 уровень, 0 лекция
Недоступна
Сумма до стопа
Сумма до стопа
1
Задача
Go SELF, 67 уровень, 0 лекция
Недоступна
Логи без тормозов
Логи без тормозов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ