Каналы: владение и закрытие

Перед началом: модули 1–2 (GMP, happens-before, гонки). Инструменты: Go 1.22+, go test -race, go vet. Трудоёмкость: ~4 ч (теория 2 ч · практика 1.5 ч · квиз 0.5 ч).

🔥 Инцидент: fan-in логов уронил агрегатор паникой send on closed

Сервис собирал логи из трёх источников — HTTP, базы и кэша — в один канал events, из которого агрегатор писал их в хранилище. Каждый источник работал в своей горутине и при завершении закрывал общий канал: «я закончил — закрываю». Первый же источник, дочитавший файл, вызвал close(events), а два других в этот момент отправили событие и упали с panic: send on closed channel. Процесс умер вместе с недописанными логами.

Во второй итерации закрытие убрали совсем: паники прекратились, но агрегатор с for e := range events перестал завершаться. Сервис не проходил graceful shutdown, а горутины источников висели на отправке в канал, который никто больше не читал.

Обе аварии — про одно: у канала один владелец, и только он вправе закрывать. Канал — объект передачи владения, не переменная.

📖 Теория

3.1 Канал: очередь, рукопожатие и широковещание

Канал — типизированная FIFO-очередь с блокировками. Значения копируются при отправке и приёме; отправитель и получатель синхронизируются через happens-before (модуль 2).

  • Небуферизованный канал — рукопожатие: send блокируется, пока получатель не готов принять, и наоборот. Это не «медленно», а точка синхронизации: отправитель знает, что значение принято.
  • Буферизованный — очередь на cap(ch) значений: send блокируется только при полном буфере, receive — при пустом.
  • close(ch) — широковещание «значений больше не будет»: все ждущие получатели просыпаются, получают нулевое значение и ok == false. Отправка в закрытый канал — паника, повторное закрытие — тоже.
  • nil-канал блокирует и отправку, и приём навсегда. Это не ошибка, а инструмент (см. 3.4).
  • Направление задаётся типом: chan<- T — только писать, <-chan T — только читать. Компилятор проверяет контракт.

Закрытие не удаляет канал: он остаётся читаемым — сначала выходят буферизованные значения, затем нули с ok == false.

go
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
for v := range ch { // 1, 2 и выход
    fmt.Println(v)
}

3.2 Владение: закрывает отправитель

Базовое правило звучит так: закрывает тот, кто пишет. Точнее — тот, кто может доказать, что других писателей не осталось. Получатель закрывать не должен: он не знает, закончили ли отправители, и рискует уронить их паникой.

Направленные типы делают владение видимым в сигнатуре:

go
// Конструктор возвращает только приём: писать снаружи нельзя.
func counter(n int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out) // владелец и писатель — эта горутина
        for i := 0; i < n; i++ {
            out <- i
        }
    }()
    return out
}

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

Запомните три запрета:

  • Не закрывайте канал со стороны получателя.
  • Не закрывайте канал, если писателей больше одного, пока не дождались всех.
  • Не закрывайте канал «на всякий случай»: закрытие — сигнал конца данных, а не уборка мусора.

3.3 Fan-in: агрегатор закрывает после WaitGroup

Правильная схема fan-in: у каждого источника свой канал, источник сам его закрывает; агрегатор читает все входы, а общий выход закрывает только после того, как все входы закрылись.

go
func merge(ins ...<-chan string) <-chan string {
    out := make(chan string)
    var wg sync.WaitGroup
    wg.Add(len(ins))
    for _, in := range ins {
        go func(in <-chan string) {
            defer wg.Done()
            for v := range in {
                out <- v
            }
        }(in)
    }
    go func() {
        wg.Wait() // все входы закрыты и дочитаны
        close(out) // теперь выход можно закрыть
    }()
    return out
}

Почему это работает:

  • range in завершается, когда вход закрыт и опустошён; wg.Done вызывается после этого.
  • wg.Wait возвращается, когда все копирующие горутины закончили, — значит, отправок в out больше не будет.
  • close(out) будит потребителя, и его range тоже завершается.

wg.Wait и close живут в отдельной горутине, потому что merge должен вернуть канал немедленно. Если склеить это в одну функцию без горутины, получится deadlock: потребитель ещё не начал читать, а merge уже ждёт отправок.

В сниппете модуля generator — владелец своего канала, а merge — агрегатор: он не закрывает входы, но закрывает свой выход после wg.Wait. Потребитель просто считает события: 3 + 3 + 2 = 8.

3.4 nil-канал как выключатель ветки select

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

go
for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok {
            a = nil // источник A исчерпан, ветка выключена
            continue
        }
        use(v)
    case v, ok := <-b:
        if !ok {
            b = nil
            continue
        }
        use(v)
    }
}

Тот же приём закрывает проблему «закрытый канал всегда готов»: закрытый канал немедленно отдаёт нули, и select начнёт крутиться вхолостую, если ветку не обнулить. Поэтому после ok == false канал присваивают nil и продолжают ждать остальные источники.

В сниппете b = nil отключает источник B: из a уже лежат два значения, и цикл дважды печатает A-1, A-2. Ветка case v := <-b становится мёртвой, хотя код остался на месте.

3.5 Буфер как семафор: пропуска, а не очередь

Буферизованный канал из struct{} — классический семафор: отправка берёт пропуск, приём возвращает.

go
sem := make(chan struct{}, 4) // не больше 4 работ одновременно
for _, job := range jobs {
    sem <- struct{}{} // взять пропуск (блокируется, если занято)
    go func(job Job) {
        defer func() { <-sem }() // вернуть пропуск
        process(job)
    }(job)
}

Здесь буфер — не «очередь на ускорение», а счётчик занятых слотов: размер меняет лимит параллелизма, а не пропускную способность. Канал на один слот — неблокирующий мьютекс: попытка взять пропуск через select с default сразу получает отказ, если слот занят (модуль 4).

Ловушка: закрывать такой канал нельзя. После close приём немедленно возвращает нули, и <-sem перестаёт ограничивать — лимит исчезает, а горутины разгоняются. Семафор живёт столько же, сколько его пользователи; завершение координируйте WaitGroup или контекстом.

3.6 Компромиссы

  • Без буфера vs с буфером: небуферизованный честно показывает готовность получателя и синхронизирует этапы; буфер сглаживает пики, но прячет backpressure и добавляет задержку до обработки.
  • Один общий канал vs канал на источник: общий проще, но владение размывается, и закрывать его некому; каналы на источник с merge дают явного владельца и независимое завершение.
  • close vs done-канал отмены: close означает «данных больше нет» и будит всех читателей; отдельный done означает «прекратите работу» и не мешает дочитывать данные.
  • Канал vs мьютекс: канал передаёт владение и упорядочивает события; мьютекс защищает состояние. Для счётчика и флага канал — лишняя церемония.
  • Размер буфера: маленький чаще блокирует и раньше выявляет узкое место; большой копит память и маскирует перегрузку. Измеряйте длину очереди, а не угадывайте.

💻 Практика

  1. Воспроизведите панику. Два отправителя пишут в один канал, каждый закрывает его после трёх значений. Запустите go run: получите send on closed channel или close of closed channel и стек. Виноват не «медленный» отправитель, а нарушенное владение. Почините: канал на источник плюс merge.
  2. Воспроизведите зависание. Уберите close из предыдущего примера: паники нет, но for range out не завершается. Пошлите процессу SIGQUIT (Ctrl+\) и снимите дамп горутин: найдите заблокированные на chan send, затем добавьте агрегатор с WaitGroup + close.
  3. Проверьте направленные типы. Сделайте конструктор, возвращающий <-chan int, и попробуйте отправить в него значение из main. Убедитесь, что код не компилируется, и объясните сообщение компилятора. Затем верните chan int и покажите, чем это опасно для владения.
  4. Запустите сниппет модуля. Ожидаемый вывод: fan-in собрал событий: 8, затем взял: A-1 и взял: A-2. Первая строка — сумма трёх источников после закрытия всех входов; две следующие — очередь a при выключенном b. Запустите с -race: отчётов и паник быть не должно.

🧩 Quiz

1. Кто вправе закрыть канал?
2. Зачем в select присваивать каналу nil после ok == false?
3. Что произойдёт при отправке в закрытый канал?
4. Что ограничивает буферизованный канал из struct{}?
5. Что делает close(ch) для ожидающих получателей?

✅ Итоги модуля

Студент должен:

  • Объяснять разницу очереди и рукопожатия у канала
  • Формулировать правило владения и закрытия
  • Строить fan-in с WaitGroup и закрытием агрегатором
  • Использовать направленные типы в сигнатурах
  • Выключать ветки select через nil-канал
  • Применять буфер как семафор и не закрывать его
  • Не закрывать канал со стороны получателя
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 4

Каналы задают направления, но воркеру обычно нужно слушать несколько источников сразу: задачи, heartbeat, отмену и дедлайн. Как select выбирает ветку, почему time.Tick течёт и когда default превращает цикл в busy-loop — в следующем модуле.