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

Кто владеет каналом

Канал закрывает отправитель, не получатель. Получатель не знает, появится ли следующее значение, и закрытие во время записи другой горутины вызовет panic.

jobs := make(chan Job)
go func() {
    defer close(jobs)
    for _, job := range loadJobs() {
        jobs <- job
    }
}()

Если отправителей несколько, канал закрывают после завершения всех через WaitGroup. Никто не закрывает канал «на всякий случай» при отмене: сначала прекращают запись, затем закрывают.

Select для ожидания и отмены

select ждёт несколько операций одновременно:

select {
case result := <-results:
    return result, nil
case err := <-errs:
    return nil, err
case <-ctx.Done():
    return nil, ctx.Err()
}

Если канал закрыт, получение возвращает нулевое значение и ok == false. Без этой проверки нулевой результат можно принять за настоящий.

Таймер на каждой итерации создают заново или переиспользуют через NewTimer с корректной остановкой. До Go 1.23 забытые таймеры действительно копились; теперь сборщик освобождает потерянные ссылки на них. Но создание таймера на каждом круге всё равно остаётся лишней работой: в долгом или горячем цикле один таймер останавливают и перезапускают.

Fan-out и fan-in

Fan-out запускает работу параллельно, fan-in собирает результаты в один канал:

jobs воркеры 1, 2, 3 results consume

Fan-out распределяет задачи между воркерами, fan-in собирает их ответы в один канал результатов.

results := make(chan Result, len(urls))
for _, url := range urls {
    go func(url string) {
        results <- fetch(url)
    }(url)
}

for range urls {
    result := <-results
    consume(result)
}

Буфер len(urls) позволяет всем отправителям завершиться, даже если читатель выйдет по ошибке. Без буфера оставшиеся отправители встанут навсегда.

Буфер не отменяет работу. Для отмены нужен context, а внешняя операция должна уметь его принимать.

Направление и владение данными

Канал хорош для передачи владения объектом. После отправки предыдущий владелец больше не меняет объект. Иначе синхронизация есть, а гонка на содержимое остаётся.

Для разделяемого состояния канал не заменяет мьютекс. Если нужен доступ по чтению и записи из нескольких мест, состояние защищают отдельно или отдают одной горутине-владельцу.

Завершение

Рабочая схема завершения:

  1. прекратить создание новых задач;
  2. отменить текущие через context;
  3. дождаться отправителей через WaitGroup;
  4. закрыть канал результатов;
  5. дочитать результаты и вернуть итог.

Проверка на утечки идёт через goleak, а гонки через go test -race. Паника send on closed channel обычно означает, что порядок закрытия нарушен.

Коротко

  • Канал закрывает только отправитель после завершения всех записей.
  • select выбирает результат, ошибку или отмену, но не отменяет внешнюю работу сам.
  • Буфер помогает отправителям завершиться, а не останавливает их.
  • После передачи объекта прежний владелец его не меняет.
  • Завершение - это протокол, а не один close.