GraphLMS

Go
0/32 решеноНачать
Глава 03 · Основы~14 мин чтения

Каналы

О чём эта глава

Канал — это типизированный конвейер, по которому горутины передают значения и одновременно синхронизируются. У Go есть девиз: «Don't communicate by sharing memory; share memory by communicating». Звучит как лозунг, но за ним стоит очень практичная идея: вместо того чтобы городить мьютекс вокруг общих данных, вы передаёте владение данными через канал — и проблема видимости памяти решается сама собой.

Почему сама собой? Потому что отправка в канал и приём из него устанавливают happens-before. Когда горутина A отправила значение, а горутина B его приняла, всё, что A записала в память до отправки, гарантированно видно B после приёма. Канал — это не просто очередь, это очередь со встроенной синхронизацией.

В этой главе разберём четыре вещи, на которых держится почти вся конкурентность в Go: буфер против его отсутствия, закрытие канала как широковещательный сигнал, коварный nil-канал и направления каналов в сигнатурах.

package main
 
import "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 значений, не дожидаясь читателя. Отправка блокируется только когда буфер полон, приём — только когда буфер пуст. Буфер сглаживает рывки скорости: если отправитель на секунду опережает получателя, значения копятся в буфере, а не вызывают блокировку. Но важно понимать, чего буфер не делает: он не добавляет параллелизма и не отменяет необходимости кого-то, кто в итоге прочитает.

ch := make(chan int, 2)
ch <- 1   // ок, буфер 1/2
ch <- 2   // ок, буфер 2/2
ch <- 3   // блок: буфер полон, читателя нет

Посмотрим на разницу вживую. В примере ниже буфер на 3 позволяет отправителю закинуть все значения, ни разу не заблокировавшись, и только потом мы читаем:

package main
 
import "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 и прячет утечки, потому что блокировка, которая должна была сразу сказать «получатель не успевает», наступает с большой задержкой или не наступает вовсе.

Закрытие как broadcast

close(ch) — это сигнал «данных больше не будет». Ключевое свойство, ради которого закрытие так любят: из закрытого канала чтение никогда не блокирует. Оно сразу возвращает нулевое значение типа и ok == false.

done := make(chan struct{})
close(done)
<-done   // не блокирует
<-done   // и снова — закрытие видно всем и навсегда

Вот почему закрытие работает как широковещательный сигнал: одно close будит любое число горутин, ждущих на <-done. Не нужно отправлять по сигналу каждой — достаточно закрыть канал один раз, и все, кто ждёт, проснутся. Это основа done-channel паттерна и всего пакета context. Идиоматический тип для чистого сигнала — chan struct{}: он не несёт данных, занимает ноль байт и сообщает только сам факт события.

Покажем broadcast на нескольких горутинах. Все три воркера ждут на одном канале; мы закрываем его один раз — и все стартуют:

package main
 
import (
	"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 main
 
import "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-каналы

Операции на nil-канале блокируются навсегда — и это не баг, а инструмент:

var ch chan int   // nil
<-ch              // вечная блокировка
ch <- 1           // вечная блокировка

Сначала кажется дикостью: зачем нужна операция, которая никогда не завершится? Сила появляется в select. Если присвоить переменной канала nil, соответствующая ветка select «выключается» — приём из nil-канала никогда не будет выбран. Так можно динамически включать и выключать источники, не переписывая сам select.

Классический приём: когда источник исчерпан, мы зануляем его канал, чтобы select перестал его рассматривать и не крутился вхолостую на уже закрытом канале. Ниже — упрощённая версия: после того как первый канал закрылся, мы делаем его nil, и select дальше обслуживает только второй.

package main
 
import "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 main
 
import "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 с пайплайнами): на каждый канал уметь ответить «кто его закрывает и кто гарантированно его дочитает». Если на оба вопроса есть чёткий ответ — конкурентный код почти наверняка корректен.