Каналы: как горутины общаются

Разбираем вопрос о том, как горутины безопасно обмениваются данными друг с другом — без общих переменных и ручных блокировок.

Канал (channel) — это типизированная "труба" для передачи данных между горутинами. Одна горутина кладёт значение в канал, другая его забирает, и Go сам гарантирует, что доступ к данным будет безопасным.

Вопрос: зачем нужны каналы, если есть обычные переменные?

Чёткий ответ. Если несколько горутин одновременно читают и пишут одну и ту же обычную переменную, это гонка данных (data race) — непредсказуемое поведение, которое трудно отловить и воспроизвести. Каналы решают эту проблему на уровне языка: в философии Go это звучит как правило "не общайтесь через разделяемую память — делитесь памятью, общаясь через канал". Канал сам берёт на себя всю синхронизацию.

Создаётся канал функцией make, а тип элементов указывается через chan. Отправка — оператор <- после имени канала, приём — <- перед именем.

package main

import "fmt"

func main() {
	ch := make(chan string) // небуферизованный канал строк

	go func() {
		ch <- "привет из горутины" // отправка в канал
	}()

	msg := <-ch // приём из канала (блокирует main до отправки)
	fmt.Println(msg)
}

Вывод:

привет из горутины

Небуферизованные каналы: отправка и приём — это рандеву

Канал из примера выше — небуферизованный (объявлен без второго аргумента в make). Такой канал не хранит значения "про запас": отправляющая горутина блокируется на строке ch <- значение до тех пор, пока какая-то другая горутина не будет готова его тут же забрать. Это похоже на встречу двух людей: оба должны оказаться в условленной точке одновременно, иначе один из них ждёт.

Именно поэтому в примере выше main не завершается раньше времени, хотя мы не звали time.Sleep: строка msg := <-ch сама блокирует выполнение, пока горутина не пришлёт значение.

Буферизованные каналы: труба с "полочкой"

Если каналу передать вторым аргументом число, получится буферизованный канал — у него есть внутренняя очередь на заданное число элементов. Отправка блокируется, только когда буфер полностью заполнен; приём — только когда он полностью пуст.

package main

import "fmt"

func main() {
	ch := make(chan int, 2) // буфер на 2 элемента

	ch <- 1 // не блокирует: в буфере есть место
	ch <- 2 // тоже не блокирует
	fmt.Println("отправили 2 значения, не заблокировавшись")

	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

Вывод:

отправили 2 значения, не заблокировавшись
1
2

Если бы мы попытались отправить в этот канал третье значение без предварительного чтения — ch <- 3 заблокировала бы горутину, потому что буфер уже полон. Это частый вопрос на собеседовании: "будет ли этот код дедлоком?" — надо аккуратно посчитать, сколько значений умещается в буфер и кто их читает.

Закрытие канала и чтение "до дна"

Канал можно закрыть функцией close(ch) — это сигнал получателям "больше отправок не будет". После закрытия из канала всё ещё можно вычитать всё, что там осталось, а затем приём начинает возвращать нулевое значение. Чтобы отличить "пришло настоящее значение 0" от "канал закрыт и пуст", используют форму приёма с двумя значениями.

package main

import "fmt"

func main() {
	ch := make(chan int, 3)
	ch <- 10
	ch <- 20
	close(ch) // отправок больше не будет

	for {
		v, ok := <-ch
		if !ok {
			fmt.Println("канал закрыт и пуст")
			break
		}
		fmt.Println("получили:", v)
	}
}

Вывод:

получили: 10
получили: 20
канал закрыт и пуст

Такой же перебор гораздо чаще пишут через range — он сам останавливается, когда канал закрыт и пуст, без ручной проверки ok:

for v := range ch {
	fmt.Println("получили:", v)
}

Частые ошибки на собеседовании

  • Путают буферизованный и небуферизованный канал — думают, что make(chan int) тоже что-то буферизует.
  • Забывают закрыть канал там, где его читают через range — тогда цикл зависает навечно (дедлок).
  • Пытаются отправить значение в уже закрытый канал — это паника времени выполнения (panic: send on closed channel).
  • Не понимают, что закрывать канал должен именно отправитель, а не получатель — иначе легко словить гонку или двойное закрытие.

Итог

  • Канал — типобезопасная труба для обмена данными между горутинами, синхронизация встроена.
  • Небуферизованный канал — рандеву: отправка ждёт готового получателя.
  • Буферизованный канал блокирует только при заполненном (отправка) или пустом (приём) буфере.
  • Форма v, ok := <-ch и цикл range позволяют корректно определить, что канал закрыт.
Проверьте себя
1. Что произойдёт при отправке значения в небуферизованный канал, если ни одна горутина не готова его сразу же принять?
AЗначение потеряется
BПрограмма упадёт с ошибкой компиляции
CОтправляющая горутина заблокируется, пока не появится готовый получатель
DЗначение автоматически попадёт в буфер
2. Чем полезна форма чтения v, ok := <-ch ?
AОна ускоряет чтение из канала
BОна позволяет отличить реально полученное значение от ситуации, когда канал закрыт и пуст
CОна автоматически закрывает канал после чтения
DОна создаёт новый буферизованный канал
3. Что будет, если отправить значение в уже закрытый канал?
AЗначение спокойно запишется
BНичего не произойдёт, отправка просто игнорируется
CПроизойдёт паника (panic) во время выполнения
DКанал снова откроется