Каналы: владение и закрытие
Перед началом: модули 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.
goch := 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: у каждого источника свой канал, источник сам его закрывает; агрегатор читает все входы, а общий выход закрывает только после того, как все входы закрылись.
gofunc 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 и не заводя флаги:
gofor 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{} — классический семафор:
отправка берёт пропуск, приём возвращает.
gosem := 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дают явного владельца и независимое завершение. closevsdone-канал отмены:closeозначает «данных больше нет» и будит всех читателей; отдельныйdoneозначает «прекратите работу» и не мешает дочитывать данные.- Канал vs мьютекс: канал передаёт владение и упорядочивает события; мьютекс защищает состояние. Для счётчика и флага канал — лишняя церемония.
- Размер буфера: маленький чаще блокирует и раньше выявляет узкое место; большой копит память и маскирует перегрузку. Измеряйте длину очереди, а не угадывайте.
💻 Практика
- Воспроизведите панику. Два отправителя пишут в один канал,
каждый закрывает его после трёх значений. Запустите
go run: получитеsend on closed channelилиclose of closed channelи стек. Виноват не «медленный» отправитель, а нарушенное владение. Почините: канал на источник плюсmerge. - Воспроизведите зависание. Уберите
closeиз предыдущего примера: паники нет, ноfor range outне завершается. Пошлите процессуSIGQUIT(Ctrl+\) и снимите дамп горутин: найдите заблокированные наchan send, затем добавьте агрегатор сWaitGroup+close. - Проверьте направленные типы. Сделайте конструктор,
возвращающий
<-chan int, и попробуйте отправить в него значение изmain. Убедитесь, что код не компилируется, и объясните сообщение компилятора. Затем вернитеchan intи покажите, чем это опасно для владения. - Запустите сниппет модуля. Ожидаемый вывод:
fan-in собрал событий: 8, затемвзял: A-1ивзял: A-2. Первая строка — сумма трёх источников после закрытия всех входов; две следующие — очередьaпри выключенномb. Запустите с-race: отчётов и паник быть не должно.
🧩 Quiz
select присваивать каналу nil после ok == false?struct{}?close(ch) для ожидающих получателей?✅ Итоги модуля
Студент должен:
- Объяснять разницу очереди и рукопожатия у канала
- Формулировать правило владения и закрытия
- Строить fan-in с
WaitGroupи закрытием агрегатором - Использовать направленные типы в сигнатурах
- Выключать ветки
selectчерезnil-канал - Применять буфер как семафор и не закрывать его
- Не закрывать канал со стороны получателя
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 4
Каналы задают направления, но воркеру обычно нужно слушать
несколько источников сразу: задачи, heartbeat, отмену и дедлайн.
Как select выбирает ветку, почему time.Tick течёт и когда
default превращает цикл в busy-loop — в следующем модуле.