GraphLMS

Go
0/32 решеноНачать
Глава 04 · Основы~11 мин чтения

select

О чём глава

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

Разберём четыре вещи, которые делают select по-настоящему мощным: мультиплексирование нескольких каналов, default для неблокирующих операций, таймауты и приём с nil-каналами, который позволяет на ходу «выключать» отдельные ветки. Аудитория уже знает каналы и горутины — здесь мы смотрим, как из них собирается управляющая логика.

Мультиплексирование каналов

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

select {
case v := <-in:
    process(v)
case out <- result:
    // отправили
case <-done:
    return
}

Семантику стоит запомнить дословно — на ней держатся все остальные приёмы:

  • Готова ровно одна ветка — выбирается она.
  • Готовы несколько — выбирается случайная, равновероятно. Это не «сверху вниз»: рандомизация специально защищает от ситуации, когда один бойкий канал вечно перебивает остальные (голодание).
  • Не готова ни одна, но есть default — выполняется default.
  • Не готова ни одна и default нет — select блокируется, пока какая-нибудь ветка не станет готовой.

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

package main
 
import (
	"fmt"
	"sync"
)
 
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)
	var wg sync.WaitGroup
	sum := 0
	wg.Add(1)
	go func() {
		defer wg.Done()
		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
			}
		}
	}()
	wg.Wait()
	fmt.Println("sum =", sum)
}

Порядок, в котором приходят значения из a и b, не детерминирован — но сумма всегда одна и та же. На этом и строят корректные select-программы: не полагаемся на порядок, полагаемся на инвариант.

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

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

package main
 
import "fmt"
 
func main() {
	ch := make(chan int, 1)
	ch <- 42
 
	// Первое чтение успевает — в буфере есть значение.
	select {
	case v := <-ch:
		fmt.Println("получили:", v)
	default:
		fmt.Println("пусто")
	}
 
	// Второе — канал уже пуст, не ждём, идём в default.
	select {
	case v := <-ch:
		fmt.Println("получили:", v)
	default:
		fmt.Println("пусто")
	}
}

Так делают неблокирующую отправку (case ch <- v: плюс default — «положим, если влезает»), опрос состояния без зависания, «сбросить значение, если успеем».

Но есть ловушка: default в цикле без паузы — это busy loop, который крутится вхолостую и жжёт CPU на 100%. default означает «не ждать ни секунды», поэтому такой цикл проверяет канал миллионы раз в секунду. Если вам нужно «ждать, но с реакцией на отмену» — это не работа для default. Вместо него добавьте отдельную ветку <-ctx.Done(): тогда select спокойно паркуется на двух каналах сразу и просыпается на первом готовом, не нагружая процессор.

Таймауты

select вместе с time.After навешивает таймаут на любую канальную операцию. time.After(d) возвращает канал, в который через d придёт значение — если за это время основная ветка не сработала, выбирается ветка таймаута:

package main
 
import (
	"fmt"
	"time"
)
 
func main() {
	ch := make(chan int)
	go func() {
		time.Sleep(30 * time.Millisecond)
		ch <- 7 // слишком поздно
	}()
 
	select {
	case v := <-ch:
		fmt.Println("значение:", v)
	case <-time.After(10 * time.Millisecond):
		fmt.Println("таймаут")
	}
}

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

Важный нюанс: time.After создаёт таймер, который живёт до самого срабатывания, даже если основная ветка уже выиграла гонку. В горячем цикле, где select крутится тысячи раз, это копит таймеры и нагружает GC. Лекарства два: переиспользовать time.NewTimer с Reset, либо — что почти всегда правильнее — передавать дедлайн через context и слушать ctx.Done() вместо time.After. Контекст создаётся один раз на всю операцию и отменяется ровно один раз.

nil-каналы: выключаем ветки на ходу

Это самый недооценённый приём select. Вспомним из главы про каналы: любая операция на nil-канале блокируется навсегда. В обычном коде это источник дедлоков, но внутри select это превращается в выключатель: ветка с nil-каналом просто никогда не выберется. Присвоив переменную канала в nil, вы динамически гасите ветку, не трогая структуру select.

Зачем это нужно? Классическая проблема: когда канал закрывается, <-ch перестаёт блокироваться и начинает мгновенно отдавать нулевые значения с ok == false. Если такая ветка стоит в цикле, select будет крутиться вхолостую на закрытом канале. Зануление ветки решает это чисто:

package main
 
import "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; continue } // a закрыт — гасим ветку
				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)
}

Когда a закрывается, мы зануляем переменную a, и её ветка молча выпадает из выбора — select дальше работает только с b. Цикл for a != nil || b != nil завершится, когда оба источника станут nil. Без этого трюка пришлось бы тащить булевы флаги и вкладывать select'ы друг в друга. Сравните с примером мультиплексирования выше: там мы считали живые источники счётчиком, здесь — проверяем сами переменные на nil. Оба способа рабочие; nil-вариант не требует отдельного счётчика.

select без веток

select {}   // блокируется навсегда

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

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

Расчёт на порядок веток. select не приоритезирует. Нельзя рассчитывать, что <-done проверится «раньше» <-data — при готовности обеих выбор случаен. Если приоритет действительно нужен, его делают явно через вложенный select: сначала неблокирующая проверка приоритетной ветки с default, и только потом — общее ожидание.

select {
case <-done:
    return
default:
    select {
    case <-done:
        return
    case v := <-data:
        use(v)
    }
}

Забыли ветку отмены. select, который слушает только данные, виснет навсегда, если данные перестали приходить, а отменить операцию нечем. Почти в любом долгоживущем select нужна ветка <-ctx.Done() — иначе горутина становится неубиваемой.

time.After в горячем цикле. Аллокация нового таймера на каждой итерации нагружает GC, и каждый таймер живёт до срабатывания, даже если ветка уже проиграла. В циклах — time.NewTimer + Reset или дедлайн через context.

busy loop на default. default в цикле без ожидания крутит CPU вхолостую. Если задача — «ждать события», ветка default тут лишняя; ждите на самих каналах.

Чего в браузере не покажешь честно

Учебный playground исполняет код через yaegi в одном потоке: горутины кооперативно переключаются, но настоящего параллелизма нет, а race-детектор не работает. Поэтому примеры про гонки данных и параллельное исполнение здесь даём статичным блоком — запускать их в браузере было бы обманом.

Вот типичная гонка вокруг select: общий счётчик без синхронизации. На реальной машине с go run -race детектор немедленно поймает конфликтный доступ:

// НЕ запускать в playground — настоящей гонки тут не видно.
// На реальной машине: go run -race main.go поймает data race.
func collect(in <-chan int, done <-chan struct{}) {
    total := 0 // читается и пишется из нескольких горутин без защиты
    for i := 0; i < 4; i++ {
        go func() {
            for {
                select {
                case v := <-in:
                    total += v // ← гонка: незащищённая запись
                case <-done:
                    return
                }
            }
        }()
    }
}

Исправление — не делить total между горутинами, а собирать частичные суммы и складывать их в конце, либо защитить доступ через sync.Mutex/sync/atomic. Но сам факт гонки в браузере вы не «пощупаете» — это особенность среды, держите её в голове.

Мысленная модель

select — это «жди первого из событий». Представляйте, что горутина паркуется сразу на нескольких каналах и просыпается на том, который сработал первым. У вас два рычага управления: default (не ждать вовсе) и nil-каналы (выключать отдельные события, не меняя код select). Этих двух рычагов хватает почти на всю управляющую логику конкурентных программ.

Что дальше

  • context — ветка <-ctx.Done() в select делает любую операцию отменяемой; почти всегда лучше, чем time.After.
  • Каналы — откуда берётся поведение nil-каналов и закрытия, на котором держатся приёмы выше.
  • Паттерны конкурентности — fan-in, or-channel и слияние потоков целиком построены на select.

Как это связано с задачами тренажёра

select — половина топика 1 (1–4): задача 01 «Or-Channel» строит дерево select'ов, чтобы поймать первый сигнал и не утечь горутинами. В топике 3 (9–12) через select собираются fan-in и слияние потоков (паттерн с nil-каналами выше — прямо оттуда). В топиках 4 (13–15) и 7 (23–32) связка select с <-ctx.Done() — стандартный способ сделать операцию отменяемой (см. context).