Channels в Go: буферизированные и небуферизированные

Полный разбор каналов в Go: небуферизированные vs буферизированные, направленные каналы, select, закрытие, паттерны pipeline/fan-out/fan-in и частые ошибки на собеседовании.

01 марта 2026 г.20 минLexicon Team

Каналы — один из двух главных инструментов конкурентности в 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

Читайте также