Channels в Go: буферизированные и небуферизированные
Полный разбор каналов в Go: небуферизированные vs буферизированные, направленные каналы, select, закрытие, паттерны pipeline/fan-out/fan-in и частые ошибки на собеседовании.
- 1. Небуферизированные каналы: рандеву
- 2. Буферизированные каналы: асинхронная очередь
- 3. Направленные каналы
- 4. Закрытие канала и итерация
- 5. select: мультиплексирование каналов
- 6. Паттерны с каналами
- Pipeline: цепочка обработки
- Fan-out: распределение работы
- Fan-in: слияние каналов
- Семафор: ограничение параллелизма
- 7. Частые ошибки и утечки горутин
- Горутина заблокирована навсегда
- Закрытие канала не тем, кто пишет
- Nil-канал: блокировка навсегда
- Канал без инициализации
- Отправка в канал без горутины-потребителя
- Итоги / что точно спросят на собеседовании
Каналы — один из двух главных инструментов конкурентности в Go, наряду с горутинами. Мьютексы защищают разделяемое состояние, каналы — передают его. Философия, сформулированная создателями языка: «не общайтесь через разделяемую память — разделяйте память через общение». Это не просто красивый слоган: каналы переносят ownership данных от одной горутины к другой, исключая гонки на уровне архитектуры, а не через ручную синхронизацию.
На Go-собеседованиях каналы — обязательная тема. Разница между буферизированным и небуферизированным, поведение при закрытии, select, направленные каналы, паттерны pipeline и fan-out — всё это регулярно спрашивают, начиная с уровня middle. При этом вопросы часто ставятся так, что без понимания внутренней семантики ответить точно не выйдет.
Если ты ещё не читал про горутины и планировщик Go — загляни в Goroutines в Go: как работают и что спрашивают на интервью: каналы — это следующий уровень поверх горутин, и понимание GMP-модели поможет лучше разобраться с блокировками. А для широкого обзора всех тем — 50 вопросов для junior и middle Go-разработчика.
Тренируй каналы и конкурентность с AI-интервьюером
Реальные вопросы по каналам, select, паттернам и утечкам — с разбором ответов в реальном времени.
1. Небуферизированные каналы: рандеву
Небуферизированный канал создаётся без второго аргумента:
ch := make(chan int)
Семантика строгая: отправка блокируется до тех пор, пока получатель не готов принять, и наоборот. Обе стороны встречаются в одной точке времени — это называется рандеву (rendezvous). Ни один из участников не может продолжить работу, пока другой не подтянется.
func main() {
ch := make(chan int)
go func() {
fmt.Println("горутина: отправляю 42")
ch <- 42 // блокируется, пока main не прочитает
fmt.Println("горутина: отправка завершена")
}()
time.Sleep(100 * time.Millisecond) // main делает что-то своё
val := <-ch // разблокирует горутину
fmt.Println("main: получил", val)
}
Вывод будет такой:
горутина: отправляю 42
main: получил 42
горутина: отправка завершена
Заметь порядок: строка «отправка завершена» появляется только после того, как main забрал значение. Это ключевое свойство небуферизированного канала — отправитель знает, что получатель взял данные. Это даёт гарантию доставки, которую буферизированный канал не предоставляет.
Когда использовать небуферизированные каналы:
- Когда нужна явная синхронизация — «сделай это, потом я продолжу»
- Для сигналов между горутинами (
done chan struct{}) - Когда передаёшь ownership объекта и хочешь убедиться, что получатель взял его прямо сейчас
- В unit-тестах, где важен строгий порядок операций
Классическая ловушка — deadlock. Если попытаться отправить в небуферизированный канал без горутины-получателя, рантайм обнаружит, что все горутины заблокированы, и завершит программу паникой:
ch := make(chan int)
ch <- 42 // deadlock: fatal error: all goroutines are asleep
Та же паника, если пытаться читать из канала, в который никто не пишет:
ch := make(chan int)
<-ch // deadlock
2. Буферизированные каналы: асинхронная очередь
Буферизированный канал создаётся со вторым аргументом — размером буфера:
ch := make(chan int, 3)
Внутри — кольцевая очередь FIFO фиксированного размера. Отправитель блокируется только когда буфер заполнен; получатель — только когда буфер пуст. Если буфер не полный и не пустой — обе стороны могут работать независимо друг от друга.
ch := make(chan int, 3)
// Отправляем 3 значения — ни разу не блокируемся,
// буфер ещё не полон
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // заблокировало бы: буфер полон
// Читаем в порядке FIFO
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
Важный момент: буфер — не просто «кэш». Это изменение гарантий. Отправитель в буферизированный канал не знает, взял ли получатель данные прямо сейчас или они ещё лежат в очереди.
Worker pool с буферизированным каналом задач. Канал задач ограничивает количество в полёте заданий и не даёт отправителю убежать далеко вперёд воркеров:
func main() {
const numWorkers = 3
jobs := make(chan int, 10) // буфер на 10 задач
results := make(chan int, 10)
// Запускаем воркеры
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs {
// Имитируем тяжёлую работу
results <- job * job
}
}(i)
}
// Отправляем задачи
for i := 1; i <= 9; i++ {
jobs <- i
}
close(jobs) // сигнализируем воркерам: задач больше нет
// Закрываем results когда все воркеры завершились
go func() {
wg.Wait()
close(results)
}()
// Собираем результаты
for r := range results {
fmt.Println(r)
}
}
Когда использовать буферизированные каналы:
- Для развязки скоростей отправителя и получателя (rate smoothing)
- В worker pool для очереди задач
- Когда нужно накопить пачку данных перед обработкой
- Для ограничения параллелизма (семафорный паттерн — см. раздел 6)
Выбор размера буфера. Нет универсального правила: буфер слишком маленький — отправители блокируются и деградируют к поведению небуферизированного; буфер слишком большой — маскирует медленных потребителей и тратит память. Начни с того числа, которое соответствует ожидаемому числу одновременных производителей или размеру батча.
Go-вопросы в Telegram
Ежедневные разборы горутин, каналов и паттернов.
3. Направленные каналы
Канал в Go — двунаправленный по умолчанию. Но в сигнатурах функций принято сужать его до одного направления через типы chan<- T (только запись) и <-chan T (только чтение).
func producer(out chan<- int) { // только запись
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
func consumer(in <-chan int) { // только чтение
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int, 5) // двунаправленный
go producer(ch) // неявное сужение: chan int → chan<- int
consumer(ch) // неявное сужение: chan int → <-chan int
}
Двунаправленный канал неявно конвертируется в направленный — это безопасное сужение типа. Обратное невозможно: из chan<- int нельзя получить chan int.
Зачем это нужно? Три причины:
Первая — статическая проверка компилятором. Если функция случайно попытается закрыть или читать из канала, в который должна только писать — компилятор не пропустит. Это исключает целый класс ошибок.
Вторая — документирование контракта. Глядя на сигнатуру func generate(nums ...int) <-chan int, сразу ясно: функция вернёт канал только для чтения. Она сама создаёт, пишет и закрывает его — снаружи не нужно этим управлять.
Третья — разграничение ответственности. Канал закрывает тот, кто пишет. Направленные типы делают это правило явным: у получателя просто нет доступа к операции close.
Pipeline с направленными типами:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out // возвращаем <-chan int, скрывая двунаправленный внутри
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Цепочка: generate → square → print
for v := range square(generate(2, 3, 4, 5)) {
fmt.Println(v) // 4 9 16 25
}
}
4. Закрытие канала и итерация
Закрытие канала — это сигнал: данных больше не будет. Синтаксис прост:
close(ch)
Что происходит при чтении из закрытого канала. Если в буфере ещё есть данные — они будут возвращены в нормальном режиме. Когда буфер опустеет (или канал небуферизированный), чтение немедленно вернёт нулевое значение типа и false вторым аргументом:
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // 10 true
v, ok = <-ch
fmt.Println(v, ok) // 20 true
v, ok = <-ch
fmt.Println(v, ok) // 0 false ← канал закрыт и пуст
for range по каналу читает значения до тех пор, пока канал не закрыт и не пуст — это идиоматичный способ потребления потока данных:
ch := make(chan string, 3)
ch <- "one"
ch <- "two"
ch <- "three"
close(ch)
for v := range ch {
fmt.Println(v) // one, two, three
}
// после закрытия цикл завершается автоматически
Без close цикл for range будет ждать вечно — это частая причина утечки горутин.
Паника при записи в закрытый канал. Это одна из самых жёстких ошибок в Go — нет способа «мягко» поймать её на уровне языка:
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Двойное закрытие тоже вызывает панику:
ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel
Паттерн done-канал для отмены. Закрытие канала разблокирует сразу всех слушателей — это гораздо эффективнее, чем отправлять сигнал в цикле каждому:
func worker(id int, done <-chan struct{}, jobs <-chan int) {
for {
select {
case <-done:
fmt.Printf("воркер %d: остановлен\n", id)
return
case job, ok := <-jobs:
if !ok {
return // канал задач закрыт
}
fmt.Printf("воркер %d: обрабатываю %d\n", id, job)
}
}
}
func main() {
done := make(chan struct{})
jobs := make(chan int, 10)
for i := 1; i <= 3; i++ {
go worker(i, done, jobs)
}
for i := 0; i < 5; i++ {
jobs <- i
}
time.Sleep(100 * time.Millisecond)
close(done) // останавливаем всех воркеров разом
time.Sleep(50 * time.Millisecond)
}
Правила закрытия канала — запомни их:
Первое: закрывает канал только отправитель, никогда получатель. Второе: канал закрывается ровно один раз. Третье: если несколько горутин пишут в один канал — нужна дополнительная координация (sync.WaitGroup + отдельная горутина, которая ждёт всех и закрывает). Четвёртое: nil-канал никогда не закрывается сам — close(nil) вызывает панику.
5. select: мультиплексирование каналов
select позволяет горутине ждать на нескольких каналах одновременно — это фундаментальный инструмент конкурентного программирования в Go. Синтаксически похож на switch, но каждый case — это операция с каналом.
Базовый select:
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
}
// Блокируется, пока хотя бы один канал не станет готов
Случайный выбор при нескольких готовых каналах. Если несколько case готовы одновременно, Go выбирает один псевдослучайно. Это предотвращает starvation — когда один канал обрабатывается всегда первым:
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch1 <- "один"
ch2 <- "два"
// Каждый раз может выбраться любой из двух
select {
case v := <-ch1:
fmt.Println(v)
case v := <-ch2:
fmt.Println(v)
}
default для неблокирующих операций. Если ни один case не готов, выполняется default. Это превращает select в неблокирующую операцию:
// Неблокирующее чтение
select {
case v := <-ch:
fmt.Println("получил:", v)
default:
fmt.Println("канал пуст, продолжаю")
}
// Неблокирующая запись
select {
case ch <- value:
fmt.Println("отправил")
default:
fmt.Println("буфер полон, пропускаю")
}
Таймауты через time.After. time.After(d) возвращает <-chan Time, который получит значение через d времени — удобно для select:
func fetchWithTimeout(ch <-chan string) (string, error) {
select {
case result := <-ch:
return result, nil
case <-time.After(2 * time.Second):
return "", fmt.Errorf("timeout")
}
}
time.After в цикле — утечка таймера. Если использовать time.After внутри цикла, каждую итерацию создаётся новый таймер, который не отменяется до срабатывания. В горячих путях это утечка. Правильный способ — time.NewTimer с явным Stop:
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case result := <-ch:
timer.Stop()
return result, nil
case <-timer.C:
return "", fmt.Errorf("timeout")
}
Nil-канал в select. case с nil-каналом никогда не выбирается — он навсегда заблокирован. Это позволяет динамически «выключать» case, просто обнуляя переменную канала:
var ch1, ch2 <-chan int
ch1 = make(chan int, 1)
ch1 <- 42
// ch2 == nil, его case никогда не выберут
select {
case v := <-ch1:
fmt.Println("ch1:", v) // выберется этот case
case v := <-ch2:
fmt.Println("ch2:", v) // никогда не выберется
}
Бесконечный цикл с select — стандартный скелет воркера:
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return // канал задач закрыт
}
results <- process(job)
}
}
}
6. Паттерны с каналами
Несколько устойчивых паттернов, которые стоит знать и уметь объяснить на интервью.
Pipeline: цепочка обработки
Каждая стадия читает из входного канала, обрабатывает и пишет в выходной. Функции-стадии принимают <-chan T и возвращают <-chan T:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func filter(in <-chan int, pred func(int) bool) <-chan int {
out := make(chan int)
go func() {
for n := range in {
if pred(n) {
out <- n
}
}
close(out)
}()
return out
}
func multiply(in <-chan int, factor int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * factor
}
close(out)
}()
return out
}
func main() {
// Генерируем числа → фильтруем чётные → умножаем на 10
nums := generate(1, 2, 3, 4, 5, 6)
evens := filter(nums, func(n int) bool { return n%2 == 0 })
result := multiply(evens, 10)
for v := range result {
fmt.Println(v) // 20 40 60
}
}
Плюс паттерна: каждая стадия изолирована и тестируется независимо. Минус: нет встроенной отмены — нужно добавлять ctx или done-канал.
Fan-out: распределение работы
Один входной канал читают несколько горутин-воркеров. Go-каналы безопасны для конкурентного чтения из нескольких горутин — каждое значение получит ровно один воркер:
func fanOut(in <-chan int, workers int) []<-chan int {
outs := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
outs[i] = square(in) // каждый воркер читает из одного in
}
return outs
}
Fan-out применяется, когда обработка одного элемента дорогостоящая и можно распараллелить её на несколько воркеров.
Fan-in: слияние каналов
Несколько входных каналов сливаются в один выходной. Merge-функция запускает по горутине на каждый входной канал и собирает их вывод в общий канал:
func merge(channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)
output := func(c <-chan int) {
defer wg.Done()
for v := range c {
merged <- v
}
}
wg.Add(len(channels))
for _, c := range channels {
go output(c)
}
// Закрываем merged, когда все горутины завершились
go func() {
wg.Wait()
close(merged)
}()
return merged
}
func main() {
ch1 := generate(1, 2, 3)
ch2 := generate(10, 20, 30)
for v := range merge(ch1, ch2) {
fmt.Println(v) // порядок не гарантирован
}
}
Семафор: ограничение параллелизма
Буферизированный канал размером N работает как семафор: «захват» — отправка в канал, «освобождение» — чтение. Не более N горутин выполняются одновременно:
func processURLs(urls []string, concurrency int) {
sem := make(chan struct{}, concurrency) // семафор на N слотов
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
sem <- struct{}{} // захватить слот
defer func() { <-sem }() // освободить слот
fetch(u) // максимум concurrency fetch'ей одновременно
}(url)
}
wg.Wait()
}
Это элегантнее пула воркеров в сценариях, где задачи уже существуют как горутины и нужно просто ограничить их параллельное исполнение.
7. Частые ошибки и утечки горутин
Большинство проблем с каналами в реальном коде — это несколько повторяющихся паттернов.
Горутина заблокирована навсегда
Горутина ждёт из канала, в который никто не запишет, или пишет в канал, из которого никто не читает:
func leaky() {
ch := make(chan int)
go func() {
val := <-ch // заблокирована навсегда — ch никто не пишет
fmt.Println(val)
}()
// ch выходит из области видимости, но горутина продолжает висеть
}
Исправление — всегда давать горутине способ завершиться: через context.Context или done-канал:
func safe(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // выходим при отмене
}
}()
}
Закрытие канала не тем, кто пишет
Получатель никогда не должен закрывать канал — он не знает, сколько ещё данных собирается отправить производитель. Если это сделать, следующий send от производителя вызовет панику:
// Неправильно
func badConsumer(ch chan int) {
v := <-ch
close(ch) // panic если producer ещё пишет
fmt.Println(v)
}
// Правильно: только producer закрывает
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // producer знает, когда данные закончились
}
Если несколько горутин пишут в один канал — нужна координация: sync.WaitGroup ждёт всех, и отдельная горутина закрывает канал после завершения всех производителей:
func multiProducer(ch chan<- int, n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id * id
}(i)
}
go func() {
wg.Wait()
close(ch)
}()
}
Nil-канал: блокировка навсегда
Операции с nil-каналом (send и receive) блокируются навсегда — deadlock в рамках горутины. Единственное исключение — case с nil-каналом в select просто никогда не выбирается:
var ch chan int // nil
// ch <- 1 // навсегда заблокирует горутину
// <-ch // навсегда заблокирует горутину
// close(ch) // panic: close of nil channel
// В select — безопасно, case просто игнорируется:
select {
case v := <-ch: // ch == nil, этот case никогда не выберется
fmt.Println(v)
default:
fmt.Println("nil-канал, пропускаем")
}
Nil-канал как «отключённый case в select — иногда полезный паттерн: динамически обнуляй переменную, чтобы временно игнорировать источник данных.
Канал без инициализации
Самая банальная ошибка — использование канала, который не был инициализирован через make:
var jobs chan int // nil, не инициализирован
jobs <- 1 // навсегда заблокирует, нет паники
<-jobs // навсегда заблокирует
close(jobs) // panic: close of nil channel
Всегда инициализируй каналы перед использованием:
jobs := make(chan int, 10) // буферизированный
// или
jobs := make(chan int) // небуферизированный
Отправка в канал без горутины-потребителя
Если буфер каналов заполнен или канал небуферизированный, а потребителя нет — send заблокирует навсегда. Это частый источник deadlock в тестах:
func processAll(items []int) []int {
results := make(chan int) // небуферизированный!
for _, item := range items {
go func(v int) {
results <- v * v // все горутины ждут, пока main читает
}(item)
}
var out []int
// Если items пустой — цикл не выполнится, горутины не запустятся,
// но если items не пустой — нужно читать ровно len(items) раз
for i := 0; i < len(items); i++ {
out = append(out, <-results)
}
return out
}
Итоги / что точно спросят на собеседовании
Каналы в Go — не просто очередь с горутинами. Это механизм координации с чёткой семантикой, которую нужно знать наизусть.
В чём разница между буферизированным и небуферизированным каналом?
Небуферизированный (make(chan T)) синхронный: отправитель ждёт получателя — рандеву. Буферизированный (make(chan T, N)) асинхронный: отправитель блокируется только когда буфер полон, получатель — когда пуст. Небуферизированный канал гарантирует, что получатель взял данные в момент отправки; буферизированный — нет.
Что произойдёт при записи в закрытый канал?
panic: send on closed channel. Чтение из закрытого канала возвращает нулевое значение типа и false вторым аргументом. Двойной close — паника. Закрывает канал только отправитель.
Что такое направленные каналы?
chan<- T разрешает только запись, <-chan T — только чтение. Используются в сигнатурах функций для документирования контракта и статической проверки. Двунаправленный канал неявно сужается до направленного; обратное невозможно.
Что делает select при нескольких готовых каналах?
Выбирает один псевдослучайно — для предотвращения starvation. default выполняется, если ни один case не готов. case с nil-каналом никогда не выбирается.
Как избежать утечки горутин через каналы?
Всегда передавать context.Context и проверять ctx.Done() в select. Гарантировать, что канал будет закрыт или в него придут данные. Использовать goleak в тестах. Следить за runtime.NumGoroutine() в динамике.
Как устроен паттерн pipeline?
Каждая стадия — функция, принимающая <-chan T и возвращающая <-chan T. Внутри горутина читает из входного канала, трансформирует данные и пишет в выходной. Когда входной канал закрывается, горутина завершается и закрывает выходной.
Для более широкого разбора Go-собеседований — читай 50 вопросов для junior и middle Go-разработчика: там разбираются вопросы по интерфейсам, ошибкам, памяти и другим темам, которые задают рядом с конкурентностью.
Разберём каналы на практике
AI-интервьюер задаёт вопросы по каналам, select и паттернам — и разбирает твои ответы с примерами кода.
Go для собеседований: 50 реальных вопросов
Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.
Автор
Lexicon Team
Читайте также
backend
Threading vs multiprocessing в Python: что выбрать и почему
Глубокий разбор threading и multiprocessing в Python: GIL, CPU-bound vs I/O-bound, concurrent.futures, синхронизация, IPC и реальные примеры кода для собеседования.
backend
Asyncio в Python: event loop, async/await и задачи
Полный разбор asyncio: как работает event loop, coroutines и async/await, создание задач через asyncio.Task и gather — и что точно спросят на собеседовании.
backend
Go memory model простыми словами: happens-before, гонки и sync
Разбираем модель памяти Go: happens-before, data races, гарантии каналов, Mutex, Once и атомики — с примерами кода и реальными вопросами с интервью.