Канал — это типизированный конвейер, по которому горутины передают значения и
одновременно синхронизируются. У Go есть девиз: «Don't communicate by sharing
memory; share memory by communicating». Звучит как лозунг, но за ним стоит очень
практичная идея: вместо того чтобы городить мьютекс вокруг общих данных, вы
передаёте владение данными через канал — и проблема видимости памяти решается
сама собой.
Почему сама собой? Потому что отправка в канал и приём из него устанавливают
happens-before. Когда горутина A отправила значение, а
горутина B его приняла, всё, что A записала в память до отправки, гарантированно
видно B после приёма. Канал — это не просто очередь, это очередь со встроенной
синхронизацией.
В этой главе разберём четыре вещи, на которых держится почти вся конкурентность в
Go: буфер против его отсутствия, закрытие канала как широковещательный сигнал,
коварный nil-канал и направления каналов в сигнатурах.
package mainimport "fmt"func main() { ch := make(chan string, 1) ch <- "сигнал прошёл через канал" fmt.Println(<-ch)}
Базовый синтаксис, который дальше будет встречаться постоянно:
ch := make(chan int) // небуферизованныйch := make(chan int, 100) // буфер на 100 элементовch <- 42 // отправкаv := <-ch // приёмv, ok := <-ch // ok == false, если канал закрыт и пуст
Это первое, что нужно прочувствовать, потому что от размера буфера зависит, когда
именно горутина заблокируется.
Небуферизованный канал — это рандеву. Отправка не «кладёт значение в очередь»
и не идёт дальше: она блокируется, пока кто-то на другом конце не начнёт принимать.
И наоборот, приём блокируется, пока кто-то не отправит. Передача значения — это
момент, когда две горутины встречаются в одной точке времени. Поэтому
небуферизованный канал даёт самую сильную гарантию синхронизации: после успешной
отправки вы точно знаете, что получатель уже здесь и забрал значение.
Буферизованный канал ослабляет рандеву. Он позволяет отправить до cap
значений, не дожидаясь читателя. Отправка блокируется только когда буфер полон,
приём — только когда буфер пуст. Буфер сглаживает рывки скорости: если отправитель
на секунду опережает получателя, значения копятся в буфере, а не вызывают
блокировку. Но важно понимать, чего буфер не делает: он не добавляет
параллелизма и не отменяет необходимости кого-то, кто в итоге прочитает.
Посмотрим на разницу вживую. В примере ниже буфер на 3 позволяет отправителю
закинуть все значения, ни разу не заблокировавшись, и только потом мы читаем:
package mainimport "fmt"func main() { ch := make(chan int, 3) for i := 1; i <= 3; i++ { ch <- i * i // не блокируемся: буфера хватает fmt.Println("отправил", i*i, "len/cap:", len(ch), "/", cap(ch)) } close(ch) for v := range ch { fmt.Println("прочитал", v) }}
Обратите внимание на len(ch) и cap(ch): cap — это размер буфера, len —
сколько значений в нём прямо сейчас лежит. Это полезный способ заглянуть внутрь.
Размер буфера — это не «оптимизация наугад». Буфер 1 часто ставят, чтобы
отправитель не завис, если получатель внезапно ушёл (типичная история в
паттернах с таймаутом: воркер отправляет результат в канал
с буфером 1, и даже если ответ уже никому не нужен, воркер не утекает). А вот
большой буфер — повод насторожиться: он маскирует проблемы backpressure и прячет
утечки, потому что блокировка, которая должна была сразу сказать «получатель не
успевает», наступает с большой задержкой или не наступает вовсе.
close(ch) — это сигнал «данных больше не будет». Ключевое свойство, ради которого
закрытие так любят: из закрытого канала чтение никогда не блокирует. Оно сразу
возвращает нулевое значение типа и ok == false.
done := make(chan struct{})close(done)<-done // не блокирует<-done // и снова — закрытие видно всем и навсегда
Вот почему закрытие работает как широковещательный сигнал: одно close будит
любое число горутин, ждущих на <-done. Не нужно отправлять по сигналу каждой —
достаточно закрыть канал один раз, и все, кто ждёт, проснутся. Это основа
done-channel паттерна и всего пакета context. Идиоматический тип
для чистого сигнала — chan struct{}: он не несёт данных, занимает ноль байт и
сообщает только сам факт события.
Покажем broadcast на нескольких горутинах. Все три воркера ждут на одном канале;
мы закрываем его один раз — и все стартуют:
package mainimport ( "fmt" "sync")func main() { start := make(chan struct{}) var wg sync.WaitGroup for i := 1; i <= 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() <-start // ждём общего сигнала fmt.Println("воркер", id, "поехал") }(i) } close(start) // один close будит всех wg.Wait() fmt.Println("все воркеры стартовали")}
Порядок строк «воркер N поехал» в yaegi не гарантирован — важно, что все три
проснулись от одного close. WaitGroup здесь нужен, чтобы main дождался
горутин и вывод успел напечататься.
Правила закрытия, нарушение которых — паника (и обычно падение всей программы):
close уже закрытого канала — паника.
Отправка в закрытый канал — паника.
close(nil) — паника.
Принцип: закрывает только отправитель, и только один. Получатель не
закрывает. Если отправителей несколько, координируйте закрытие отдельно —
через sync.Once или выделенную горутину-владельца,
которая дождётся всех и закроет канал сама.
for range по каналу читает значения до закрытия и затем аккуратно выходит — это
самый частый способ дочитать продьюсера до конца:
package mainimport "fmt"func main() { nums := make(chan int) go func() { for i := 1; i <= 4; i++ { nums <- i } close(nums) // обязательно: иначе range висит вечно }() sum := 0 for v := range nums { // выходит, когда канал закрыт и опустошён sum += v } fmt.Println("сумма:", sum)}
Операции на nil-канале блокируются навсегда — и это не баг, а инструмент:
var ch chan int // nil<-ch // вечная блокировкаch <- 1 // вечная блокировка
Сначала кажется дикостью: зачем нужна операция, которая никогда не завершится?
Сила появляется в select. Если присвоить переменной канала nil,
соответствующая ветка select «выключается» — приём из nil-канала никогда не будет
выбран. Так можно динамически включать и выключать источники, не переписывая сам
select.
Классический приём: когда источник исчерпан, мы зануляем его канал, чтобы select
перестал его рассматривать и не крутился вхолостую на уже закрытом канале. Ниже —
упрощённая версия: после того как первый канал закрылся, мы делаем его nil,
и select дальше обслуживает только второй.
package mainimport "fmt"func main() { a := make(chan int, 2) b := make(chan int, 2) a <- 1 a <- 2 close(a) b <- 10 b <- 20 close(b) sum := 0 for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil // выключаем ветку: больше её не выберем continue } sum += v case v, ok := <-b: if !ok { b = nil continue } sum += v } } fmt.Println("слили оба канала, сумма:", sum)}
Без зануления закрытый канал в select выбирался бы постоянно (чтение из него не
блокирует), и цикл крутился бы вхолостую. nil решает это элегантно: «выключенная»
ветка просто никогда не срабатывает.
Тип канала можно сузить до «только отправка» или «только приём» — прямо в сигнатуре
функции. Это превращает намерение в контракт, который проверяет компилятор:
func produce(out chan<- int) { out <- 1 } // chan<- : только отправкаfunc consume(in <-chan int) { <-in } // <-chan : только приём
Стрелка показывает, куда «течёт» значение относительно канала. Двунаправленный
chan int неявно приводится к chan<- или <-chan при передаче в такую функцию,
а вот обратно — нельзя. Польза двойная: это самодокументируемая сигнатура (видно,
кто продьюсер, а кто консьюмер) и защита от ошибок — компилятор не даст продьюсеру
случайно читать из канала, а консьюмеру — закрыть чужой канал (закрыть можно только
двунаправленный или send-only канал).
package mainimport "fmt"func produce(out chan<- int) { for i := 1; i <= 3; i++ { out <- i } close(out)}func sum(in <-chan int) int { total := 0 for v := range in { total += v } return total}func main() { ch := make(chan int) go produce(ch) // chan int подходит как chan<- int fmt.Println("итог:", sum(ch))}
Запись в закрытый канал. Чаще всего возникает, когда отправителей несколько и
один закрыл канал раньше остальных. Следующая отправка от другого отправителя —
паника. Лечится правилом «закрывает один владелец»: пусть закрытием управляет тот,
кто гарантированно знает, что все отправители закончили.
Утечка на отправке. Горутина навсегда висит на ch <- v, потому что получатель
уже ушёл. Каждая такая горутина — это удержанный стек и память, которые уже никогда
не освободятся. Спасает буфер 1, select с default, или done-канал
(см. context).
func leak() chan int { ch := make(chan int) go func() { ch <- expensive() }() // если никто не прочитает — горутина висит вечно return ch}
Закрытие со стороны получателя. Получатель не знает, закончил ли отправитель,
и, закрыв канал «под рукой», устроит панику при следующей отправке живого
продьюсера. Получатель не закрывает — точка.
for range без закрытия. Если канал так и не закроют, range будет ждать
следующего значения вечно. Закрытие — это не опция, а часть протокола: тот, кто
пишет в канал в цикле, обязан в конце его закрыть.
Удержите в голове три смысла — и любую конкурентную структуру вы соберёте
правильно:
Передача значения = передача владения. После ch <- x отправитель не должен
трогать x: теперь это забота получателя. Happens-before гарантирует, что
получатель увидит данные целиком.
Закрытие = «событий больше не будет» и одновременно broadcast. Один close
будит всех ждущих и навсегда.
nil = «канал, который никогда не сработает». В select это выключатель
ветки.
Когда ловите себя на вопросе «почему здесь дедлок / утечка / паника», почти всегда
ответ — в одном из этих трёх смыслов.
Каналы редко живут в одиночку — их сила раскрывается в комбинации с другими
конструкциями:
select — ждать сразу нескольких каналов, таймауты, отмена,
трюк с nil-каналом из этой главы.
context — done-канал, выросший в стандарт отмены и дедлайнов.
Паттерны — пайплайны, fan-in/fan-out, генераторы: всё это
каналы плюс правило «кто закрывает».
Модель памяти — почему отправка/приём дают happens-before
и зачем это нужно.
Главный навык, который пригодится в задачах тренажёра (особенно в топике 1
с «Or-Channel» и в топике 3 с пайплайнами): на каждый канал уметь ответить
«кто его закрывает и кто гарантированно его дочитает». Если на оба вопроса есть
чёткий ответ — конкурентный код почти наверняка корректен.