сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Горутины и каналы

Ловушки каналов в Go

Грабли каналов

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

Стержень: паникуют операции с закрытым каналом, а молча висят операции с nil-каналом и с забытым закрытием.

// Формулировки: «что будет при отправке в закрытый канал?», «что делает приём из nil-канала?», «почему горутина висит?»

Громкая половина: паники

Три операции паникуют, и тексты у них разные - по ним удобно опознавать проблему в логе. Отправка в закрытый канал: send on closed channel. Повторное закрытие: close of closed channel. Закрытие nil-канала: close of nil channel. Все три проверены и все три - обычные паники, которые ловит recover.

Заметь асимметрию: приём из закрытого канала абсолютно безопасен и просто отдаёт нулевое значение, а отправка в него паникует. Логика в том, что закрытие означает «данных больше не будет», и читатели должны об этом узнать штатно, а вот писатель, который пишет после закрытия, явно ошибся в собственной логике.

Отсюда правило про право закрытия. Закрывает отправитель, потому что только он знает, что поток кончился. Отправителей несколько - не закрывает никто из них, закрывает отдельная горутина после WaitGroup.

// Ловить эти паники через recover - плохое лечение. Они означают ошибку в устройстве программы, а не внешний сбой, и перехват просто спрячет её до следующего раза.

ch := make(chan int, 1)
close(ch)

ch <- 1        // panic: send on closed channel
close(ch)      // panic: close of closed channel

var nilch chan int
close(nilch)   // panic: close of nil channel

Тихая половина: вечная блокировка

Операции с nil-каналом не паникуют - они блокируются навсегда. И приём, и отправка. Ошибка тут обычная: объявили var ch chan int вместо make, и всё внешне работает, просто ничего не происходит.

Иногда рантайм спасает. Если заблокированы вообще все горутины, он замечает это и печатает fatal error: all goroutines are asleep - deadlock! вместе с состоянием каждой горутины - в нашем случае chan receive (nil chan). Очень удобно.

Но это работает только когда спят все. В реальном сервисе всегда есть работающая горутина - HTTP-сервер, планировщик, сборщик метрик - и тогда рантайм молчит. Заблокированная горутина просто остаётся висеть, держит свой стек и всё, на что ссылается.

// Тот же тихий эффект даёт незакрытый канал: range на другой стороне ждёт закрытия, которого не будет. И небуферизованный канал без готового получателя: отправитель блокируется на первой же строчке, а его никто не ждёт.

deadlock-детектор
рантайм роняет процесс, только если СПЯТ ВСЕ горутины; частичную блокировку он не видит

Как не наступать

Первое: право закрытия принадлежит одной стороне и записано в сигнатуре. Функция возвращает <-chan T - значит она же и закроет. Направленные типы каналов делают это правило проверяемым компилятором, а не устной договорённостью.

Второе: у каждой блокирующей операции должен быть выход. Отправка и приём в долгоживущем коде идут через select с веткой ctx.Done() либо с таймаутом. Тогда блокировка «навсегда» превращается в блокировку «до отмены».

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

// И проверка, которая ловит остальное: гонки ищутся сборкой с -race, а зависшие горутины - дампом стеков. Оба инструмента штатные и включаются одной строкой, о них подробнее в теме про утечки.

Как отвечать: «Что произойдёт при отправке в закрытый канал и при работе с nil-каналом?»

Это два разных класса проблем, и разница принципиальна. Отправка в закрытый канал паникует с текстом send on closed channel - это обычная паника, её видно в логе и её ловит recover, хотя ловить тут нечего: она означает ошибку в устройстве программы. Туда же относятся повторное закрытие и закрытие nil-канала, тексты у них свои. При этом приём из закрытого канала полностью безопасен - возвращает нулевое значение и ok равное false. Асимметрия осмысленная: закрытие означает «данных больше не будет», читатели узнают об этом штатно, а писатель после закрытия явно ошибся. С nil-каналом всё наоборот и хуже: и приём, и отправка блокируются навсегда, молча. Если случайно заблокировались все горутины, рантайм напишет fatal error про deadlock и даже покажет состояние chan receive nil chan. Но если хоть одна горутина работает - а в сервисе всегда работает - никакого сообщения не будет, горутина просто повиснет вместе со всей памятью, на которую ссылается. Поэтому в долгоживущем коде я не пишу голых блокирующих операций: всё через select с ctx.Done() или таймаутом.

Два класса разведены по симптому и по опасности, названы точные тексты ошибок, объяснена асимметрия закрытия и дан предел работы deadlock-детектора - последнее знают немногие.

На чём валят

  • Отправляют в закрытый канал: паника send on closed channel и упавший процесс.
  • Закрывают канал дважды или закрывают nil-канал - тоже паника.
  • Забывают make и получают nil-канал: всё висит молча, без единой ошибки.
  • Рассчитывают, что рантайм поймает любую вечную блокировку - он видит только сон всех горутин.
  • Пишут голые блокирующие отправки в долгоживущем коде без ветки отмены.
  • Ловят паники каналов через recover вместо того, чтобы починить право закрытия.

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

Пять вопросов из банка по этой подтеме. Всего их 5, остальные разбираются в тренажёре.

  1. #go_chan_pitfalls1 / 5
    Горутина выполняет ch <- v, но канал к этому моменту уже закрыт. Что будет?
    A)Значение молча отбрасывается
    B)v кладётся в буфер и прочитается после переоткрытия канала
    C)Паника рантайма: send on closed channel
    D)Отправка блокируется до повторного открытия канала
    показать ответ и разбор
    +C)Паника рантайма: send on closed channel

    // разбор: Отправка в закрытый канал — паника рантайма «send on closed channel». Каналы не переоткрываются: close необратим. Отсюда классическая гонка — писатель шлёт после того, как канал закрыл кто-то другой. Лечится координацией: закрывает один владелец, а писатели узнают об остановке через отдельный сигнал (context/done) и прекращают отправку до close.

  2. #go_chan_pitfalls2 / 5
    close(ch) вызвали дважды для одного и того же канала. Результат?
    A)Второй close — no-op, канал ведь уже закрыт
    B)Паника рантайма: close of closed channel
    C)Канал сбрасывается и снова готов к записи
    D)Ошибка компиляции: повторный close виден статически
    показать ответ и разбор
    +B)Паника рантайма: close of closed channel

    // разбор: Повторный close закрытого канала паникует «close of closed channel»; close(nil) тоже паникует. Компилятор это не ловит — только рантайм. Поэтому за жизненный цикл канала отвечает один владелец. Типичные защиты — sync.Once вокруг close или единый писатель-координатор, который гарантирует ровно одно закрытие на канал.

  3. #go_chan_pitfalls3 / 5
    var ch chan int — канал объявлен, но не создан через make. Что делает <-ch?
    A)Сразу возвращает нулевое значение — канал ведь пустой
    B)Паникует: nil-канал не инициализирован
    C)Блокирует горутину навсегда
    D)Ошибка компиляции: чтение из неинициализированного канала
    показать ответ и разбор
    +C)Блокирует горутину навсегда

    // разбор: Нулевое значение канала — nil. Приём и отправка на nil-канале блокируются навсегда (а вот close(nil) паникует). Это НЕ то же, что закрытый канал: тот отдаёт zero/false и не блокирует. Отсюда тихий баг — забыли make, и горутина висит вечно, часто как источник утечки. Полезная сторона того же свойства — «выключение» ветки select через nil.

  4. #go_chan_pitfalls4 / 5
    Потребитель читает for v := range ch, а отправитель канал не закрывает. Чем это кончится?
    A)Цикл завершится, когда отправитель перестанет слать значения
    B)Цикл будет ждать вечно, и при отсутствии других горутин рантайм сообщит о взаимоблокировке
    C)Цикл получит нулевые значения и продолжит крутиться вхолостую
    D)Рантайм закроет канал сам, когда отправитель завершится
    показать ответ и разбор
    +B)Цикл будет ждать вечно, и при отсутствии других горутин рантайм сообщит о взаимоблокировке

    // разбор: range заканчивается только на закрытии канала — «отправитель молчит» и «канал закрыт» для потребителя неразличимы. Он остаётся заблокированным, и если работать больше некому, рантайм печатает fatal error: all goroutines are asleep - deadlock. Отсюда правило: закрывает тот, кто отправляет, и делает это ровно тогда, когда отправлять больше нечего.

  5. #go_chan_pitfalls5 / 5
    Пул воркеров читает задачи из общего канала. Как корректно завершить работу?
    A)Закрыть канал задач и дождаться воркеров через WaitGroup
    B)Отправить в канал столько нулевых значений, сколько воркеров
    C)Вызвать runtime.Goexit из управляющей горутины
    D)Закрыть канал результатов, чтобы воркеры увидели это и вышли
    показать ответ и разбор
    +A)Закрыть канал задач и дождаться воркеров через WaitGroup

    // разбор: Закрытие входного канала — сигнал всем воркерам сразу: их range завершится, каждый выйдет сам. Остаётся дождаться, пока они дообработают взятое, — для этого WaitGroup, и только после Wait можно закрывать канал результатов. Нулевые значения-стражи хрупки: их число должно совпасть с числом воркеров, а оно меняется вместе с масштабированием пула.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.