Канал в Go: значение мимо буфера, select без приоритетов и цена, которую платят парковкой
Собеседование по каналам идёт лестницей: что лежит в переменной — куда попадает значение — что будет с закрытым и nil-каналом — как выбирает select — во что обходится буфер — где утекают горутины. Урок проходит её целиком: почему при ждущем получателе значение идёт мимо буфера, почему у ветки отмены в select нет приоритета и почему канал без буфера дороже втрое — но не потому, что «канал медленный».
Полное техническое изложение
TL;DR
Канал — это связь двух горутин: одна отправляет значение, другая его получает. Пока получателя нет, отправитель ждёт; пока значения нет, ждёт получатель. Из этого «ждёт» вырастает вся тема: канал в Go — не столько способ передать данные, сколько способ договориться о моменте.
Отсюда главное следствие: буфер не убирает ожидание, а отсрочивает его — и
ровно на свою вместимость. А когда получатель уже ждёт, значение идёт мимо
буфера, прямо ему; это видно из программы: len(ch) остаётся нулём. Цена
канала без буфера — не «медленный канал», а остановка и пробуждение горутин:
измерено 198 391 нс против 64 570 на тысяче значений, в 3,1 раза.
Дальше — то, что отличает знающего от читавшего. У select нет
приоритета по порядку веток: из готовых выбирается равномерно случайная, и
ставить case <-ctx.Done() первым бесполезно. Из закрытого канала можно
читать: сначала дочитываются уже положенные значения, и только потом идёт
нулевое с ok == false; писать в закрытый и закрывать дважды — паника.
nil-канал блокируется навсегда — и это инструмент: ветка select с ним
никогда не выбирается. Под всем этим лежит структура рантайма — буфер, мьютекс
и две очереди ожидающих горутин, — и chan T есть указатель на неё:
поэтому канал, переданный в функцию, — тот же канал, а nil-канал — просто
указатель в никуда. И последнее, о чём чаще всего забывают: горутина,
заблокированная на канале навсегда, не собирается сборщиком мусора. Это
самая обычная утечка в Go, и go test -race её не находит.
- горутина — отдельная работа, которая идёт своим ходом, пока идёт вся остальная программа;
- двум горутинам бывает нужно друг от друга значение: одной есть что передать, другой это нужно получить;
- горутины доходят до нужной строки не одновременно, и кому-то придётся подождать.
- устройство канала внутри: кольцевой буфер, мьютекс, очереди ожидающих и парковка горутин;
select,default,close,nil-канал,lenиcapу канала;- что такое happens-before и что канал гарантирует про память.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Что такое канал?» — проверяют, скажете ли вы «указатель на структуру с мьютексом» или ограничитесь словом «труба».
- «Чем отличается буферизованный от небуферизованного?» — проверяют, понимаете ли вы, что дело в парковке, а не в скорости.
- «Что будет, если писать в закрытый канал? А читать? А из nil?» — та самая таблица из двенадцати клеток.
- «Как select выбирает ветку?» — вопрос-ловушка: не сверху вниз.
- «Где здесь утечка?» (с кодом) — проверяют, знаете ли вы, что горутина на заблокированном канале живёт вечно.
Дальше урок идёт по этой лестнице.
База: одна горутина отправила, другая получила
Самая простая модель канала — та, с которой стоит начинать и ответ, — состоит из двух действий, и больше в ней нет ничего.
Есть две горутины. Одна доходит до строки ch <- v и отправляет значение.
Другая доходит до <-ch и получает его. Значение переходит от первой ко
второй, и обе идут дальше своими делами.
ch := make(chan int)
go func() { ch <- 42 }() // одна отправила
v := <-ch // другая получилаКанал здесь — не переменная, которую читают и пишут, а место встречи: пока никто не пришёл с другой стороны, значение через него не проходит.
И вот главный вопрос урока: что происходит, когда отправка и приём не
совпали во времени? Горутины идут своим ходом, и до своих строк они доходят
когда придётся: отправитель может оказаться у ch <- v за секунду до того, как
получатель дойдёт до <-ch, — или на минуту позже.
Ответ простой: тот, кто пришёл первым, ждёт второго. Отправитель, которому некому передать значение, останавливается и стоит, пока не появится получатель; получатель, которому нечего забрать, стоит, пока не появится значение.
На этом «ждёт» и держится всё остальное в теме. Буфер — способ отложить
ожидание отправителя. select — способ ждать сразу нескольких каналов.
Закрытие — способ сказать всем ждущим, что ждать больше нечего.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования «что такое
канал». Дальше — про то, как канал ведёт себя в остальных своих состояниях
(буфер полон, канал закрыт, канала нет вовсе), почему у select нет порядка,
чего стоит ожидание и где на нём утекают горутины.
Механизм 1: таблица поведения — вся тема на одном экране
Канал стоит разбирать не с устройства, а с того, что он делает в каждом из своих состояний. Это буквально вопрос собеседования, и списком он не запоминается — зато запоминается таблицей, потому что клетки в ней связаны.
Вот та же пара действий из «Базы», разложенная по состояниям канала:
| состояние канала | ch <- v — отправка | <-ch — получение |
|---|---|---|
| небуферизованный | ждёт получателя; передача происходит в момент встречи | ждёт отправителя |
| буферизованный, есть свободное место | кладёт значение и идёт дальше | забирает значение; если буфер пуст — ждёт |
| буферизованный, заполнен | ждёт, пока освободится место | забирает значение и освобождает место |
| закрытый | паника | не ждёт: сначала выдаёт положенные значения, потом нулевое с ok == false |
nil (канал не создан) | блокируется навсегда | блокируется навсегда |
Читается она по строкам, и каждая строка — отдельный вопрос собеседования.
Третья операция, close, добавляет к этому ещё одно измерение; полная таблица
из двенадцати клеток разобрана на картинке. Нажимайте клетки — разбор под
таблицей объясняет, почему именно так:
Из двенадцати клеток стоит выучить не двенадцать фактов, а два правила:
- Паникуют только запись и закрытие, и только там, где канала нет или он
уже закрыт. Три паники:
send on closed,close of closed,close of nil. - Чтение не паникует никогда. Ровно поэтому закрытие годится как широковещательный сигнал: все ждущие читатели просыпаются разом.
И одна клетка, которую забывают чаще всех: закрытие не выбрасывает то, что
уже лежит в буфере. Спецификация говорит это дословно — приём из закрытого
канала выдаёт нулевое значение after any previously sent values have been
received
(после того, как получены все ранее отправленные значения). Сначала дочитываются положенные значения с ok == true, и только
потом начинается нулевое с false. На этом построена задача в конце урока.
nil-канал — не ошибка, а инструмент. Раз ветка select с nil-каналом
никогда не выбирается, обнуление канала — штатный способ выключить ветку:
for {
select {
case v, ok := <-in:
if !ok {
in = nil // ветка выключена, select живёт дальше
continue
}
// …
case <-ctx.Done():
return
}
}Без этого приёма закрытый канал крутил бы select вхолостую: чтение из него
готово всегда.
Механизм 2: небуферизованный канал — это рандеву
Из таблицы видно главное свойство небуферизованного канала: отправка и приём происходят одновременно или не происходят вовсе. Это не «отправитель положил, получатель потом забрал» — это встреча:
Отсюда практическое, ради чего небуферизованный канал и берут: он даёт не
передачу данных, а согласование момента. Отправитель, вернувшийся из
ch <- v, знает, что получатель уже здесь.
И отсюда же то, что удивляет при разборе устройства.
Канал почти всегда рисуют трубой: отправитель кладёт в буфер, получатель забирает из буфера. В самом частом случае это неверно.
Если получатель уже ждёт, значение идёт мимо буфера — копируется прямо из стека отправителя в стек получателя. Буфер не участвует вовсе.
Это не абстракция про реализацию: она наблюдаема из программы. Если бы
значение легло в буфер, len(ch) стал бы единицей. Он остаётся нулём.
Механизм 3: буферизованный канал — ограниченная очередь
Буферизованный канал ведёт себя иначе, и разница описывается одной фразой: это очередь с фиксированной вместимостью, а блокировка наступает только на её границах.
Отсюда правильный ответ на вопрос «зачем тогда буфер». Буфер нужен не для скорости передачи, а чтобы отправителю не приходилось ждать получателя. Он не убирает блокировку — он её отсрочивает ровно на свою вместимость.
Отсюда же два практических правила, которые стоит назвать вместе. Буфер размером в один почти всегда бессмыслен: он снимает ожидание только у первого значения. Очень большой буфер вреден иначе: он прячет то, что получатель не справляется, и превращает быстрый отказ в медленный рост памяти. Осмысленных размеров два — под всю партию, если она конечна и известна, и под ожидаемый всплеск, если это очередь.
Механизм 4: select не смотрит ветки сверху вниз
Самая частая правка на код-ревью: «поставь case <-ctx.Done() первым, чтобы
отмена срабатывала раньше». Она не делает ничего.
Спецификация: если готовы несколько операций, a single one that can proceed
is chosen via a uniform pseudo-random selection
(одна из готовых выбирается равномерным псевдослучайным образом). Порядок веток в исходнике
не значит ничего.
Когда отменённый контекст и готовая работа соревнуются, отмена выигрывает ровно
в половине случаев — не чаще. Если отмена действительно должна иметь приоритет,
её проверяют отдельным select перед основным:
select {
case <-ctx.Done():
return ctx.Err()
default:
}Ещё две вещи про select, которые спрашивают следом:
defaultпревращаетselectв неблокирующий. Без негоselectбез готовых веток ждёт; с ним — уходит сразу.select {}без веток блокируется навсегда. Это не ошибка компиляции, а идиома «занять текущую горутину навсегда».
Механизм 5: где утекают горутины
Классический вопрос «найдите утечку», и код обычно такой:
func find(ctx context.Context) int {
ch := make(chan int) // без буфера
go func() { ch <- search() }()
select {
case v := <-ch:
return v
case <-ctx.Done():
return -1 // ушли, а горутина осталась
}
}По таймауту функция возвращается — а горутина внутри навсегда застревает на
ch <- search(): получателя больше нет, буфера нет.
И такая горутина не собирается сборщиком мусора. Это ключевой факт: она жива, её стек жив, и всё, на что она ссылается, тоже. Сборщик мусора не умеет собирать заблокированные горутины — он не может знать, что разбудить их некому.
Лечится это буфером на одно значение: отправитель положит результат и уйдёт, даже если читать уже некому.
ch := make(chan int, 1)И отдельно про то, чем такое ловится. go test -race эту утечку не
находит — гонки здесь нет, всё корректно синхронизировано. Ловится она
счётом: runtime.NumGoroutine() до и после, либо профиль goroutine из
runtime/pprof, либо goleak в тестах. Ответ «поймаем детектором гонок» —
неверный, и это ровно та разница, которую вопрос проверяет.
Механизм 6: что канал гарантирует про память
Про эту сторону забывают, а спрашивают её на позициях повыше. Канал — не только транспорт, но и точка синхронизации.
- Отправка синхронизирована до завершения приёма: всё, что отправитель
записал в память до
ch <- v, получатель после<-chувидит. - Закрытие синхронизировано до приёма, вернувшего нулевое значение. Поэтому
close(done)— законный способ опубликовать данные, записанные до закрытия. - У небуферизованного канала есть и обратная гарантия: приём синхронизирован до завершения отправки. Отсюда его вторая роль — рандеву: отправитель знает, что получатель дошёл до места.
Практический вывод: close(done) как сигнал готовности не требует ни мьютекса,
ни атомиков вокруг данных, которые он публикует.
Глубже: что под этим в рантайме
Всё предыдущее описывало, что канал делает, и этого хватает, чтобы отвечать верно. Дальше — как он сделан внутри; здесь меняется сорт утверждений, и всё ниже относится к конкретной версии рантайма.
chan T — это указатель на структуру рантайма. Внутри неё: кольцевой буфер
(если он заказан), мьютекс и две очереди ожидающих горутин — те, кто ждёт
отправки, и те, кто ждёт приёма.
Из этого следует сразу несколько ответов:
- Канал, переданный в функцию, — тот же канал. Копируется указатель, а
структура за ним одна. Никаких
*chan Tне нужно. - Канал внутри защищён мьютексом. «Канал — это lock-free» неверно: каждая операция берёт мьютекс структуры. Быстро он не поэтому.
nil-канал — это указатель в никуда. Ни буфера, ни очередей: положить значение некуда, разбудить некого. Отсюда всё его поведение.
Стоит сказать вслух и то, чем канал не является. Канал — не очередь
сообщений и не средство ускорить передачу данных: две горутины через канал
всегда медленнее, чем одна без него. Канал покупает синхронизацию, и модель
памяти формулирует это прямо: A send on a channel is
synchronized before the completion of the corresponding receive from that
channel
(Отправка в канал синхронизирована до завершения соответствующего приёма из этого канала). То есть всё, что отправитель записал до отправки, получатель после
приёма гарантированно увидит.
Цена — это парковка, а не «канал»
И прежде чем смотреть на числа — одно различение, без которого «горутина заблокировалась» звучит страшнее, чем есть.
Заблокированная горутина не означает заблокированный поток ОС. Парковка —
операция планировщика внутри процесса: горутина снимается с P, на её место
встаёт другая, поток продолжает работать. Ядро об этом не знает и не участвует.
Именно поэтому «тысяча горутин ждёт на каналах» — нормальное состояние
программы, а не тысяча простаивающих потоков.
Здесь важно не перепутать причину. Небуферизованный канал действительно дороже, но дорожает не «канал», а парковка горутины: отправитель останавливается, планировщик снимает его с процессора и ставит другого, потом обратно.
Измерено на передаче тысячи значений между двумя горутинами:
| время | |
|---|---|
make(chan int) | 198 391 нс |
make(chan int, 1000) | 64 570 нс |
| кратность | 3,1 |
Три раза — это не «канал медленный», это тысяча парковок против нуля. Проверить причину легко: сделайте буфер меньше партии, и парковки вернутся пропорционально.
И граница у этого числа названа тут же: «3,1» получены на одной машине, двух ядрах и одной версии Go. Кратность зависит от того, сколько горутин выполняется одновременно и попали ли отправитель с получателем на одно ядро, — то есть переносить отсюда нужно причину, а не число. Правило пишется так: цена канала растёт вместе с числом парковок, и мерить её нужно на своей нагрузке.
Как отвечать на собеседовании
Короткий ответ: канал — это связь двух горутин, в которой отправка и приём
встречаются: пока получателя нет, отправитель ждёт, и наоборот. Буфер это
ожидание не убирает, а отсрочивает ровно на свою вместимость. Из закрытого
канала читать можно — сначала дочитываются положенные значения; писать в него и
закрывать его повторно нельзя. А ветки select выбираются равномерно случайно,
а не сверху вниз.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «что такое канал» отвечайте структурой. «Указатель на структуру с
буфером, мьютексом и двумя очередями ожидающих». Дальше почти всё выводится
отсюда, включая поведение nil-канала.
Про буфер говорите «отсрочка», а не «ускорение». «Буфер не убирает блокировку, а отсрочивает её на свою вместимость; цена небуферизованного — это парковки, у меня выходило втрое на тысяче значений». Второе предложение превращает заученное в проверенное.
Таблицу давайте двумя правилами. «Паникуют только запись и закрытие; чтение не паникует никогда, поэтому закрытие и работает как сигнал». Двенадцать клеток по памяти перечисляют немногие, а два правила восстанавливают их все.
Про select скажите «равномерно случайно». И добавьте, что порядок веток
ничего не значит, а приоритет отмены делается отдельным select с default.
Это распознаётся как опыт мгновенно.
Про утечку назовите и причину, и инструмент. «Заблокированная горутина не
собирается; -race её не найдёт, ловится счётом горутин или профилем
goroutine». Половина кандидатов отвечает «поймает race detector».
Дальше спросят
Кто должен закрывать канал и почему именно он?
Отправитель — потому что только он знает, что значений больше не будет.
Получатель этого знать не может в принципе, а закрытие из получателя приводит к
панике send on closed channel у отправителя, который ещё пишет.
Если отправителей несколько, закрытие выносится наружу: обычно sync.WaitGroup
на всех отправителей и отдельная горутина, которая после Wait() закрывает
канал. Проверка «а не закрыт ли уже» не работает — между проверкой и закрытием
успевает вклиниться другой.
Как отличить «пришёл ноль» от «канал закрыт»?
Только формой с двумя результатами: v, ok := <-ch. При закрытом канале ok
равен false, при полученном нулевом значении — true. Одноместная форма их не
различает, и это источник тихих багов в каналах чисел.
В for range ch различать не нужно: цикл сам заканчивается на закрытии. Но и
здесь есть ловушка — range по каналу, который никто не закроет, не кончится
никогда, и горутина повиснет.
Буфер какого размера брать?
Осмысленных ответов два, и оба про смысл, а не про число. Если партия конечна и известна — буфер под всю партию: тогда парковок нет вовсе. Если это очередь — буфер под ожидаемый всплеск, чтобы короткий пик не блокировал отправителей.
Плохих ответов тоже два. Буфер на единицу почти всегда бессмысленный: он снимает парковку у одного значения. Очень большой буфер вреден иначе — он прячет то, что получатель не справляется, и превращает быстрый отказ в тихий рост памяти.
Канал или мьютекс — что брать?
Ориентир такой: канал — когда данные передаются между горутинами, мьютекс —
когда состояние разделяется. Счётчик, к которому все ходят, — это мьютекс
или atomic; конвейер, где значение переходит от стадии к стадии, — канал.
Формулу share memory by communicating
(разделяйте память, общаясь) полезно уметь и оспорить: у канала есть
своя цена — мьютекс внутри плюс парковки, — и на простом счётчике он проигрывает
атомарной операции на порядок. Ответ «каналы всегда лучше, это же Go» — не тот,
которого ждут.
Что будет, если все горутины заблокируются на каналах?
Рантайм это замечает и печатает fatal error: all goroutines are asleep - deadlock!. Но заметить он может только полную остановку: если хоть одна
горутина работает — например, таймер или HTTP-сервер, — заблокированные
остаются висеть молча.
Отсюда важное для практики: в реальном сервисе дедлок почти никогда не выглядит
как fatal error. Он выглядит как растущее число горутин, и увидеть его можно
только профилем.
Почему select с одной готовой веткой и default иногда выбирает default?
Не иногда, а никогда: если хоть одна ветка готова, default не выбирается —
он берётся только когда не готова ни одна. Путаница обычно из другого: ветка
считается готовой в момент проверки, а между проверкой и выбором состояние
успевает измениться в другой горутине.
Практический вывод: select с default — это снимок состояния, а не гарантия.
Строить на нём «если канал пуст, сделаем X» надёжно нельзя.
Частые заблуждения
значение при отправке кладётся в буфер, а получатель забирает его оттуда
Только если получателя ещё нет. Когда получатель уже ждёт, значение копируется прямо в его стек, минуя буфер, — и это наблюдаемо: len(ch) остаётся нулём. Отсюда правильный ответ на «зачем буфер»: не для скорости передачи, а чтобы отправителю не приходилось ждать.
буфер убирает блокировку
Он её отсрочивает ровно на свою вместимость. Как только буфер полон, отправитель паркуется точно так же. Поэтому буфер на единицу почти всегда бессмысленный, а очень большой вреден: он прячет то, что получатель не справляется, превращая быстрый отказ в рост памяти.
небуферизованный канал медленный, потому что канал медленный
Дорожает не канал, а парковка горутины: остановка отправителя и пробуждение получателя через планировщик. Измерено на тысяче значений: 198 391 нс без буфера против 64 570 с буфером на всю партию — в 3,1 раза. Уменьшите буфер ниже размера партии, и парковки вернутся пропорционально.
в select ветки проверяются сверху вниз, поэтому отмену ставят первой
Порядок веток не значит ничего: спецификация требует выбирать из готовых via a uniform pseudo-random selection
(равномерным псевдослучайным образом). Отменённый контекст против готовой работы выигрывает ровно в половине случаев. Приоритет отмены делается отдельным select с default перед основным.
закрытие канала выбрасывает то, что в нём лежало
Не выбрасывает. Спецификация: приём из закрытого канала выдаёт нулевое значение after any previously sent values have been received
(после того, как получены все ранее отправленные значения). Сначала дочитываются все положенные значения с ok == true, и только потом начинается нулевое с false.
nil-канал — это ошибка, его надо проверять
Это инструмент. Ветка select с nil-каналом никогда не выбирается, поэтому обнуление канала — штатный способ выключить ветку, не трогая сам select. Без этого приёма закрытый канал крутил бы цикл вхолостую: чтение из него готово всегда.
горутину, застрявшую на канале, соберёт сборщик мусора
Не соберёт. Заблокированная горутина жива, её стек жив, и всё, на что она ссылается, тоже: рантайм не может знать, что разбудить её некому. Это самая обычная утечка в Go, и go test -race её не находит — гонки здесь нет. Ловится счётом горутин или профилем goroutine.
каналы всегда лучше мьютексов — это же Go
У канала своя цена: мьютекс внутри структуры плюс парковки. На разделяемом счётчике он проигрывает атомарной операции на порядок. Ориентир простой: канал — когда данные передаются между горутинами, мьютекс или atomic — когда состояние разделяется.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
ch := make(chan int, 2) ch <- 1 ch <- 2 close(ch) a, ok1 := <-ch b, ok2 := <-ch c, ok3 := <-ch fmt.Println(a, ok1) fmt.Println(b, ok2) fmt.Println(c, ok3) fmt.Println(len(ch), cap(ch))
Практика · оцените
Проверка знаний
Горутина отправляет в небуферизованный канал, а получатель уже ждёт на приёме. Каким станет len(ch) сразу после передачи?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Канал — это связь двух горутин: одна отправляет значение, другая его получает. Пока получателя нет, отправитель ждёт; пока значения нет, ждёт получатель. Из этого «ждёт» вырастает вся тема: канал в Go — не столько способ передать данные, сколько способ договориться о моменте.
- Отсюда главное следствие: буфер не убирает ожидание, а отсрочивает его — и ровно на свою вместимость. А когда получатель уже ждёт, значение идёт мимо буфера, прямо ему; это видно из программы:
len(ch)остаётся нулём. Цена канала без буфера — не «медленный канал», а остановка и пробуждение горутин: измерено 198 391 нс против 64 570 на тысяче значений, в 3,1 раза. - Дальше — то, что отличает знающего от читавшего. У
selectнет приоритета по порядку веток: из готовых выбирается равномерно случайная, и ставитьcase <-ctx.Done()первым бесполезно. Из закрытого канала можно читать: сначала дочитываются уже положенные значения, и только потом идёт нулевое сok == false; писать в закрытый и закрывать дважды — паника.nil-канал блокируется навсегда — и это инструмент: веткаselectс ним никогда не выбирается. Под всем этим лежит структура рантайма — буфер, мьютекс и две очереди ожидающих горутин, — иchan Tесть указатель на неё: поэтому канал, переданный в функцию, — тот же канал, аnil-канал — просто указатель в никуда. И последнее, о чём чаще всего забывают: горутина, заблокированная на канале навсегда, не собирается сборщиком мусора. Это самая обычная утечка в Go, иgo test -raceеё не находит.
На самом деле
- Только если получателя ещё нет. Когда получатель уже ждёт, значение копируется прямо в его стек, минуя буфер, — и это наблюдаемо:
len(ch)остаётся нулём. Отсюда правильный ответ на «зачем буфер»: не для скорости передачи, а чтобы отправителю не приходилось ждать. - Он её отсрочивает ровно на свою вместимость. Как только буфер полон, отправитель паркуется точно так же. Поэтому буфер на единицу почти всегда бессмысленный, а очень большой вреден: он прячет то, что получатель не справляется, превращая быстрый отказ в рост памяти.
- Дорожает не канал, а парковка горутины: остановка отправителя и пробуждение получателя через планировщик. Измерено на тысяче значений: 198 391 нс без буфера против 64 570 с буфером на всю партию — в 3,1 раза. Уменьшите буфер ниже размера партии, и парковки вернутся пропорционально.
- Порядок веток не значит ничего: спецификация требует выбирать из готовых via a uniform pseudo-random selection. Отменённый контекст против готовой работы выигрывает ровно в половине случаев. Приоритет отмены делается отдельным
selectсdefaultперед основным. - Не выбрасывает. Спецификация: приём из закрытого канала выдаёт нулевое значение after any previously sent values have been received. Сначала дочитываются все положенные значения с
ok == true, и только потом начинается нулевое сfalse. - Это инструмент. Ветка
selectсnil-каналом никогда не выбирается, поэтому обнуление канала — штатный способ выключить ветку, не трогая самselect. Без этого приёма закрытый канал крутил бы цикл вхолостую: чтение из него готово всегда. - Не соберёт. Заблокированная горутина жива, её стек жив, и всё, на что она ссылается, тоже: рантайм не может знать, что разбудить её некому. Это самая обычная утечка в Go, и
go test -raceеё не находит — гонки здесь нет. Ловится счётом горутин или профилемgoroutine. - У канала своя цена: мьютекс внутри структуры плюс парковки. На разделяемом счётчике он проигрывает атомарной операции на порядок. Ориентир простой: канал — когда данные передаются между горутинами, мьютекс или
atomic— когда состояние разделяется.
Что разобрано
- Что здесь на самом деле спрашивают
- База: одна горутина отправила, другая получила
- Механизм 1: таблица поведения — вся тема на одном экране
- Механизм 2: небуферизованный канал — это рандеву
- Механизм 3: буферизованный канал — ограниченная очередь
- Механизм 4: select не смотрит ветки сверху вниз
- Механизм 5: где утекают горутины
- Механизм 6: что канал гарантирует про память
- Глубже: что под этим в рантайме
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
3 ИСТОЧНИКА
- Спецификация Go — Channel types, Send statements, Receive operator, CloseОфициальная документация. Поведение, из которого собрана таблица «состояние × операция». Про отправку: «A send on a nil channel blocks forever» (Отправка в nil-канал блокируется навсегда) и «A send on a closed channel proceeds by causing a run-time panic» (Отправка в закрытый канал приводит к панике во время выполнения). Про приём: «Receiving from a nil channel blocks forever» (Приём из nil-канала блокируется навсегда) и «A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value after any previously sent values have been received» (Операция приёма из закрытого канала всегда может выполниться немедленно, выдавая нулевое значение типа элемента после того, как получены все ранее отправленные значения). Последняя фраза — та, о которой чаще всего забывают: сначала дочитываются положенные значения. Про закрытие: «Closing a nil channel or closing a channel that has already been closed causes a run-time panic» (Закрытие nil-канала или уже закрытого канала вызывает панику во время выполнения). И про select: «If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection» (Если готовы одна или несколько операций обмена, одна из готовых выбирается равномерным псевдослучайным образом).https://go.dev/ref/spec#Channel_types
- The Go Memory Model — Channel communicationОфициальная документация. Гарантии, ради которых канал и существует, а не только передача значений. «A send on a channel is synchronized before the completion of the corresponding receive from that channel» (Отправка в канал синхронизирована до завершения соответствующего приёма из этого канала), и про закрытие: «The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed» (Закрытие канала синхронизировано до приёма, возвращающего нулевое значение из-за того, что канал закрыт). Отдельно про небуферизованный: «A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel» (Приём из небуферизованного канала синхронизирован до завершения соответствующей отправки в этот канал).https://go.dev/ref/mem#chan
- Effective Go — ConcurrencyОфициальная документация. Формулировка, которую обычно цитируют половиной: «Do not communicate by sharing memory; instead, share memory by communicating» (Не общайтесь, разделяя память; вместо этого разделяйте память, общаясь). Там же — про то, что канал не заменяет мьютекс во всех случаях: «A buffered channel can be used like a semaphore» (Буферизованный канал можно использовать как семафор), то есть у канала есть роли помимо передачи данных.https://go.dev/doc/effective_go#concurrency