1. Закрытие канала: close(ch)
Когда вы пишете однопоточную программу, “конец данных” часто понятен сам: например, вы дошли до конца массива, или цикл for i := 0; i < n; i++ завершился. Но в конкурентном коде данные могут приходить “из будущего”: сейчас их нет, но через 5 миллисекунд появятся. И вот тут начинается философия уровня «ожидание как стиль жизни» — если не договориться, когда данные точно закончились, получатель может ждать бесконечно.
Представьте, что у вас есть горутина-производитель, которая отправляет в канал результаты работы. Другая горутина-потребитель читает эти результаты и печатает их. Всё хорошо, пока результаты есть. А когда они кончились — потребителю надо как-то понять, что это не “временная тишина”, а “конец фильма, расходимся”.
Плохая (но очень распространённая у новичков) идея — отправить “специальное значение”, например 0, как признак конца. Это ломается мгновенно, как только 0 становится легальным значением данных. Поэтому у каналов есть встроенный официальный механизм: закрытие канала.
Что делает close (и чего не делает)
Важно начать с честности: close(ch) — это не “уничтожить канал” и не “убить горутину”. В Go закрытие канала — это сигнал протокола, означающий: «В этот канал больше не будут отправляться значения».
Формулировка в духе спецификации звучит примерно так: встроенная функция close(ch) для канала ch “records that no more values will be sent on the channel” (фиксирует, что больше значений отправляться не будет).
Что не делает close:
- Он не очищает буфер “магией”. Если в буферизированном канале уже лежат значения, они там лежат и будут читаться как обычно.
- Он не “останавливает” горутины. Если у вас горутина в панике (в человеческом смысле) бесконечно крутится в цикле, close её не успокоит.
- Он не является “командой завершиться” для получателя. Получатель сам решает, что делать, увидев факт закрытия: закончить цикл, дописать отчёт, закрыть файлы, сварить кофе.
Мини-пример, просто чтобы увидеть синтаксис и то, что чтение после close вообще возможно:
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // 0 false
}
Здесь v стал 0, а ok стал false. Это и есть главный инструмент, который мы сегодня освоим.
Кто должен закрывать канал
С закрытием канала связано одно правило, которое экономит нервные клетки лучше, чем отпуск: канал закрывает отправитель (или тот, кто владеет отправкой). Причина простая: только отправитель точно знает, что он больше ничего не отправит. Получатель этого знать не может — он видит лишь “сейчас пусто”.
Если закрывает получатель, легко получить гонку: отправитель ещё пытается отправить значение, а канал уже закрыт — и программа падает с panic. В мире Go это не “мелкая ошибка”, а прямой стоп-сигнал: «вы нарушили протокол».
Чтобы прочувствовать опасность, можно посмотреть на такой антипример (код специально “плохой”; не запускайте его как эталон):
package main
func main() {
ch := make(chan int)
go func() {
ch <- 1 // может паникнуть, если канал закроют раньше
}()
close(ch) // закрывает получатель (main), это плохая идея
}
Это как если бы вы закрыли дверь лифта, пока люди ещё пытаются войти. Формально действие понятное, но дальше будет больно и громко.
3. Чтение из канала после закрытия
Четыре состояния при чтении
Теперь самое полезное: давайте разложим поведение чтения на понятные случаи. Канал (для получателя) можно мысленно видеть в двух осях: “закрыт/не закрыт” и “есть значения/нет значений”. Получается четыре ситуации.
Ниже таблица — её стоит перечитать пару раз, потому что это фундамент.
| Состояние канала | Что делает v := <-ch | Что делает v, ok := <-ch |
|---|---|---|
| Канал не закрыт, значений нет (пусто сейчас) | Блокируется (ждёт отправки) | Блокируется (тоже ждёт) |
| Канал не закрыт, значения есть | Возвращает очередное значение | Возвращает значение и ok=true |
| Канал закрыт, значения есть (например, в буфере) | Возвращает оставшиеся значения | Возвращает значение и ok=true |
| Канал закрыт, значений нет | Возвращает zero value сразу | Возвращает zero value и ok=false |
Ключевая мысль: закрытие канала делает чтение безопасным и “завершаемым”. Получатель больше не рискует зависнуть навсегда — он может увидеть ok=false и выйти.
Двухзначное чтение: v, ok := <-ch
Самое важное умение дня — читать из канала в “двухзначной форме”.
Форма такая:
v, ok := <-ch
Смысл:
- ok == true означает: вы получили настоящее значение, всё нормально.
- ok == false означает: канал закрыт и пуст, данных больше не будет.
Почему возвращается именно “zero value”? Потому что Go обязан вернуть значение типа T (ведь v имеет тип элемента канала). Для int это 0, для string это "", для bool это false, для *Task это nil и так далее. Поэтому без ok вы не отличите “настоящий ноль” от “конца потока”.
Мини-таблица zero values (просто чтобы снять удивление):
| Тип элемента канала | Zero value |
|---|---|
|
|
|
|
|
|
|
|
|
“структура из zero values полей” |
Вот пример “плохого протокола” с использованием 0 как сигнала конца. Он плох именно потому, что 0 может быть легальным значением:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 0 // легальное значение
ch <- 5 // ещё одно значение
// “Сигнал конца = 0” ломается прямо здесь:
v := <-ch
if v == 0 {
fmt.Println("конец") // конец (хотя данные ещё есть!)
return
}
fmt.Println(v)
}
Правильный подход — не изобретать “магические числа”, а закрывать канал, а на чтении использовать ok.
Цикл чтения “до закрытия” без range
Есть очень удобная форма for range ch, но мы её оставим в стороне: сейчас нам важно научиться понимать механику через ok, руками, без «автопилота». Это как сначала научиться водить по педалям, а потом уже радоваться круиз-контролю.
Вот “ручной” цикл чтения:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
for {
v, ok := <-ch
if !ok {
break
}
fmt.Println(v) // 10 потом 20
}
}
Здесь важны сразу три вещи.
- Первая: close не уничтожает уже отправленные значения — они дочитываются.
- Вторая: когда значения кончаются, получаем ok=false и выходим.
- Третья: цикл не зависит от того, буферизированный канал или нет — протокол одинаковый.
4. Практика и продвинутые сценарии
Поток задач в TaskCLI
Чтобы примеры не были “в вакууме”, продолжим мысль учебного приложения. Пусть у нас есть консольная утилита TaskCLI, которая работает со списком задач. Раньше мы хранили задачи в слайсах, фильтровали, сортировали, печатали. Теперь добавим конкурентный кусок: одну горутину, которая “производит задачи” в канал, и main, которая “читает задачи” и печатает.
Начнём с модели (предположим, что структура Task у нас уже знакома по предыдущим темам про struct):
package main
type Task struct {
ID int
Title string
Done bool
}
Теперь сделаем производителя: он пробегает по слайсу и отправляет задачи, а потом закрывает канал. Обратите внимание: закрытие — внутри производителя, то есть “у владельца отправки”.
package main
func produceTasks(ch chan Task, tasks []Task) {
for _, t := range tasks {
ch <- t
}
close(ch) // важно: закрывает отправитель
}
Теперь в main создадим канал, запустим горутину-производитель и будем читать до закрытия через v, ok.
package main
import "fmt"
func main() {
tasks := []Task{
{ID: 1, Title: "learn Go", Done: false},
{ID: 2, Title: "drink tea", Done: true},
}
ch := make(chan Task)
go produceTasks(ch, tasks)
for {
t, ok := <-ch
if !ok {
break
}
fmt.Println(t.ID, t.Title, t.Done)
// 1 learn Go false
// 2 drink tea true
}
}
Этот код уже демонстрирует “правильный конец”: программа не зависнет, даже если задач больше не будет, потому что канал закрывается.
Несколько отправителей и один close
С одним отправителем всё просто: он отправляет, он закрывает. Но как быть, если отправителей несколько? Например, вы распараллелили чтение задач из разных источников (пока без select, просто концептуально) и каждый отправляет в один общий канал.
В этом случае нельзя, чтобы каждый делал close(ch) — кто-то закроет раньше, а остальные попытаются отправить и уронят программу.
Здесь появляется классический трюк: канал закрывает отдельный “координатор”, который ждёт завершения всех отправителей через WaitGroup, и закрывает канал ровно один раз. Это продолжение того, что мы учили про WaitGroup раньше.
Вот минимальная схема (всё ещё без сложных конструкций):
package main
import "sync"
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ch <- 1 }()
go func() { defer wg.Done(); ch <- 2 }()
go func() {
wg.Wait()
close(ch) // закрываем один раз, после всех отправок
}()
_ = ch
}
Здесь мораль такая: “закрывает тот, кто точно знает, что отправки закончились”. Иногда это реальный отправитель, иногда — координатор, который владеет протоколом.
panic: что запрещено делать с close
С close есть два запрета, и оба очень “физические”, как законы гравитации: нарушишь — упадёшь.
Первый запрет: нельзя отправлять в закрытый канал.
Второй запрет: нельзя закрывать канал дважды.
Покажем оба в виде маленьких примеров (строки, которые вызовут panic, оставим закомментированными).
package main
func main() {
ch := make(chan int, 1)
ch <- 1
close(ch)
// ch <- 2 // panic: send on closed channel
// close(ch) // panic: close of closed channel
_ = ch
}
Почему Go делает panic, а не “молча игнорирует”? Потому что это почти всегда логическая ошибка в вашем протоколе. Если бы это игнорировалось, баги были бы гораздо более “тихими” и мерзкими.
Кстати, у Go когда-то были экспериментальные “булевые формы” неблокирующих операций с каналами, но их убрали, чтобы не плодить двусмысленность и не усложнять язык. И это хорошо: в конкурентности нам важнее ясность правил, чем миллион “удобных” вариантов, которые потом никто не может отладить.
5. Типичные ошибки при закрытии каналов и чтении v, ok
Ошибка №1: канал закрывает получатель “чтобы остановить отправителя”.
Такое решение выглядит логичным, пока не появится гонка: отправитель ещё работает и делает ch <- v, но канал уже закрыт, и вы ловите panic: send on closed channel. Получатель не должен “командовать закрытием” через close. Если нужно управлять остановкой — это отдельный протокол, а close остаётся сигналом “отправок больше не будет”.
Ошибка №2: закрытие канала происходит в двух местах.
Обычно это случается при рефакторинге: вы вынесли отправку в функцию, оставили close(ch) внутри, а потом “на всякий случай” добавили ещё один close(ch) в main. Итог предсказуем: panic: close of closed channel. Лечится дисциплиной владения: у канала должен быть один ответственный за закрытие.
Ошибка №3: вместо close используют “магическое значение” (например, 0 или пустую строку).
Пока ваш поток данных “случайно” не содержит 0 — всё кажется рабочим. Но как только 0 станет легальным значением, получатель начнёт завершаться раньше времени, или наоборот — не сможет понять, где конец. В Go конец потока обозначают close, а факт конца распознают через ok.
Ошибка №4: читают v := <-ch и пытаются угадать, конец это или данные.
Если вы читаете без ok, то после закрытия канала вы получите zero value, и можете принять его за настоящее значение. Это особенно неприятно для типов вроде int или string, где 0 и "" часто легальны. Правильная форма для чтения “до конца” — именно v, ok := <-ch и проверка if !ok { ... }.
Ошибка №5: закрыли канал, но ожидают, что это “остановит всё”.
Закрытие канала — это лишь изменение поведения операций чтения/записи, а не “рубильник горутин”. Если горутина делает что-то ещё (например, крутит цикл, пишет в другой канал или ждёт чего-то), close её не завершит. Поэтому полезно мысленно держать модель: close — это про коммуникацию (“значений больше не будет”), а не про управление жизненным циклом горутин.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ