10.4 Семантика закрытия канала
В предыдущих разделах операции отправки и получения были «парными» рандеву: одна отправка соответствует одному получению, а избыточная сторона блокируется в ожидании. Закрытие — единственная операция над каналом с семантикой «один ко многим». close(ch) вызывается одной горутиной, однако в тот же момент пробуждает всех получателей, заблокированных на канале, и заставляет немедленно запаниковать всех заблокированных отправителей. Эта возможность «однократного широковещания, пробуждающего всех» превращает закрытие из, казалось бы, малозначимой операции очистки в наиболее распространённый в Go механизм отмены и завершения работы. Паттерн done-канала и отмена через context (11.8) восходят именно к этой семантике.
Данный раздел отвечает на три вопроса: что close делает на уровне рантайма и почему он служит примитивом широковещания; что читает получатель из уже закрытого канала и почему for range завершается; и наконец — три запретные черты, которые язык проводит вокруг закрытия, предпочитая немедленно уронить программу, а не молча терпеть некорректное использование.
10.4.1 Что делает close: единственная блокировка с широковещанием
Реализация закрытия сосредоточена в одной функции — runtime.closechan. Её структуру можно свести к трём частям: установить бит closed под блокировкой, затем за один проход извлечь всех ожидающих из recvq и sendq и собрать их в локальный список, и наконец — снять блокировку и разом пробудить всех. Ниже приведена упрощённая версия, сохраняющая только ключевые архитектурные детали:
|
|
Здесь стоит выделить три архитектурных решения.
Во-первых, closed — это однократное, необратимое состояние: будучи однажды установленным в 1, оно никогда не возвращается в 0. Быстрый путь получения из закрытого канала (10.3) построен именно на этом инварианте: факт «канал закрыт» является постоянно истинным, без какой-либо угрозы того, что он снова окажется открытым.
Во-вторых, ожидающие в recvq и sendq извлекаются полностью, а не только головной элемент очереди, как это делают операции отправки и получения. Именно здесь и заключена широковещательная природа закрытия: на одном канале могут быть одновременно заблокированы сотни и тысячи получателей, и close пробуждает их всех за один раз. Целевая память каждого получателя очищается через typedmemclr — это и есть то «нулевое значение», которое они прочитают, — а sg.success = false сигнализирует о том, что данное пробуждение не является следствием парной отправки; именно на основании этого получатель устанавливает второе возвращаемое значение ok в false.
В-третьих, сначала собрать в glist, снять блокировку, затем вызвать goready. Пробуждение горутины затрагивает шедулер. Если вызвать goready, удерживая блокировку канала, пробуждённая горутина может немедленно начать выполняться на другом P, попытаться захватить ту же блокировку и создать излишнее давление на неё, увеличив время её удержания. Вынесение пробуждения за пределы блокировки — приём, встречающийся в рантайме повсеместно (сравните с тем, как мьютекс возвращает ожидающих в 11.3).
flowchart TD
C["close(ch)"] --> L["lock(&c.lock)"]
L --> S["c.closed = 1 (необратимо)"]
S --> RQ["обход recvq: каждый получатель<br/>typedmemclr → нулевое значение + success=false"]
RQ --> SQ["обход sendq: каждый отправитель<br/>success=false (паника при пробуждении)"]
SQ --> G["собрать всех в glist"]
G --> U["unlock(&c.lock)"]
U --> R["goready по одному: единственный вызов пробуждает всех ожидающих"]
Пробуждённый отправитель не отправляет никаких данных. Он возобновляет выполнение в точке блокировки внутри chansend (10.3), обнаруживает, что c.closed != 0, при том что это не реальная отправка (флаг sg.success, переданный через gp.param, равен false), и потому выполняет panic(plainError("send on closed channel")). Иными словами, правило «отправка в закрытый канал вызывает панику» для отправителя, находящегося в состоянии ожидания: он пробуждается через closechan, а паника происходит в нём самом — в точке, где он возобновляет выполнение.
10.4.2 Закрытие как широковещание: done-каналы и отмена
Как только становится понятно, что closechan пробуждает всех получателей одновременно, становится понятен и самый идиоматичный паттерн отмены в Go. Рассмотрим done-канал: он никогда не передаёт значимых данных — только единственное событие «закрытие».
|
|
Здесь тип элемента chan struct{} имеет нулевую ширину, не занимает буфер и используется исключительно как сигнальная линия. close(done) инициирует широковещание в closechan: все воркеры, заблокированные на <-done, пробуждаются вместе, каждый читает нулевое значение и завершает работу. Если бы вместо закрытия использовалась отправка, одна операция отправки пробуждает лишь одного воркера — для пробуждения воркеров потребовалось бы отправок, и их количество должно быть известно заранее. Закрытие же превращает это в единственную широковещательную операцию , не требующую знания числа получателей.
Именно этот механизм лежит в основе отмены через context (11.8). context.WithCancel внутренне хранит канал done, а ключевое действие cancel() — это просто close(done): одно закрытие, и каждая горутина, прослушивающая ctx.Done() во всём дереве контекстов, одновременно получает сигнал отмены. Можно сказать, что context — это обёртка над паттерном done-канала, добавляющая древовидное распространение и запись причины, тогда как способность к широковещанию целиком обеспечивается самой операцией close.
10.4.3 Получение из закрытого канала: сначала буфер, затем нулевые значения
Помимо широковещания, закрытие несёт дополнительный слой семантики для получения, неразрывно связанный с обработкой буферизованных данных: закрытие не уничтожает данные в буфере, которые ещё не были прочитаны. Когда получатель читает из уже закрытого канала, он сначала забирает оставшиеся значения из буфера по порядку и только затем начинает многократно возвращать нулевое значение.
Эту гарантию обеспечивает порядок проверок при получении (10.3). Первая проверка chanrecv под блокировкой — «закрыт и буфер пуст»: только при выполнении обоих условий сразу возвращается нулевое значение; если в буфере есть данные, управление передаётся дальше — в ветку «буфер непустой, извлечь элемент» — и данные берутся обычным образом. Таким образом, получение после закрытия демонстрирует две фазы:
|
|
Объединив обе семантики, легко понять условие завершения for range. for v := range ch компилятор (10.3) транслирует в цикл, который на каждой итерации получает значение с помощью v, ok := <-ch и выходит при ok == false. Сопоставим это с двумя фазами выше: до закрытия канала цикл получает значения как обычно и блокируется при отсутствии данных; после закрытия — сначала забирает все буферизованные данные, а когда очередной receive вернёт ok == false, цикл завершается. Закрытие — единственный чистый сигнал завершения для for range: итерация по незакрытому каналу заблокируется навсегда, как только данные закончатся.
Здесь же полностью проясняется смысл второго возвращаемого значения ok: оно отвечает не на вопрос «было ли прочитано значение», а на вопрос «поступило ли это значение в результате реальной отправки». ok == true означает, что значение получено в результате парной отправки или из буфера; ok == false означает, что канал закрыт, данных больше нет, а прочитанное — нулевое значение.
С точки зрения модели памяти (11.9), закрытие также предоставляет гарантию happens-before: close(ch) происходит до «получения, вернувшего нулевое значение по причине закрытия канала». Таким образом, done-канал не только передаёт единственный сигнал «отмена», но и позволяет безопасно публиковать разделяемые данные, записанные до сигнала: операции записи, выполненные до закрытия, видимы для операций чтения после получения сигнала закрытия. Именно на этом основана возможность использовать done-канал в качестве точки синхронизации.
10.4.4 Три запретные черты и принцип быстрого отказа
Закрытие — наиболее «паникогенное» место среди операций с каналами. Язык проводит вокруг него три запретные черты, пересечение любой из которых немедленно роняет программу:
| Некорректное использование | Точка срабатывания | Сообщение паники |
|---|---|---|
| Отправка в закрытый канал | chansend обнаруживает c.closed != 0 |
send on closed channel |
| Повторное закрытие | closechan обнаруживает c.closed != 0 |
close of closed channel |
| Закрытие nil-канала | вход в closechan: c == nil |
close of nil channel |
Все три случая следуют принципу быстрого отказа: немедленная паника вместо возврата ошибки или молчаливого игнорирования. Это проектное решение, заслуживающее отдельного пояснения. Возможность восстановления после таких ошибок на первый взгляд выглядит дружелюбнее, но на деле лишь глубже скрывает то, что по сути является логической ошибкой программиста.
Отправка в закрытый канал и повторное закрытие почти всегда сигнализируют о путанице с владением: отправитель и тот, кто закрывает канал, расходятся в понимании того, «следует ли в данный момент что-то писать в этот канал и кто должен завершать его работу». Если такие ошибки подавлять, данные могут молча теряться, или программа продолжает писать в мёртвый канал, а место сбоя оказывается далеко от источника проблемы, что существенно усложняет отладку. Паника на месте фиксирует ошибку в точке её возникновения: стек вызовов указывает прямо на незаконный close или send.
Закрытие nil-канала — та же ситуация. Отправка и получение через nil-канал блокируются навсегда (10.3), а вызов close на нём, скорее всего, означает, что переменная так и не была инициализирована. У подобной ошибки нет никакой разумной «отказоустойчивой» семантики, и единственно верная реакция — выявить её как можно раньше.
Что касается того, почему эти три случая нельзя аккуратно перехватить с помощью recover: паника send on closed channel происходит в промежуточном состоянии — данные уже подготовлены, но обнаружилось, что им некуда деться, — и внутреннее состояние канала в этот момент не позволяет вызывающему коду сделать вид, что ничего не произошло, и продолжить работу. Позиция Go здесь последовательна: некорректное использование канала — это логическая ошибка программиста, которую следует выявлять в процессе разработки с помощью -race и тестов, а не маскировать в production через recover.
10.4.5 Правило владения: отправитель закрывает, получатель — никогда
Когда три запретные черты приходят в инженерную практику, они кристаллизуются в одно простое, но высокоэффективное соглашение: канал закрывает отправитель, получатель никогда этого не делает; при наличии нескольких отправителей ни один из них не вправе закрыть канал в одиночку. Это правило не навязывается рантаймом, но является дисциплиной, естественно вытекающей из описанной выше семантики.
Почему закрывает именно отправитель? Потому что «больше нечего отправлять» знает только он. Получатель не может определить, завершил ли противоположный конец отправку: если он поспешно закроет канал, сторона, продолжающая отправку, натолкнётся на send on closed channel и завершится аварийно (первая запретная черта). Напротив, когда отправитель закрывает канал, он точно транслирует получателю «это всё», что идеально соответствует семантике завершения for range.
Случай с несколькими отправителями сложнее. Любой из них, выполнив закрытие, рискует активировать первую запретную черту, пока другой отправитель ещё пишет в канал, или столкнуться с повторным закрытием — второй запретной чертой. Правильное решение: ни один отправитель не закрывает канал данных; вместо этого вводится отдельный done-канал, закрываемый координатором. Перед каждой отправкой отправитель через select проверяет, не закрыт ли done, и при необходимости прекращает работу. Это возвращает нас ровно к паттерну широковещания из 10.4.2: канал данных несёт значения, done-канал несёт «стоп» — каждый занимается своим делом.
flowchart LR
P1["отправитель 1"] -->|данные| CH["канал данных"]
P2["отправитель 2"] -->|данные| CH
COORD["координатор"] -->|close| DONE["done-канал"]
DONE -.broadcast.-> P1
DONE -.broadcast.-> P2
CH --> R["получатель"]
Связывая эту дисциплину с предыдущими разделами: отправка (10.3) и получение (10.3) описывают парное рандеву, select (10.3) позволяет одной горутине одновременно следить за несколькими каналами, а закрытие единым широковещанием оповещает всех ожидающих о том, «я завершил работу со своей стороны». Только три эти механизма вместе образуют полный базовый словарь, которым Go ткёт конкурентность из каналов.
Дополнительные материалы
- The Go Authors. runtime/chan.go (ветви закрытия в
closechan,chanrecv,chansend). https://github.com/golang/go/blob/master/src/runtime/chan.go - The Go Authors. The Go Programming Language Specification: Close, Receive operator, For statements with range clause. https://go.dev/ref/spec#Close
- The Go Authors. The Go Memory Model (Channel communication, включая положение happens-before «закрытие предшествует получению»). https://go.dev/ref/mem#chan
- Sameer Ajmani. Go Concurrency Patterns: Pipelines and cancellation. The Go Blog, 2014. https://go.dev/blog/pipelines (первоначальное описание паттерна отмены через done-канал)
- Sameer Ajmani. Go Concurrency Patterns: Context. The Go Blog, 2014. https://go.dev/blog/context (распространение отмены, построенное поверх close)
- C. A. R. Hoare. “Communicating Sequential Processes.” Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585 (теоретические основы канальной коммуникации)
- Эта книга: 10.3 Отправка/получение и прямая передача, 10.3 Отправка/получение и прямая передача, 10.3 select, 11.8 Context, 11.9 Модель согласованности памяти.