Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 5 из 25 ~24 мин +50 XP

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

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

Содержание урока · 13 разделов

Каналы 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 мест

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) показывает, сколько значений стоит в очереди прямо сейчас.

Вывод

Запустите код, чтобы увидеть вывод.

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

Буферизованный канал — это очередь с ограниченной ёмкостью: отправители кладут в хвост, получатели забирают из головы, и пока очередь неполна, стороны не встречаются вовсе. Это меняет характер синхронизации: незабуферизованный канал заставляет горутины идти нога в ногу, буферизованный позволяет отправителю убежать вперёд — но не более чем на 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 не заканчивается никогда.

Вывод

Запустите код, чтобы увидеть вывод.

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

В буферизованный канал ёмкостью 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

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!». В песочнице вы увидите честное прерывание по лимиту времени: программа не ответила, потому что не должна была. Второй распространённый источник дедлока — обратный: приём из канала, в который никто никогда не отправит (например, отправитель уже завершился или забыл про канал). Симптом один: программа висит.

Лекарство простое, но дисциплинарное: на каждую отправку в незабуферизованный канал должен существовать приём в другой горутине (или буфер), а горутина-производитель в цикле обязана передавать переменную параметром, как учил разбор захвата в уроке про замыкания. Если вторая сторона не гарантирована — буферизуйте, выносите отправку в горутину или пересматривайте дизайн. И держите в голове старший брат этой ошибки — 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.

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Рандеву на незабуферизованном канале

нажмите, чтобы перевернуть
Определение

Отправка ch <- v блокирует до приёма, приём <-ch блокирует до отправки; значение передаётся в одной атомарной точке встречи. Порядок печати определяет принимающий — отсюда детерминированный вывод.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 зависнет до лимита времени.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Каналы: обмен данными)

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

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

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

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

Закрытие — обещание отправителя «больше ничего не пришлю». Принимающий этого обещать не может: отправители могут писать в канал прямо сейчас, и 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.

Термины урока

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

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией