---
title: "Каналы: обмен данными"
description: "Каналы в Go: make(chan T), отправка и приём, незабуферизованное рандеву, буфер, close и range, направления chan<- и <-chan и откуда берётся дедлок."
course: "Продвинутые возможности Go: от замыканий до рефлексии"
url: "https://golangmaster.ru/courses/go-advanced/lessons/05-kanaly/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Каналы: обмен данными

*Курс «Продвинутые возможности Go: от замыканий до рефлексии», урок 5 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-advanced/lessons/05-kanaly/*

Каналы go — это труба, соединяющая горутины: одна отправляет значение, другая принимает, и обе встречаются в контролируемой точке. Разберём make(chan T), отправку и приём, рандеву на незабуферизованном канале, буфер, закрытие, конструкции v, ok := <-ch и range — и увидим, почему дедлок случается буквально в одну строку. Каждый демонстрационный пример запускается прямо на странице.

### Каналы go: труба между горутинами

В [прошлом уроке](/courses/go-advanced/lessons/04-gorutiny/) горутины научились работать параллельно, но обмениваться словами могли только через общий срез и общий счётчик — а это прямая дорога к гонкам данных. Каналы go решают проблему обмена честно: это типизированная труба, у которой есть один отправитель и один получатель, и значение проходит по ней строго по одному — как монетка в щели копилки. Никакая горутина не трогает чужую память: она просто кладёт значение в канал или достаёт из него. В англоязычных источниках это слово пишется латиницей — channel, и по запросу channels golang вы найдёте те же самые механизмы, что разберём ниже.

Канал типизирован: `chan int` возит только int, `chan string` — только строки. Смешать типы не выйдет, компилятор поймает на месте. И канал — это первое значение в курсе, которое можно не только создавать и читать, но и «закрывать», поэтому у него богатая жизнь: рождение через make, работа через отправку и приём, смерть через close.

По [курсу продвинутых возможностей](/courses/go-advanced/) это центральный урок про конкурентность: WaitGroup говорил «дождись», канал говорит «передай». Дальше на select, мьютексах и пуле workers мы достроим остальное — но без каналов никуда.

### make(chan T): создать канал и четыре операции

Создаётся канал встроенной функцией make: `ch := make(chan int)` — и это единственный способ, нулевое значение `var ch chan int` даёт nil-канал, который пока только мешает (о нём ближе к концу урока). Над готовым каналом есть ровно четыре операции, и все записываются стрелкой `<-`, которая похожа на направление движения значения:

- `ch <- v` — отправка: значение v уезжает в канал;
- `v := <-ch` — приём: значение приезжает из канала в переменную;
- `<-ch` — приём и выбросить: если значение не нужно, а нужен только факт приёма;
- `close(ch)` — закрыть: объявить, что новых значений не будет.

Стрелку удобно читать вслух «значение течёт в ту сторону»: в `ch <- v` оно течёт из v в ch, в `v := <-ch` — из ch в v. Синтаксис компактный, но под каждой из четырёх операций прячутся правила блокировки, и именно они делают каналы инструментом синхронизации, а не просто очередью. Начнём с самого строгого режима — незабуферизованного канала.

### Рандеву: отправка ждёт приёма

Запустите и обратите внимание на порядок: «получил: мяч» напечатано main-ом, а горутина к этому моменту ещё не вернулась из строки отправки. Запустите ещё дважды — вывод идентичен. Незабуферизованный канал синхронизирует сам себя: send и receive встречаются в одной точке, и обе стороны выходят из блокировки одновременно. Запомните ощущение: строка ch <- "мяч" была выполнена наполовину, пока main не пришёл за значением.

```go
package main

import "fmt"

func main() {
        ch := make(chan string) // незабуферизованный канал
        go func() {
                ch <- "мяч" // отправка: стоит и ждёт принимающего
        }()
        v := <-ch // приём: здесь встречаемся — рандеву
        fmt.Println("получил:", v)
        fmt.Println("порядок задаёт принимающий")
}
```

### Рандеву: кто кого ждёт

Незабуферизованный канал — это рандеву, «встреча из руки в руки». Отправка `ch <- v` блокирует горутину до тех пор, пока кто-то не примет значение: нет принимающего — отправитель стоит. Приём `<-ch` блокирует до тех пор, пока кто-то не отправит: нет отправителя — приёмник стоит. Встреча происходит атомарно: в момент рандеву значение передаётся, обе стороны разблокируются и идут дальше.

Из этого следует неожиданное на первый взгляд свойство: **порядок печати в программе с каналами определяет принимающий, а не отправляющий**. В прошлом уроке мы добивались детерминированного вывода через WaitGroup и обсуждали, что планировщик ничего не обещает; здесь гарантия получается бесплатно. Горутина, чья строка стоит после отправки, не напечатает ничего, пока main не заберёт мяч — значит, первым на экране всегда будет вывод принимающего. Именно поэтому пример выше — не «повезло с таймингом», а конструкция, которую можно повторять бесконечно.

Рандеву — это и синхронизация, и передача в одном флаконе: `v := <-ch` вернулся — значит, отправитель уже дошёл до этой точки. Такая гарантия «случилось перед» не даётся ни sleep-ами, ни надеждой на планировщик, и она же спасает от большинства гонок при обмене данными.

### Буферизованный канал: очередь на n мест

```go
ch := make(chan int)   // незабуферизованный: send ждёт receive
ch2 := make(chan int, 3) // буфер на три значения
ch2 <- 1                 // не блокирует: в буфере есть место
ch2 <- 2                 // тоже
ch2 <- 3                 // буфер полон...
// ch2 <- 4              // ...следующая отправка заблокирует
```

Второй аргумент make — ёмкость буфера. Пока в буфере есть место, отправка не ждёт никого: положил и пошёл дальше. Блокировка возвращается только при попытке положить (n+1)-е значение — освободить место должен принимающий.

### Буфер: FIFO без ожидания

Запустите. Обе отправки прошли мгновенно — буфер вмещает два значения, принимающего в момент отправки не требовалось. Приём же идёт строго в порядке очереди: «первый» вышел раньше «второго», потому что канал — FIFO, first in, first out. Встроенная функция len(ch) показывает, сколько значений стоит в очереди прямо сейчас.

```go
package main

import "fmt"

func main() {
        ch := make(chan string, 2) // буфер на два
        ch <- "первый"             // не блокирует: есть место
        ch <- "второй"             // и это тоже
        fmt.Println("обе отправки прошли, длина:", len(ch))
        fmt.Println("принял:", <-ch)
        fmt.Println("принял:", <-ch)
}
```

### Буфер: очередь, которая сглаживает пики

Буферизованный канал — это очередь с ограниченной ёмкостью: отправители кладут в хвост, получатели забирают из головы, и пока очередь неполна, стороны не встречаются вовсе. Это меняет характер синхронизации: незабуферизованный канал заставляет горутины идти нога в ногу, буферизованный позволяет отправителю убежать вперёд — но не более чем на n значений. Как только буфер заполняется, канал снова становится точкой встречи: отправка ждёт, пока принимающий не разгрузит очередь.

Зачем это нужно? Чтобы разнести по времени быстрых и медленных. Производитель кладёт задачи в буфер и не простаивает, пока потребитель обрабатывает предыдущую; буфер на пару значений сглаживает мелкие пики. Но ёмкость — не лекарство от всего: если потребитель в принципе медленнее производителя, любой буфер когда-нибудь наполнится, и производитель снова заблокируется. Правильный размер буфера — инженерное решение, а не заклинание «поставил побольше».

Есть и удобные встроенные помощники: `len(ch)` — сколько значений в очереди сейчас, `cap(ch)` — сколько мест всего. Полезно помнить, что len меняется под ногами у конкурентных горутин: для отладки и логов он хорош, для управления логикой программы — уже рискован (это ближе к теме [мьютексов и гонок](/courses/go-advanced/lessons/04-gorutiny/)).

### Три состояния канала: шпаргалка по блокировкам

| Состояние | Отправка ch <- v | Приём <-ch | Когда выбирать |
| --- | --- | --- | --- |
| Незабуферизованный: make(chan T) | Блокирует, пока кто-то не примет | Блокирует, пока кто-то не отправит | Рандеву: передать из руки в руки и заодно синхронизировать |
| Буферизованный: make(chan T, n) | Блокирует, только если буфер полон | Блокирует, только если буфер пуст | Очередь: отправителю можно убежать вперёд на n значений |
| Закрытый: close(ch) | Паника: send on closed channel | Немедленно: остаток буфера, потом нулевое значение и ok = false | Сигнал «данных больше не будет»; закрывает отправитель |

### Приём из закрытого канала: v, ok := <-ch

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

Кто закрывает? Тот, кто отправляет: close — обещание владельца трубы, поэтому его берут в [defer](/courses/go-advanced/lessons/02-defer/) там же, где создаётся канал, — механика «при любом выходе» та же, что у wg.Done из прошлого урока.

Правила приёма из закрытого канала двухступенчатые. Сначала вычитывается всё, что осталось в буфере (и что «застряло» в рандеву), — обычные значения, как ни в чём не бывало. Когда буфер пуст, приём возвращает мгновенно нулевое значение типа: 0 для int, "" для string, nil для указателей. Вторая ступень потому и опасна: без проверки вы получите нули и примете их за настоящие данные.

Поэтому у приёма есть двухзначная форма: `v, ok := <-ch` — та же конструкция «значение и флаг», что у чтения из карт. ok — это true, пока канал открыт (или пока из буфера есть что вычитать), и false, когда канал закрыт и опустошён. Проверка ok — единственный способ отличить «настоящий ноль из канала» от «нуля, потому что данных больше нет».

### Закрытый канал: второе значение ok

Запустите и прочитайте вывод построчно. Первый приём вытащил 7 из буфера — флаг ok равен true, канал-то закрыт, но данные ещё были. Второй приём вернул нулевое значение и ok = false: опустошённый закрытый канал больше не блокирует. Именно false — правильный сигнал «данные кончились», а не ноль из приёма.

```go
package main

import "fmt"

func main() {
        ch := make(chan int, 2)
        ch <- 7
        close(ch) // отправитель объявил: больше не будет
        v, ok := <-ch
        fmt.Println("первый приём:", v, ok) // 7 и true: из буфера
        v, ok = <-ch
        fmt.Println("второй приём:", v, ok) // 0 и false: канал пуст и закрыт
}
```

### range по каналу: вычитать всё и выйти

Писать `v, ok := <-ch` в цикле вручную можно, но громоздко. Идиома лучше: `for v := range ch` — цикл range умеет читать каналы. Он принимает значения одно за другим и продолжается, пока канал открыт и данные идут; как только канал закрыт **и** опустошён, цикл корректно завершается. Это тот же range, что ходил по срезам и картам, но тут он превращается в «читай, пока есть что читать».

Ключевое правило, которое экономит часы отладки: **range останавливается только на close**. Если отправитель забыл закрыть канал, потребитель вычитает все значения и застрянет в ожидании следующего — цикл не закончится никогда, main подвиснет. Поэтому в Go закреплено распределение ролей: канал закрывает тот, кто в него отправляет. Принимающий закрывать канал не имеет права — и даже range не спасёт, если отправитель в другой горутине не позвал close.

Обратная сторона: закрытый канал можно продолжать читать. После close и опустошения каждый приём мгновенно возвращает ноль и ok = false — range на этом и заканчивается. Никакой ошибки чтения из закрытого канала нет; ошибка (паника send on closed channel — из той же семьи, что разобрана в [уроке про panic и recover](/courses/go-advanced/lessons/03-panic-recover/)) случается только при отправке в закрытый канал и при повторном close. Запомните таблицу асимметрий: читать закрытый — можно и нужно, писать в закрытый — нельзя никогда.

### range: вычитать до закрытия

Запустите. Цикл вычел три значения из буфера и завершился сам — без счётчика, без проверки ok, без лишнего приёма. А теперь эксперимент: закомментируйте close(ch) и запустите снова. Цикл вычтет три задачи и зависнет в ожидании четвёртого — дедлок, прерванный лимитом времени. range без close не заканчивается никогда.

```go
package main

import "fmt"

func main() {
        ch := make(chan int, 3)
        ch <- 1
        ch <- 2
        ch <- 3
        close(ch) // сигнал: задач больше не будет
        for v := range ch {
                fmt.Println("задача", v)
        }
        fmt.Println("канал пуст и закрыт — цикл закончен")
}
```

> **Честно о двух движках песочницы.** Кнопка «Запустить» в этом уроке работает на основном движке — настоящем Go (yaegi, собран в WASM), и все канальные примеры исполняются там честно, как в локальном go run. Если WASM не загрузился, песочница включает резервный упрощённый движок, и вот там каналы урезаны: буферизованный make и отправка/приём с направленными типами работают, len показывает очередь, но встроенная функция close отсутствует, а приём из пустого незабуферизованного канала висит без тайм-лимита. Поэтому runner-ы урока — рандеву, close, range — проверяйте в основном движке; на резервном они честно не запустятся.

### Проверьте себя

**Вопрос:** В буферизованный канал ёмкостью 2 положили одно значение и закрыли канал. Что вернёт первый же приём v, ok := <-ch?

- v = нулевое значение, ok = false: канал ведь закрыт
- ✅ v = положенное значение, ok = true: закрытие не стирает буфер
- Панику: читать закрытый канал нельзя
- Блокировку: закрытый канал всегда блокирует

close запрещает только новые отправки. Всё, что уже лежало в буфере, приём получает как обычно, с ok = true; флаг false появится на приёме после опустошения. Читать закрытый канал безопасно — паникует лишь отправка в закрытый канал и повторный close.

### Что выведет код?

```go
package main

import "fmt"

func main() {
        ch := make(chan string)
        go func() {
                ch <- "привет"
                ch <- "пока"
        }()
        fmt.Println(<-ch)
        fmt.Println(<-ch)
}
```

**Что выведет программа?**

- ✅ привет, потом пока — строго в этом порядке
- пока, потом привет
- Только «привет»: вторая отправка потеряется
- Программа зависнет: незабуферизованный канал

Два приёма в main — два рандеву. Каждая отправка из горутины встречается со своим приёмом: значение передаётся из руки в руки, поэтому порядок приёма повторяет порядок отправки. Запустите в основном движке — вывод подтвердится дословно.

### Направления каналов: chan<- и <-chan

Канал — двусторонняя труба, но хорошая функция должна признаваться, какой стороной она пользуется. Для этого у типа канала есть направления: `chan<- int` — «только отправка» (send-only), `<-chan int` — «только приём» (receive-only). Стрелка смотрит в сторону канала — значит, можно отправлять; стрелка смотрит из канала — можно принимать. Внутри функции с таким параметром компилятор не даст сделать лишнее: из send-only канала не прочитать, в receive-only не записать и его не закрыть.

Направленные типы — это документация, которую проверяет компилятор. Сигнатура `func producer(out chan<- int)` сразу говорит: эта функция только пишет. `func consume(in <-chan int) int` — только читает. Если вы по ошибке попытаетесь принять в producer-е, программа не соберётся — ошибка найдена в момент компиляции, а не в трёх ночи в продакшене. Обычный двунаправленный канал `chan int` неявно превращается в направленный при передаче: отправить канал в функцию с параметром chan<- можно, обратно — нет.

Функция put в примере ниже объявлена send-only, take — receive-only, а main владеет двунаправленным каналом и раздаёт его сторонам. Это стандартный скелет конвейера: владелец канала делает make и close, посредники получают направления.

### Направления в сигнатурах: put и take

```go
package main

import "fmt"

// put умеет только отправлять: chan<- не даст даже прочитать
func put(ch chan<- int) {
        ch <- 9
}

// take умеет только принимать: <-chan не даст записать или закрыть
func take(ch <-chan int) int {
        v := <-ch
        return v
}

func main() {
        ch := make(chan int, 1) // двунаправленный канал в main
        go put(ch)
        v := take(ch)
        fmt.Println("передано:", v)
}
```

Скомпилируется и запустится: main передал двунаправленный канал в put как chan<- и в take как <-chan — сужение направлений неявно и безопасно. Попробуйте убрать буфер (make(chan int)) и добавить close или приём внутри put — компилятор откажет в каждом случае.

### Дедлок: отправка без принимающего

Все блокировки каналов добрые, пока есть вторая сторона. Уберите её — и блокировка станет навсегда: дедлоком. Классическая ошибка новичка — отправить значение в незабуферизованный канал из main и надеяться, что «значение где-то сохранится»: `ch := make(chan int); ch <- 1; fmt.Println(<-ch)`. Первая же строка ch <- 1 блокирует main навсегда: принимающего нет, а до приёма в третьей строке main не дойдёт — он стоит во второй. Одна горутина не может одновременно отправлять и принимать из одного незабуферизованного канала: рандеву требует двоих.

Рантайм распознаёт ситуацию, когда все горутины спят, — и паникует с «fatal error: all goroutines are asleep - deadlock!». В песочнице вы увидите честное прерывание по лимиту времени: программа не ответила, потому что не должна была. Второй распространённый источник дедлока — обратный: приём из канала, в который никто никогда не отправит (например, отправитель уже завершился или забыл про канал). Симптом один: программа висит.

Лекарство простое, но дисциплинарное: на каждую отправку в незабуферизованный канал должен существовать приём в другой горутине (или буфер), а горутина-производитель в цикле обязана передавать переменную параметром, как учил разбор захвата в [уроке про замыкания](/courses/go-advanced/lessons/01-zamykaniya/). Если вторая сторона не гарантирована — буферизуйте, выносите отправку в горутину или пересматривайте дизайн. И держите в голове старший брат этой ошибки — nil-канал, о нём ниже.

### Что выведет код: отправка в пустоту

```go
package main

import "fmt"

func main() {
        ch := make(chan int) // незабуферизованный
        ch <- 1              // принимающего нет — main заблокирован навсегда
        fmt.Println(<-ch)    // до этой строки дело не дойдёт
}
```

**Что выведет программа?**

- 1 — значение сохранится в канале и вернётся при приёме
- ✅ Ничего: main заблокирован на отправке навсегда — дедлок
- 0 — приём вернёт нулевое значение
- Панику канала: нельзя отправлять в канал из main

main и отправляет, и должен принимать — но это одна горутина. Строка ch <- 1 ждёт принимающего, а принимающий — это следующий шаг того же стоящего main. В песочнике зависание будет прервано лимитом времени; локальный go run выпал бы с «all goroutines are asleep - deadlock!».

### nil-канал, мьютексы и что дальше

Нулевое значение канала — nil, и это не «пустой канал», а отсутствие канала как такового. И отправка `ch <- v`, и приём `<-ch` на nil-канале блокируются навсегда: горутина просто засыпает, ожидая, что кто-то когда-то подключится к трубе, которой нет. Отсюда практическое правило: забытый make — источник «зависшей» программы без единого сообщения об ошибке. Зато в конструкциях выбора (select, урок 06) nil-канал превращается в полезный инструмент: ветку с nil-каналом планировщик никогда не выбирает, и им «выключают» остывшие направления.

Как каналы соотносятся с мьютексами? Канал — «передай данные кому-то», мьютекс — «дай мне одного подойти к общим данным». Идиома языка говорит: не общайтесь, передавая право собственности, через общую память с блокировками — передавайте сами данные по каналам (do not communicate by sharing memory; share memory by communicating). Мьютекс честнее для защиты структур данных, к которым подходят многие; канал честнее для конвейеров, где данные текут от этапа к этапу. Полное сравнение и борьба с гонками — отдельный урок курса, а пока держите критерий: если вы собрались класть в канал указатель на общую структуру и править её у всех на глазах — вам нужен мьютекс, а не канал.

Дальше по курсу: select научит ждать сразу несколько каналов и строить таймауты (и спасать от утечек горутин на каналах), пул workers покажет fan-out/fan-in на каналах задач, мьютексы и sync-примитивы закроют тему общих данных. Всё это — надстройки над четырьмя операциями сегодняшнего урока.

### Соберите вычитание буфера

Восстановите программу: наполните буфер двумя значениями, закройте канал и вычтите всё циклом range.

```go
package main

import "fmt"

func main() {
        ch := make(chan int, 2)
        ch ___ 10
        ch ___ 20
        close(ch)
        for v := ___ ch {
                fmt.Println(v)
        }
}
```

Подсказка: Отправка — значение течёт в канал; range сам заканчивается на закрытом канале.

Правильные ответы в пропусках: `<-`, `<-`, `range`

### Соберите: буфер, close и приём с ok

Соберите программу: канал на одно место, отправка, закрытие отправителем и приём с проверкой флага. Проверьте себя: она печатает «5 true».

Правильный порядок строк:

1. `package main`
2. `import "fmt"`
3. `func main() {`
4. `ch := make(chan int, 1)`
5. `ch <- 5`
6. `close(ch)`
7. `v, ok := <-ch`
8. `fmt.Println(v, ok)`
9. `}`

### Карточки урока

- **Рандеву на незабуферизованном канале** — Отправка ch <- v блокирует до приёма, приём <-ch блокирует до отправки; значение передаётся в одной атомарной точке встречи. Порядок печати определяет принимающий — отсюда детерминированный вывод.
- **Буферизованный канал make(chan T, n)** — Очередь на n значений: отправка блокируется только при полном буфере, приём — только при пустом. FIFO: значения выходят в порядке входа. len(ch) — текущая длина очереди, cap(ch) — ёмкость.
- **Приём из закрытого канала: v, ok := <-ch** — Сначала вычитывается остаток буфера (ok = true), затем приём мгновенно возвращает нулевое значение и ok = false. Читать закрытый канал можно; паникуют отправка в закрытый канал и повторный close.
- **for v := range ch** — Читает канал, пока он открыт и полон; завершается, когда канал закрыт И опустошён. Останавливается только на close: без close — вечное ожидание. Канал закрывает отправитель.
- **Направления chan<- и <-chan** — chan<- T — только отправка, <-chan T — только приём. Компилятор запрещает лишние операции: из send-only нельзя читать, в receive-only нельзя писать или закрывать. Двунаправленный chan T сужается неявно.
- **Дедлок и nil-канал** — Отправка в незабуферизованный канал без принимающего (или приём без отправителя) — блокировка навсегда; рантайм ругается «all goroutines are asleep». nil-канал (var ch chan int) тоже блокирует вечно: до использования канал должен пройти make.

## Коротко: что запомнить

- Канал — типизированная труба между горутинами: make(chan T) без буфера или make(chan T, n) с очередью на n значений.
- Незабуферизованный канал — рандеву: отправка ждёт приёма, приём ждёт отправки; порядок вывода задаёт принимающий.
- Буферизованный канал — FIFO: отправителю можно убежать вперёд на n значений; блокировки возвращаются на полном или пустом буфере.
- close объявляет «данных больше не будет»; читать закрытый канал можно — остаток буфера, потом ноль и ok = false; писать в закрытый — паника.
- v, ok := <-ch отличает настоящий ноль от «данных нет»; for v := range ch вычитывает всё и завершается только на close.
- chan<- и <-chan в сигнатурах фиксируют направление, и компилятор ловит лишние операции; канал закрывает отправитель.
- Отправка без принимающего и nil-канал — дедлок навсегда: на каждую отправку нужен приём в другой горутине, а канал обязан пройти make.

## Домашнее задание

Соберите конвейер из трёх горутин: первая — производитель, кладёт в незабуферизованный канал пять чисел и закрывает его; вторая — потребитель с направленным параметром <-chan int, читает через for v := range ch, возводит в квадрат и отправляет в выходной канал chan<- int; третья роль у main — приём из выходного канала и печать. Добейтесь того, чтобы вывод был одинаков при каждом запуске, а затем сломайте программу: уберите close у производителя и посмотрите, как range зависнет до лимита времени.

## Вопросы и ответы по уроку

### Чем незабуферизованный канал отличается от буферизованного простыми словами?

Незабуферизованный — передача из руки в руки: отправитель не освобождается, пока получатель не принял, и это готовая точка синхронизации. Буферизованный — очередь: отправитель кладёт значение и уходит, встреча нужна только когда буфер полон или пуст.

### Почему нельзя закрыть канал со стороны принимающего?

Закрытие — обещание отправителя «больше ничего не пришлю». Принимающий этого обещать не может: отправители могут писать в канал прямо сейчас, и close под ними вызовет панику send on closed channel. Правило языка: канал закрывает та сторона, которая отправляет (или единственный владелец-координатор).

### Что вернёт приём из закрытого пустого канала?

Мгновенно нулевое значение типа и ok = false: v, ok := <-ch даст 0 и false для chan int. Именно поэтому флаг ok обязателен, когда нулевое значение — легитимные данные. Цикл range на закрытом канале после опустошения просто завершится, не вернув ничего.

### Как понять, что программа дедлокнула на канале?

Она замолкает: ни вывода, ни ошибки, загрузка процессора нулевая. Локальный go run при полном затыке всех горутин напишет «fatal error: all goroutines are asleep - deadlock!», в песочнице сработает лимит времени. Ищите отправку без приёма, приём без отправителя, nil-канал без make и range без close.
