Как стать автором
Обновить
576.42
OTUS
Цифровые навыки от ведущих экспертов

Golang: почему select {} без default может убить ваше приложение

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров1.1K

Привет, Хабр!

Сегодня хочу поговорить о проблеме, которую многие недооценивают в своих Go‑проектах. Речь пойдет о бессрочном select {}, который легко может привести к блокировке, утечке ресурсов и деградации производительности.

Что такое select {} без default

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

Простейший select {} без кейсов выглядит так:

select {}

Этот код создает вечную блокировку. Горутина, в которой выполняется select {}, будет приостановлена навсегда. Она не будет потреблять CPU, но останется жить в памяти до окончания процесса.

Теперь представьте, что таких горутин — не одна. Их может быть сотни тысяч. И теперь у нас не просто ждущий процесс, а полноценная утечка памяти и handle‑ов в рантайме.

Почему select {} без выхода — это проблема

Когда горутина заходит в вечный select {}, она попадает в состояние перманентного ожидания событий на каналах. Внутри рантайма Go это состояние не оптимизируется: такие горутины продолжают существовать в глобальной очереди планировщика runq или в локальных очередях P‑процессоров.

Даже если горутина не потребляет CPU напрямую, её существование оказывает системное давление:

Во‑первых, рантайм при каждой операции планирования обязан учитывать все активные горутины, пусть даже спящие. Это значит, что на каждый квант планирования будет происходить лишняя проверка состояния. Чем больше таких заблокированных горутин, тем медленнее работает весь планировщик.

Во‑вторых, каждая горутина — это отдельная структура g в памяти. Она занимает не только стек (пусть и маленький, порядка нескольких килобайт в начальной фазе), но и системные метаданные. Утечка тысяч таких горутин приводит к неконтролируемому росту памяти без явной нагрузки на процессор.

В‑третьих, наличие тысяч заблокированных горутин захламляет диагностику. Инструменты вроде runtime.Stack() и pprof начинают возвращать массивы стеков, в которых реальные активные горутины тонут в массе мертвых ожиданий.

Четвёртый уровень риска — это лимиты на количество потоков. Хотя Go старается использовать малое количество настоящих потоков (в силу своего модели M:N планирования), в реальности количество системных потоков всё равно связано с активными горутинами через блокировки на внешних вызовах. Если приложение работает с сетевыми запросами, файлами, БД — большое количество заблокированных горутин может привести к росту количества M‑воркеров, а это прямой путь к ограничению лимитов операционной системы (например, в Linux по дефолту soft limit на threads — 1024 на процесс).

Вечная горутина — это не просто процесс висит. Это постепенное разрушение системных инвариантов: планировщик тратит больше времени на впустую сканирование, сборщик мусора вынужден учитывать больше объектов, pprof становится бесполезен из‑за шума.

Допустим, есть простой обработчик входящих заданий:

func worker(ch chan int) {
    for {
        select {
        case v := <-ch:
            fmt.Println(v)
        }
    }
}

На первый взгляд код выглядит корректно: горутина слушает канал и что‑то делает при приходе данных. Проблема начинается, когда ch закрывается.

Когда канал закрывается, операция чтения из него <-ch не блокируется: она мгновенно возвращает нулевое значение типа (например, 0 для int) и флаг ok == false.

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

На уровне Go это выглядит как чередование активных и пассивных фаз исполнения:

  • сначала чтение из закрытого канала без блокировки,

  • затем возвращение в планировщик,

  • потом немедленный рескедюлинг обратно в исполнение,

  • и так далее.

В результате:

  • Планировщик начинает перегреваться.

  • Время отклика всех активных горутин падает.

  • Потребление CPU растет до потолка.

  • Система входит в состояние «жив, но бесполезен» — процессы не отвечают или отвечают с колоссальными задержками.

Правильный вариант кода, который защищён от этой гибели:

func worker(ch chan int) {
    for {
        select {
        case v, ok := <-ch:
            if !ok {
                return // Канал закрыт, корректно завершаем горутину
            }
            fmt.Println(v)
        }
    }
}

Проверка ok здесь — обязательная страховка.

Когда select {} абсолютно необходим и оправдан

Существует несколько валидных ситуаций для пустого select {}:

Блокировка до внешнего сигнала

Например, когда основная горутина ждет завершения через SIGTERM/SIGINT:

func main() {
    signalChan := make(chan os.Signal, 1)
    signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
    
    <-signalChan
    fmt.Println("Shutdown requested")
}

По сути select даже не нужен: мы прямо читаем из канала. Но если нужна множественная блокировка на несколько событий — select {} с кейсами оправдан.

Явная блокировка в тестах или заглушках

В некоторых тестах можно временно использовать select {} для ожидания, но обязательно в ограниченных контекстах через time.After:

select {
case <-time.After(1 * time.Second):
    // timeout
}

И опять же: избегаем вечного блока.

Как диагностировать вечный select

pprof

Профилируем запущенные горутины:

go tool pprof http://localhost:6060/debug/pprof/goroutine

Ищем стек вызовов, который застрял в select {} без активных операций на каналах.

runtime.Stack

Можно собрать стек всех горутин вручную:

buf := make([]byte, 1<<20)
runtime.Stack(buf, true)
fmt.Printf("%s\n", buf)

И найти характерные места застывания.

Концептуальная ревизия

Любой select должен либо:

  • ждать конкретные события на каналах

  • иметь default

  • иметь корректную обработку закрытия каналов

Как правильно писать безопасные select-блоки

Никогда не предполагаем, что канал всегда будет открыт. Например:

select {
case v, ok := <-ch:
    if !ok {
        return
    }
    handle(v)
}

Добавляем защиту от вечно висящих операций:

select {
case v := <-ch:
    handle(v)
case <-time.After(5 * time.Second):
    log.Println("timeout waiting for channel")
}

Если по архитектуре допустим вечный блок — документируем это в коде, прямо в комментарии:

// This select intentionally blocks forever waiting for shutdown signals
select {
case <-shutdownChan:
    cleanup()
}

Итог

Всегда проектируйте горутины так, чтобы у них был четкий сценарий завершения. Бесконечные select {} без выхода — это тихие утечки, которые почти неизбежно приведут к проблемам. А приходилось ли вам сталкиваться с утечками из‑за неправильного select? Делитесь в комментариях.


Хотите прокачать навыки работы с каналами и тестирования сервисов на Go?
Приглашаем на открытые уроки, где разберем ключевые практики и инструменты на реальных примерах:

Теги:
Хабы:
+9
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS