Содержание урока · 13 разделов
- Каналы go: труба между горутинами
- make(chan T): создать канал и четыре операции
- Рандеву: кто кого ждёт
- Буфер: очередь, которая сглаживает пики
- Три состояния канала: шпаргалка по блокировкам
- Приём из закрытого канала: v, ok := <-ch
- range по каналу: вычитать всё и выйти
- Направления каналов: chan<- и <-chan
- Дедлок: отправка без принимающего
- nil-канал, мьютексы и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Каналы go: труба между горутинами#
В прошлом уроке горутины научились работать параллельно, но обмениваться словами могли только через общий срез и общий счётчик — а это прямая дорога к гонкам данных. Каналы go решают проблему обмена честно: это типизированная труба, у которой есть один отправитель и один получатель, и значение проходит по ней строго по одному — как монетка в щели копилки. Никакая горутина не трогает чужую память: она просто кладёт значение в канал или достаёт из него. В англоязычных источниках это слово пишется латиницей — channel, и по запросу channels golang вы найдёте те же самые механизмы, что разберём ниже.
Канал типизирован: chan int возит только int, chan string — только строки. Смешать типы не выйдет, компилятор поймает на месте. И канал — это первое значение в курсе, которое можно не только создавать и читать, но и «закрывать», поэтому у него богатая жизнь: рождение через make, работа через отправку и приём, смерть через close.
По курсу продвинутых возможностей это центральный урок про конкурентность: 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 не пришёл за значением.
Запустите код, чтобы увидеть вывод.
Рандеву: кто кого ждёт#
Незабуферизованный канал — это рандеву, «встреча из руки в руки». Отправка ch <- v блокирует горутину до тех пор, пока кто-то не примет значение: нет принимающего — отправитель стоит. Приём <-ch блокирует до тех пор, пока кто-то не отправит: нет отправителя — приёмник стоит. Встреча происходит атомарно: в момент рандеву значение передаётся, обе стороны разблокируются и идут дальше.
Из этого следует неожиданное на первый взгляд свойство: порядок печати в программе с каналами определяет принимающий, а не отправляющий. В прошлом уроке мы добивались детерминированного вывода через WaitGroup и обсуждали, что планировщик ничего не обещает; здесь гарантия получается бесплатно. Горутина, чья строка стоит после отправки, не напечатает ничего, пока main не заберёт мяч — значит, первым на экране всегда будет вывод принимающего. Именно поэтому пример выше — не «повезло с таймингом», а конструкция, которую можно повторять бесконечно.
Рандеву — это и синхронизация, и передача в одном флаконе: v := <-ch вернулся — значит, отправитель уже дошёл до этой точки. Такая гарантия «случилось перед» не даётся ни sleep-ами, ни надеждой на планировщик, и она же спасает от большинства гонок при обмене данными.
Буферизованный канал: очередь на n мест
GOch := 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) показывает, сколько значений стоит в очереди прямо сейчас.
Запустите код, чтобы увидеть вывод.
Буфер: очередь, которая сглаживает пики#
Буферизованный канал — это очередь с ограниченной ёмкостью: отправители кладут в хвост, получатели забирают из головы, и пока очередь неполна, стороны не встречаются вовсе. Это меняет характер синхронизации: незабуферизованный канал заставляет горутины идти нога в ногу, буферизованный позволяет отправителю убежать вперёд — но не более чем на n значений. Как только буфер заполняется, канал снова становится точкой встречи: отправка ждёт, пока принимающий не разгрузит очередь.
Зачем это нужно? Чтобы разнести по времени быстрых и медленных. Производитель кладёт задачи в буфер и не простаивает, пока потребитель обрабатывает предыдущую; буфер на пару значений сглаживает мелкие пики. Но ёмкость — не лекарство от всего: если потребитель в принципе медленнее производителя, любой буфер когда-нибудь наполнится, и производитель снова заблокируется. Правильный размер буфера — инженерное решение, а не заклинание «поставил побольше».
Есть и удобные встроенные помощники: len(ch) — сколько значений в очереди сейчас, cap(ch) — сколько мест всего. Полезно помнить, что len меняется под ногами у конкурентных горутин: для отладки и логов он хорош, для управления логикой программы — уже рискован (это ближе к теме мьютексов и гонок).
Три состояния канала: шпаргалка по блокировкам#
| Состояние | Отправка 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 там же, где создаётся канал, — механика «при любом выходе» та же, что у wg.Done из прошлого урока.
Правила приёма из закрытого канала двухступенчатые. Сначала вычитывается всё, что осталось в буфере (и что «застряло» в рандеву), — обычные значения, как ни в чём не бывало. Когда буфер пуст, приём возвращает мгновенно нулевое значение типа: 0 для int, "" для string, nil для указателей. Вторая ступень потому и опасна: без проверки вы получите нули и примете их за настоящие данные.
Поэтому у приёма есть двухзначная форма: v, ok := <-ch — та же конструкция «значение и флаг», что у чтения из карт. ok — это true, пока канал открыт (или пока из буфера есть что вычитать), и false, когда канал закрыт и опустошён. Проверка ok — единственный способ отличить «настоящий ноль из канала» от «нуля, потому что данных больше нет».
Закрытый канал: второе значение ok
Запустите и прочитайте вывод построчно. Первый приём вытащил 7 из буфера — флаг ok равен true, канал-то закрыт, но данные ещё были. Второй приём вернул нулевое значение и ok = false: опустошённый закрытый канал больше не блокирует. Именно 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) случается только при отправке в закрытый канал и при повторном close. Запомните таблицу асимметрий: читать закрытый — можно и нужно, писать в закрытый — нельзя никогда.
range: вычитать до закрытия
Запустите. Цикл вычел три значения из буфера и завершился сам — без счётчика, без проверки ok, без лишнего приёма. А теперь эксперимент: закомментируйте close(ch) и запустите снова. Цикл вычтет три задачи и зависнет в ожидании четвёртого — дедлок, прерванный лимитом времени. range без close не заканчивается никогда.
Запустите код, чтобы увидеть вывод.
Кнопка «Запустить» в этом уроке работает на основном движке — настоящем Go (yaegi, собран в WASM), и все канальные примеры исполняются там честно, как в локальном go run. Если WASM не загрузился, песочница включает резервный упрощённый движок, и вот там каналы урезаны: буферизованный make и отправка/приём с направленными типами работают, len показывает очередь, но встроенная функция close отсутствует, а приём из пустого незабуферизованного канала висит без тайм-лимита. Поэтому runner-ы урока — рандеву, close, range — проверяйте в основном движке; на резервном они честно не запустятся.
Проверьте себя
В буферизованный канал ёмкостью 2 положили одно значение и закрыли канал. Что вернёт первый же приём v, ok := <-ch?
Что выведет код?
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "привет"
ch <- "пока"
}()
fmt.Println(<-ch)
fmt.Println(<-ch)
}Направления каналов: 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
GOpackage 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!». В песочнице вы увидите честное прерывание по лимиту времени: программа не ответила, потому что не должна была. Второй распространённый источник дедлока — обратный: приём из канала, в который никто никогда не отправит (например, отправитель уже завершился или забыл про канал). Симптом один: программа висит.
Лекарство простое, но дисциплинарное: на каждую отправку в незабуферизованный канал должен существовать приём в другой горутине (или буфер), а горутина-производитель в цикле обязана передавать переменную параметром, как учил разбор захвата в уроке про замыкания. Если вторая сторона не гарантирована — буферизуйте, выносите отправку в горутину или пересматривайте дизайн. И держите в голове старший брат этой ошибки — nil-канал, о нём ниже.
Что выведет код: отправка в пустоту
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
func main() {
ch := make(chan int) // незабуферизованный
ch <- 1 // принимающего нет — main заблокирован навсегда
fmt.Println(<-ch) // до этой строки дело не дойдёт
}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.
Соберите: буфер, close и приём с ok
Соберите программу: канал на одно место, отправка, закрытие отправителем и приём с проверкой флага. Проверьте себя: она печатает «5 true».
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Канал — типизированная труба между горутинами: 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией