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

Каналы и select

Канал — это типизированная очередь с встроенной синхронизацией, реализованная в рантайме структурой hchan (см. runtime/chan.go). Переменная типа chan T — это указатель на hchan, поэтому нулевое значение канала — nil, каналы передаются в функции «по ссылке» (копируется указатель, а не очередь), и сравнивать их можно на равенство. Внутри hchan живут: кольцевой буфер buf с индексами sendx/recvx и счётчиком qcount, флаг closed, две очереди ожидающих горутин sendq/recvq (списки sudog) и обычный рантаймовый мьютекс lock. Все операции с каналом — это «взять lock, посмотреть состояние, либо сделать memmove, либо припарковать горутину».

Ключевая модель в голове: канал не «хранит указатели на данные», он копирует значения. Отправка — это memmove из памяти отправителя в буфер или сразу в стек получателя. Если в момент отправки уже кто-то ждёт в recvq, рантайм делает прямую передачу (direct send) мимо буфера: копирует значение прямо в переменную получателя и будит его через goready. Симметрично, приём из полного буфера у канала с ожидающими отправителями забирает элемент из головы буфера и тут же дописывает в хвост значение разбуженного отправителя. Блокировка горутины — это gopark: горутина уходит в состояние waiting, её M (поток ОС) освобождается и берёт другую работу, так что «заблокированная на канале горутина» не стоит ничего, кроме памяти под стек.

Второй столп — семантика состояний. Канал бывает в трёх состояниях: nil (не создан через make), открытый и закрытый; и двух форм: небуферизованный (cap == 0, рандеву — отправитель и получатель встречаются в одной точке) и буферизованный (cap > 0, асинхронный до заполнения буфера). Отсюда четыре «аксиомы каналов»: отправка в nil блокирует навсегда, чтение из nil блокирует навсегда, отправка в закрытый — паника, чтение из закрытого — мгновенно нулевое значение и ok == false. Закрытие закрытого или nil канала — тоже паника. Закрывает всегда отправитель, потому что только он знает, что данных больше не будет.

Третий столп — select. Это мультиплексор: он вычисляет операнды всех кейсов один раз слева направо, затем смотрит, какие кейсы готовы. Если готов один — выполняется он; если несколько — выбор псевдослучайный и равномерный, никакого приоритета по порядку записи нет. Если готовых нет и есть default — выполняется default (это способ сделать неблокирующую операцию). Если готовых нет и default нет — горутина паркуется сразу во все очереди ожидания, и просыпается от первого же события. Отдельно стоит помнить, что кейс с nil-каналом никогда не готов — это штатный приём «выключить ветку», и что закрытый канал наоборот готов всегда, поэтому забытый закрытый канал в select превращает цикл в busy-loop.

Что такое канал? Какие виды каналов бывают?

Заголовок раздела «Что такое канал? Какие виды каналов бывают?»

Коротко. Канал — типизированный потокобезопасный FIFO-конвейер для передачи значений между горутинами, создаётся через make(chan T) или make(chan T, n). Виды: по буферу — небуферизованные (cap == 0) и буферизованные (cap > 0); по направлению в типе — двунаправленные chan T, только для отправки chan<- T, только для приёма <-chan T; плюс три состояния — nil, открытый, закрытый.

Глубже. Направленные каналы — это не отдельная сущность в рантайме, а ограничение на уровне системы типов: двунаправленный канал неявно приводится к направленному, обратно — нет. Это способ зафиксировать контракт в сигнатуре: func producer(out chan<- int) физически не может ни читать из канала, ни его закрыть… точнее, закрыть chan<- T как раз можно (и нужно — закрывает отправитель), а вот close(<-chan T) — ошибка компиляции. Ещё иногда как «вид» называют канал chan struct{} — сигнальный канал нулевого размера элемента, где важен сам факт события, а не значение.

ch := make(chan int) // небуферизованный
buf := make(chan int, 10) // буферизованный
var nilCh chan int // nil-канал
fmt.Println(len(buf), cap(buf)) // 0 10

Что произойдет при отправке в закрытый канал?

Заголовок раздела «Что произойдет при отправке в закрытый канал?»

Коротко. Паника panic: send on closed channel. Это не возвращаемая ошибка и не «тихий no-op» — горутина падает, и если панику не перехватить recover, падает вся программа.

Глубже. Проверка closed делается под захваченным lock в runtime.chansend, так что гонки «проверил-и-отправил» не существует на уровне рантайма — но существует на уровне вашего кода: if !isClosed { ch <- v } принципиально не работает, между проверкой и отправкой канал могут закрыть. Единственное надёжное решение — организовать код так, чтобы закрывал канал только владелец-отправитель и только после того, как все отправки завершены. recover от такой паники технически возможен, но это костыль: он маскирует сломанный протокол владения каналом.

Для чего используется select в Go? Приведите пример использования.

Заголовок раздела «Для чего используется select в Go? Приведите пример использования.»

Коротко. select позволяет одной горутине ждать сразу несколько канальных операций и выполнить ту, что первой станет готова. Классические применения — отмена по ctx.Done(), таймаут, fan-in из нескольких источников, неблокирующие чтение/запись через default.

Глубже. Пример воркера с отменой, таймаутом простоя и корректным завершением по закрытию входного канала:

package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return // канал закрыт — работы больше не будет
}
select {
case results <- j * 2:
case <-ctx.Done():
return
}
case <-time.After(500 * time.Millisecond):
fmt.Println("простой")
}
}
}

Обратите внимание на вложенный select при отправке в results: без него горутина может залипнуть навсегда, если потребитель ушёл. Начиная с Go 1.23 таймерные каналы стали небуферизованными, и таймер, на который никто не ссылается, может быть собран GC до срабатывания — старая проблема «time.After в цикле течёт памятью до истечения таймаута» в основном ушла, но для длинных интервалов в горячем цикле по-прежнему честнее переиспользовать time.Timer.

Что произойдет при попытке чтения из закрытого канала? А при записи в закрытый канал?

Заголовок раздела «Что произойдет при попытке чтения из закрытого канала? А при записи в закрытый канал?»

Коротко. Чтение из закрытого канала не блокируется: сначала отдаются все значения, оставшиеся в буфере, затем — бесконечно нулевое значение типа элемента с ok == false в форме v, ok := <-ch. Запись в закрытый канал — паника send on closed channel.

Глубже. Асимметрия намеренная: закрытие — это широковещательный сигнал «данных больше не будет», и любое число получателей должно уметь его увидеть без гонок, поэтому чтение безопасно и повторяемо. А вот отправка после закрытия — это ошибка протокола: некому гарантировать доставку. По модели памяти Go закрытие канала happens-before приёма, который вернул нулевое значение из-за закрытия, — поэтому close корректно использовать как барьер публикации данных, записанных до закрытия.

Коротко. Канал — примитив коммуникации и синхронизации между горутинами: он одновременно передаёт данные и устанавливает отношение happens-before. Это практическая реализация лозунга «Do not communicate by sharing memory; instead, share memory by communicating».

Глубже. Каналы нужны там, где есть поток событий или передача владения данными: пайплайны, fan-out/fan-in, воркер-пулы, сигналы отмены и завершения, семафоры (chan struct{} с буфером), очереди задач, тайм-ауты в связке с select. Там, где нужно просто защитить общее состояние (счётчик, кэш, мапа), канал — оверинжиниринг: sync.Mutex или sync/atomic дешевле и понятнее. Хорошее эмпирическое правило из практики команды Go: каналы — для передачи владения и координации, мьютексы — для защиты состояния.

В чем разница между ‘буферизированными ’и ‘небуферизированными ’каналами?

Заголовок раздела «В чем разница между ‘буферизированными ’и ‘небуферизированными ’каналами?»

Коротко. У небуферизованного (cap == 0) отправка и приём происходят одновременно — это рандеву: отправитель блокируется, пока получатель не заберёт значение, и наоборот. У буферизованного (cap > 0) отправка не блокируется, пока в буфере есть место, а приём — пока буфер непуст.

Глубже. Разница не только в блокировках, но и в гарантиях. Небуферизованный канал даёт отправителю знание о том, что значение принято (модель памяти: приём из небуферизованного канала happens-before завершения соответствующей отправки). Буферизованный такой гарантии не даёт: успешный ch <- v означает лишь «положил в буфер». Для буферизованного канала ёмкости C модель памяти утверждает, что k-й приём happens-before завершения (k+C)-й отправки — то есть буфер работает ещё и как ограничитель «забегания вперёд».

Можете описать внутреннюю структуру канала?

Заголовок раздела «Можете описать внутреннюю структуру канала?»

Коротко. Это структура runtime.hchan: кольцевой буфер buf с ёмкостью dataqsiz, счётчик элементов qcount, индексы sendx/recvx, размер и тип элемента, флаг closed, очереди ожидающих sendq/recvq из sudog и мьютекс lock. Переменная chan T — указатель на эту структуру.

Глубже. Примерный вид (runtime/chan.go, состав полей от версии к версии слегка меняется):

type hchan struct {
qcount uint // сколько элементов сейчас в буфере
dataqsiz uint // ёмкость кольцевого буфера
buf unsafe.Pointer // сам буфер
elemsize uint16
closed uint32
elemtype *_type
sendx uint // индекс записи
recvx uint // индекс чтения
recvq waitq // ожидающие получатели (список sudog)
sendq waitq // ожидающие отправители
lock mutex
}

Память под hchan и буфер выделяется одним куском в makechan, если элемент не содержит указателей. sudog — обёртка над горутиной в очереди ожидания: в ней лежит *g и адрес elem, куда/откуда копировать значение; именно благодаря elem возможна прямая передача значения из стека отправителя в стек получателя без прохода через буфер. Никакого lock-free волшебства внутри нет: доступ сериализуется обычным мьютексом рантайма, зато операции под ним максимально короткие.

Коротко. select вычисляет операнды всех кейсов один раз в порядке записи, собирает список готовых кейсов и равномерно случайно выбирает один из них. Если готовых нет — выполняется default, а если default нет, горутина паркуется во все очереди ожидания и просыпается на первом событии.

Глубже. Реализация — runtime.selectgo. Она строит два порядка обхода кейсов: случайный pollorder (чтобы выбор был честным) и lockorder по адресам каналов (чтобы не словить дедлок при захвате нескольких hchan.lock). Первый проход ищет уже готовый кейс; если такого нет и default отсутствует — на каждый канал создаётся sudog, горутина паркуется, а после пробуждения все лишние sudog вычищаются из очередей. Практические следствия: порядок кейсов в коде не даёт приоритета; select без кейсов (select {}) блокирует горутину навсегда; select с единственным default — это просто выполнение default; кейс с nil-каналом никогда не выбирается.

Что произойдет, если закрыть уже закрытый канал?

Заголовок раздела «Что произойдет, если закрыть уже закрытый канал?»

Коротко. Паника panic: close of closed channel. Закрытие nil-канала — тоже паника, close of nil channel.

Глубже. Как и с отправкой, «проверить и закрыть» через флаг не спасает: if !closed { close(ch) } — гонка. Рантайм проверяет c.closed под lock, но между вашей проверкой и вызовом close вклинивается другая горутина. Единственные корректные схемы — один владелец, sync.Once или отдельный сигнальный канал done, который закрывает ровно одна сторона.

Как можно избежать паники при закрытии канала?

Заголовок раздела «Как можно избежать паники при закрытии канала?»

Коротко. Правило владения: закрывает тот, кто отправляет, и ровно один раз. При нескольких отправителях — sync.WaitGroup и закрытие в отдельной горутине после Wait(), либо sync.Once, либо вообще не закрывать канал данных, а сигналить отдельным done-каналом.

Глубже.

package main
import "sync"
func fanIn(producers int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for i := range producers { // Go 1.22+: range по int
wg.Add(1)
go func() {
defer wg.Done()
out <- i // с Go 1.22 переменная цикла своя на итерацию
}()
}
go func() {
wg.Wait()
close(out) // закрываем ровно один раз, когда все отправители закончили
}()
return out
}
type SafeCloser struct {
ch chan int
once sync.Once
}
func (s *SafeCloser) Close() { s.once.Do(func() { close(s.ch) }) }

Вариант «обернуть close в defer recover()» на собеседовании лучше упомянуть как антипаттерн: паника всё равно означает, что протокол владения сломан, и рано или поздно рядом появится send on closed channel, который так просто уже не починишь.

For which types do you need to allocate memory? Ways to allocate memory a) Required for types: slice, map, array, chan b) var i = 0 c) make creates an object with default values d) new creates a pointer to an object

Заголовок раздела «For which types do you need to allocate memory? Ways to allocate memory a) Required for types: slice, map, array, chan b) var i = 0 c) make creates an object with default values d) new creates a pointer to an object»

Коротко. make применим ровно к трём типам — slice, map, chan; массиву make не нужен, он значение фиксированного размера и инициализируется нулями сам. new(T) возвращает *T на обнулённую память и работает с любым типом, но для map и chan бесполезен (получите указатель на nil). var i = 0 никакой явной аллокации не требует.

Глубже. Разбор пункта a) — в списке ошибка: array в make не участвует, var a [10]int уже готов к работе. Пункт c) формулировка неточная: make не просто «создаёт объект с дефолтными значениями», а инициализирует внутреннюю структуру рантайма (slice header + массив, hmap, hchan) — без этого map и chan остаются nil и неработоспособны (запись в nil-map паникует, операции с nil-каналом блокируются). Где физически окажется память — на стеке или в куче — решает escape-анализ компилятора, а не выбор между new и make; посмотреть можно через go build -gcflags='-m'. В Go 1.24 внутренняя реализация map переехала на Swiss Tables, но контракт make(map[K]V, hint) не изменился.

Data race & race condition a) What is a data race? i) Competition for data access b) Ways to prevent it i) Mutexes (sync.Mutex , sync.RWMutex ), channels c) What are mutexes? i) RWMutex - separating reads (RLock() ) and writes (Lock() ) d) Mutexes in the OS i) Protect memory regions from simultaneous access

Заголовок раздела «Data race & race condition a) What is a data race? i) Competition for data access b) Ways to prevent it i) Mutexes (sync.Mutex , sync.RWMutex ), channels c) What are mutexes? i) RWMutex - separating reads (RLock() ) and writes (Lock() ) d) Mutexes in the OS i) Protect memory regions from simultaneous access»

Коротко. Data race — два конкурентных обращения к одной ячейке памяти, из которых хотя бы одно запись, без отношения happens-before между ними; по спецификации Go это неопределённое поведение. Race condition — более широкое понятие: некорректный результат из-за порядка событий, он возможен и без единой гонки данных. Средства защиты: sync.Mutex/RWMutex, sync/atomic, каналы, sync.Once, неизменяемые данные.

Глубже. sync.RWMutex разделяет читателей и писателей: много RLock() одновременно, Lock() — эксклюзивно, при этом ожидающий писатель блокирует новых читателей, чтобы не голодать. Выигрыш от RWMutex появляется только при действительно длинных чтениях; на коротких критических секциях он обычно медленнее обычного Mutex из-за более дорогой бухгалтерии. В ОС мьютекс — это объект синхронизации, защищающий критическую секцию, а не «область памяти»: в Linux это futex, быстрый путь через атомарную операцию в user space и системный вызов только при реальной конкуренции; Go-шный sync.Mutex устроен похоже (спиннинг, затем парковка горутины, плюс starvation mode). Гонки ищутся детектором: go test -race, go run -race; он находит только те гонки, что реально произошли на прогоне, поэтому «прошло без -race ошибок» не доказывает корректность.

Что будет, если писать в неинициализированный (nil) канал?

Заголовок раздела «Что будет, если писать в неинициализированный (nil) канал?»

Коротко. Отправка заблокируется навсегда. Если в этот момент в программе не остаётся ни одной работающей горутины, рантайм печатает fatal error: all goroutines are asleep - deadlock! и завершает процесс; если другие горутины работают — это просто утечка одной горутины.

Глубже. Важная деталь: дедлок-детектор Go примитивен, он срабатывает только когда все горутины спят. Заблокированная навсегда горутина в живом сервисе не будет обнаружена никогда — её видно только через runtime.NumGoroutine(), pprof goroutine или рост потребления памяти. И это fatal error, а не паника: recover его не ловит, defer не выполняются.

Что будет, если читать в неинициализированный (nil) канал?

Заголовок раздела «Что будет, если читать в неинициализированный (nil) канал?»

Коротко. То же самое: приём из nil-канала блокируется навсегда, с тем же исходом — fatal error: all goroutines are asleep - deadlock!, если спят все горутины, иначе утечка горутины.

Глубже. Это поведение не баг, а полезная фича внутри select: присвоив переменной канала nil, вы отключаете соответствующий кейс, потому что он никогда не станет готовым. Стандартный приём для fan-in: получив ok == false, обнуляем канал и продолжаем крутить select, пока не обнулятся все.

Коротко. См. выше про виды каналов: по буферу — небуферизованные и буферизованные; по направлению в типе — chan T, chan<- T, <-chan T; по состоянию — nil, открытый, закрытый.

Глубже. Отличие от предыдущей формулировки только в акценте: здесь стоит явно проговорить, что направленность — свойство типа, а не рантайма, и существует только для проверки компилятором.

Отличие буферизированного канала (буфер=1) от небуферизированного.

Заголовок раздела «Отличие буферизированного канала (буфер=1) от небуферизированного.»

Коротко. Небуферизованный требует одновременного присутствия обеих сторон: отправитель не продолжит выполнение, пока получатель не заберёт значение. Канал с буфером 1 позволяет отправителю положить одно значение и уйти работать дальше, ничего не зная о получателе.

Глубже. Практическая разница видна в «эффекте эстафеты». С cap == 0 продюсер и консьюмер жёстко синхронны, пропускная способность ограничена самым медленным на каждом шаге, зато обратная связь мгновенная. С cap == 1 они могут работать внахлёст: пока консьюмер обрабатывает элемент N, продюсер уже готовит N+1 — на пайплайнах это ощутимо повышает throughput и снижает число переключений горутин. Ещё cap >= 1 нужен там, где отправка обязана быть неблокирующей: signal.Notify требует буферизованный канал, иначе сигналы будут теряться; тот же паттерн у «последнего значения»/дедупликации уведомлений через select { case ch <- struct{}{}: default: }.

select: что делает, если каналы не готовы? Примеры использования.

Заголовок раздела «select: что делает, если каналы не готовы? Примеры использования.»

Коротко. Если нет ни одного готового кейса и есть default — сразу выполняется default (неблокирующий режим). Если default нет — горутина блокируется, встав в очередь ожидания всех перечисленных каналов, и продолжит работу, как только один из них станет готов.

Глубже. Неблокирующая отправка и приём:

select {
case v := <-ch:
use(v)
default:
// данных нет прямо сейчас — не ждём
}
select {
case ch <- v:
default:
metrics.Dropped.Add(1) // очередь полна, роняем событие вместо блокировки
}

Таймаут и отмена:

select {
case res := <-work:
return res, nil
case <-time.After(2 * time.Second):
return zero, errors.New("timeout")
case <-ctx.Done():
return zero, ctx.Err()
}

Коротко. Обрывок вопроса без условия задачи; на собеседовании так формулируют «перепишите это на каналах вместо мьютекса/waitgroup». Общий каркас ответа: определить, кто продюсер и кто консьюмер, кто владеет каналом и кто его закрывает, нужен ли буфер, как передаётся сигнал завершения.

Глубже. Чаще всего под этим прячется один из четырёх шаблонов. Поочерёдный вывод двух горутин — два небуферизованных канала-эстафеты (ping-pong). Ограничение параллелизма — семафор sem := make(chan struct{}, N). Сбор результатов из нескольких горутин — fan-in через select или через sync.WaitGroup плюс закрытие общего канала. Пайплайн — цепочка функций вида func stage(in <-chan T) <-chan U, каждая из которых сама создаёт и закрывает свой выходной канал. Проговорите вслух выбранный шаблон и явно скажите, кто закрывает каналы, — это то, что проверяют.

Каналы это, чем буферизированный от небуферизованного отличается?

Заголовок раздела «Каналы это, чем буферизированный от небуферизованного отличается?»

Коротко. См. выше: канал — типизированная синхронизированная очередь; небуферизованный синхронен (рандеву), буферизованный асинхронен, пока в буфере есть место.

Глубже. Разница по сравнению с предыдущими формулировками — здесь уместно добавить наблюдаемые метрики: у небуферизованного len(ch) == 0 и cap(ch) == 0 всегда, у буферизованного len показывает текущее число элементов в буфере, cap — ёмкость.

Как можно наладить общение между рутинами?

Заголовок раздела «Как можно наладить общение между рутинами?»

Коротко. Основных способов три: каналы (передача данных и владения), примитивы sync (Mutex, RWMutex, WaitGroup, Cond, Once) для защиты общего состояния, и sync/atomic для отдельных счётчиков и флагов. Для сигналов отмены и дедлайнов поверх каналов есть context.Context.

Глубже. Выбор диктуется задачей: поток событий и передача владения — канал; общее изменяемое состояние — мьютекс; счётчик или флаг — атомарные операции (в Go 1.19+ — типы atomic.Int64, atomic.Pointer[T] и т. п., они предпочтительнее старых функций). context — не транспорт данных, а иерархическая отмена: его Done() — обычный канал, закрываемый при отмене. С Go 1.21 есть ещё sync.OnceFunc/OnceValue, с Go 1.23 — errgroup из golang.org/x/sync как де-факто стандарт для группы горутин с ошибкой и лимитом.

Какие виды каналов существуют в Go? Когда их используют и для чего?

Заголовок раздела «Какие виды каналов существуют в Go? Когда их используют и для чего?»

Коротко. Небуферизованные — когда нужна строгая синхронизация и подтверждение доставки (рандеву, эстафеты, гарантия «получатель точно взял»). Буферизованные — когда нужно сгладить всплески, дать продюсеру забегать вперёд или сделать неблокирующую отправку (очереди задач, семафоры, сигналы). Направленные типы chan<- T/<-chan T — для фиксации контракта в сигнатурах функций.

Глубже. Практический ориентир по ёмкости: cap == 0 — синхронный хендофф; cap == 1 — «мейлбокс»/дедупликация сигнала; cap == N (число воркеров) — семафор ограничения параллелизма; cap = размер батча — сглаживание неравномерного продюсера. Большие буферы «на всякий случай» — плохая идея: они прячут дисбаланс между продюсером и консьюмером, увеличивают латентность хвоста и потребление памяти, а backpressure исчезает ровно тогда, когда он нужнее всего.

В каких случаях и для решения каких задач следует использовать буферизованные каналы, а в каких - небуферизованные?

Заголовок раздела «В каких случаях и для решения каких задач следует использовать буферизованные каналы, а в каких - небуферизованные?»

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

Глубже. Ещё один критерий — backpressure. Небуферизованный канал автоматически тормозит продюсера ровно по скорости консьюмера. Буфер размера N позволяет продюсеру уйти вперёд на N элементов — это хорошо при рваной нагрузке и плохо, если консьюмер систематически медленнее: буфер просто заполнится, и вы получите ту же блокировку, но с задержкой и лишней памятью. Размер буфера — это решение про латентность и память, и его стоит выбирать по измерениям, а не по интуиции.

Что позволяет делать и какие особенности работы у оператора select?

Заголовок раздела «Что позволяет делать и какие особенности работы у оператора select?»

Коротко. См. выше про работу select. Позволяет ждать несколько каналов сразу, делать неблокирующие операции через default, ставить таймауты и обрабатывать отмену. Особенности: операнды вычисляются один раз, выбор среди готовых кейсов случайный, nil-канал делает кейс никогда не готовым, закрытый — всегда готовым.

Глубже. Отдельно стоит помнить, что вычисление операндов происходит при входе в select для всех кейсов, включая правые части отправок. То есть в case ch <- f(): функция f() будет вызвана даже если этот кейс в итоге не выберется, — частая ловушка, если f имеет побочные эффекты.

Что делает оператор break в Go: прерывает только текущий switch /select или также цикл?

Заголовок раздела «Что делает оператор break в Go: прерывает только текущий switch /select или также цикл?»

Коротко. break внутри switch или select прерывает только сам switch/select, а не окружающий цикл. Чтобы выйти из цикла, нужен помеченный break label.

Глубже.

loop:
for {
select {
case v, ok := <-ch:
if !ok {
break loop // выходим из for, а не только из select
}
handle(v)
case <-ctx.Done():
return
}
}

Альтернативы: return (если тело вынесено в функцию), флаг done := true с проверкой в условии цикла, или goto. continue внутри select работает иначе — он относится к ближайшему циклу, а не к select, потому что у select нет «следующей итерации».

Как ты себе представляешь каналы. Если бы ты был разработчиком Go, как бы ты реализовал каналы?

Заголовок раздела «Как ты себе представляешь каналы. Если бы ты был разработчиком Go, как бы ты реализовал каналы?»

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

Глубже. Каркас хорошего ответа: (1) состояние — буфер, индексы, счётчик, флаг закрытия, очереди sendq/recvq; (2) взаимное исключение — простой мьютекс, потому что критические секции очень короткие, lock-free тут дал бы сложность без выигрыша; (3) блокировка — не спинить и не занимать поток ОС, а парковать горутину (gopark) с записью её sudog в очередь, будить через goready; (4) оптимизация — если есть ожидающий получатель, копировать значение прямо в его стек, минуя буфер, экономя одно копирование и одно пробуждение; (5) закрытие — под тем же локом выставить флаг и разбудить всех: получателям выдать нули, отправителям — панику. Хороший бонус: упомянуть, что select требует захвата нескольких локов, поэтому нужен детерминированный порядок захвата по адресу, иначе дедлок, и случайный порядок опроса, иначе будет систематическое голодание последних кейсов.

что будет если писать в закрытый канал или читать из него, а канал nil?

Заголовок раздела «что будет если писать в закрытый канал или читать из него, а канал nil?»

Коротко. Запись в закрытый — паника send on closed channel. Чтение из закрытого — мгновенно нулевое значение и ok == false (после того, как выбран буфер). Любая операция с nil-каналом (и чтение, и запись) блокируется навсегда; close(nil) — паника.

Глубже. Удобная таблица в голове:

Операцияnil-каналоткрытый пустой/полныйзакрытый
отправкаблок навсегдаблок до готовностиpanic
приёмблок навсегдаблок до готовностинулевое значение, ok == false
closepanicокpanic
len/cap0 / 0текущие значениятекущие значения

Когда блокируется небуферизированный канал?

Заголовок раздела «Когда блокируется небуферизированный канал?»

Коротко. Отправка блокируется, пока не появится готовый получатель; приём — пока не появится готовый отправитель. То есть блокируется всегда, кроме случая, когда встречная сторона уже ждёт в очереди.

Глубже. Из этого следует классический дедлок в одной горутине: ch := make(chan int); ch <- 1 в main без второй горутины даёт fatal error: all goroutines are asleep - deadlock!, потому что отправитель ждёт получателя, которого некому создать. С буфером 1 тот же код отработает и упадёт уже на второй отправке.

Как можно одновременно работать с несколькими каналами?

Заголовок раздела «Как можно одновременно работать с несколькими каналами?»

Коротко. Через select — он ждёт готовности любого из перечисленных каналов. Если число каналов заранее неизвестно, используют fan-in: по горутине на канал, все пишут в один общий выходной канал, закрываемый после WaitGroup.Wait(). Совсем динамический случай покрывает reflect.Select.

Глубже. reflect.Select принимает срез reflect.SelectCase произвольной длины — это единственный способ сделать select над слайсом каналов, но он на порядок медленнее обычного и теряет типобезопасность, поэтому в горячем коде почти всегда лучше fan-in-горутины. Ещё вариант для «многих ко многим» — иерархия: собрать каналы попарно рекурсивным merge.

Что произойдет, если в операторе select указаны операции чтения из двух каналов, но на момент выполнения ни в один из них не было отправлено данных? Как выйти из такого select ?

Заголовок раздела «Что произойдет, если в операторе select указаны операции чтения из двух каналов, но на момент выполнения ни в один из них не было отправлено данных? Как выйти из такого select ?»

Коротко. Без default горутина заблокируется, встав в очереди ожидания обоих каналов, и будет ждать первого события. Выйти можно четырьмя способами: добавить default, добавить кейс с таймаутом (time.After/time.Timer), добавить кейс отмены (<-ctx.Done()) или закрыть один из каналов — закрытый канал становится готовым немедленно.

Глубже. Если ни один из этих выходов не предусмотрен и все остальные горутины тоже спят, программа завершится с fatal error: all goroutines are asleep - deadlock!. В сервисе, где есть другие живые горутины, это будет тихая утечка — поэтому в продовом коде почти любой блокирующий select должен содержать ветку отмены по контексту.

При чтении из закрытого канала в Go возвращается нулевое значение типа. Как отличить ситуацию, когда вы прочитали реальное нулевое значение из открытого канала, от ситуации чтения из закрытого канала?

Заголовок раздела «При чтении из закрытого канала в Go возвращается нулевое значение типа. Как отличить ситуацию, когда вы прочитали реальное нулевое значение из открытого канала, от ситуации чтения из закрытого канала?»

Коротко. Использовать двузначную форму приёма: v, ok := <-ch. ok == true означает, что значение реально пришло от отправителя, ok == false — что канал закрыт и буфер исчерпан, а v — просто нулевое значение типа.

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

Если в канал передать структуру, то она скопируется?

Заголовок раздела «Если в канал передать структуру, то она скопируется?»

Коротко. Да. Канал передаёт значения по копии: при отправке значение копируется (memmove) в буфер или прямо в переменную получателя, при приёме — из буфера в переменную. Никакого «разделения» исходной переменной не происходит.

Глубже. Нюанс в том, что копия поверхностная: если в структуре есть указатели, слайсы, мапы, каналы или интерфейсы, копируются сами указатели/заголовки, а данные за ними остаются общими — и вот там уже возможна гонка. Для больших структур копирование стоит денег, поэтому часто отправляют chan *T, но тогда нужно чётко соблюдать передачу владения: после отправки указателя отправитель не должен трогать объект. Ещё деталь: у канала указателей элемент содержит указатель, поэтому буфер аллоцируется отдельно от hchan и сканируется GC.

Коротко. nil. Канал — ссылочный тип (указатель на hchan), поэтому var ch chan int даёт ch == nil, и такой канал непригоден к использованию до make.

Глубже. Отсюда же следует, что канал можно сравнивать с nil и с другим каналом (== сравнивает указатели: два канала равны, если это один и тот же канал), канал может быть ключом мапы, и структура с полем-каналом остаётся сравнимой.

Коротко. См. выше про запись в nil-канал: операция блокируется навсегда; при полном отсутствии активных горутин — fatal error: all goroutines are asleep - deadlock!.

Глубже. Отличие от паники подчёркивайте отдельно: nil-map при записи паникует, nil-канал — нет, он просто вечно ждёт. Это регулярно путают.

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

Заголовок раздела «В каких случаях могут потребоваться буферизированные каналы?»

Коротко. Когда нужно развязать продюсера и консьюмера по времени: сглаживание всплесков нагрузки, очередь задач для воркер-пула, семафор ограничения параллелизма, неблокирующая отправка сигналов, сбор фиксированного числа результатов без риска блокировки.

Глубже. Два конкретных шаблона. Семафор:

sem := make(chan struct{}, 8)
for _, task := range tasks {
sem <- struct{}{}
go func() {
defer func() { <-sem }()
process(task)
}()
}

Сбор ровно N результатов без утечки горутин: results := make(chan Result, N) — даже если читатель ушёл раньше по таймауту, все N горутин смогут записать и завершиться, а не залипнуть навсегда. Это классическое место, где буфер спасает от goroutine leak (тот же приём применён в примерах context в стандартной документации).

Как работают каналы? В чем разница между буферизированным и небуферизированным?

Заголовок раздела «Как работают каналы? В чем разница между буферизированным и небуферизированным?»

Коротко. См. выше: канал — hchan с кольцевым буфером, очередями ожидающих и мьютексом; операции копируют значения и при невозможности продолжить паркуют горутину. Небуферизованный требует встречи сторон, буферизованный работает асинхронно, пока в буфере есть место/данные.

Глубже. Здесь удобно добавить путь исполнения целиком: ch <- vruntime.chansendlock → проверка closed → есть ждущий получатель? прямая передача и goready → иначе есть место в буфере? memmove в buf[sendx] → иначе sudog в sendq и gopark. Приём — зеркально через runtime.chanrecv.

Чем SELECT FOR UPDATE отличается от обычного SELECT ?

Заголовок раздела «Чем SELECT FOR UPDATE отличается от обычного SELECT ?»

Коротко. Вопрос из SQL, к каналам Go отношения не имеет (попал сюда по слову select). SELECT ... FOR UPDATE берёт эксклюзивную блокировку на прочитанные строки до конца транзакции, поэтому другая транзакция не сможет их изменить или заблокировать; обычный SELECT в PostgreSQL/MySQL с MVCC читает снимок и блокировок не ставит.

Глубже. Используется для паттерна «прочитал — проверил — обновил» без гонок, например при списании баланса или выборке задач из таблицы-очереди. Модификаторы: FOR UPDATE SKIP LOCKED пропускает уже заблокированные строки (идеально для очередей), FOR UPDATE NOWAIT вместо ожидания сразу возвращает ошибку, FOR SHARE берёт разделяемую блокировку. Платой идут удержание блокировок на всю транзакцию и риск дедлоков, если разные транзакции блокируют строки в разном порядке.

Коротко. См. выше: канал — типизированная синхронная очередь для обмена данными между горутинами, создаётся make, бывает буферизованным и небуферизованным, поддерживает close, range и select, задаёт отношения happens-before.

Глубже. На открытый вопрос отвечайте структурой, а не потоком сознания: (1) что это и зачем — коммуникация вместо разделяемой памяти; (2) как создаётся и какие есть виды; (3) семантика блокировок и четыре аксиомы (nil, закрытый, отправка, приём); (4) внутреннее устройство hchan в двух предложениях; (5) select и типовые паттерны; (6) когда каналы не нужны — для защиты состояния лучше мьютекс. Такой каркас закрывает 90% дополнительных вопросов заранее.

Коротко. См. выше про виды каналов: buffered / unbuffered; bidirectional chan T, send-only chan<- T, receive-only <-chan T; состояния nil / open / closed.

Глубже. По-английски на собеседовании обычно ждут именно пару buffered/unbuffered плюс directional channels; про nil-канал как отдельное состояние упоминают реже, и это хороший способ показать глубину.

Разница между буферизированным каналом размера один и небуферизированным (когда эффективнее)?

Заголовок раздела «Разница между буферизированным каналом размера один и небуферизированным (когда эффективнее)?»

Коротко. См. выше про буфер=1 против небуферизованного. Небуферизованный эффективнее, когда нужна строгая синхронизация и подтверждение приёма; буфер 1 эффективнее в пайплайнах, где стороны должны перекрываться по времени, и там, где отправка не должна блокироваться.

Глубже. По производительности: на небуферизованном канале каждая передача — это гарантированная встреча двух горутин, то есть как минимум одна парковка и одно пробуждение. С буфером 1 продюсер часто проскакивает без парковки вообще (если консьюмер успел выгрести), и число переключений контекста падает. Но при устойчивом дисбалансе разницы почти нет: буфер моментально насыщается, и вы возвращаетесь к тем же блокировкам. Мерить надо go test -bench с реальной нагрузкой, а не рассуждать.

Коротко. См. выше про hchan: мьютекс, кольцевой буфер с sendx/recvx/qcount, флаг closed, очереди sendq/recvq из sudog, парковка горутин через планировщик и оптимизация прямой передачи значения мимо буфера.

Глубже. Дополнение, которое обычно нравится интервьюеру: канал не занимает поток ОС при блокировке — блокируется только горутина, M возвращается в планировщик и берёт другую G. Именно поэтому в Go нормально держать сотни тысяч горутин, ждущих на каналах, тогда как столько же заблокированных потоков ОС положили бы систему.

Основные виды запросов к БД. (SELECT, INSERT, UPDATE, DELETE)

Заголовок раздела «Основные виды запросов к БД. (SELECT, INSERT, UPDATE, DELETE)»

Коротко. Вопрос из SQL, попал в подборку по слову select. Четыре основные DML-операции: SELECT — чтение, INSERT — вставка, UPDATE — изменение существующих строк, DELETE — удаление. Их часто называют CRUD.

Глубже. Помимо DML есть DDL (CREATE, ALTER, DROP, TRUNCATE), DCL (GRANT, REVOKE) и TCL (BEGIN, COMMIT, ROLLBACK, SAVEPOINT). Полезные уточнения на собеседовании: TRUNCATE — это DDL, он не пишет построчно в журнал как DELETE и обычно не активирует row-триггеры; UPSERT в PostgreSQL делается через INSERT ... ON CONFLICT DO UPDATE; UPDATE/DELETE без WHERE — классическая продовая катастрофа.

Коротко. См. выше: буферизованные и небуферизованные, двунаправленные и направленные (chan<- T, <-chan T), плюс состояния nil/открыт/закрыт.

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

Можно ли читать из закрытого канала? А записать?

Заголовок раздела «Можно ли читать из закрытого канала? А записать?»

Коротко. Читать — можно и нужно: сначала выдаются остатки буфера, потом бесконечно нулевые значения с ok == false. Записать — нельзя, паника send on closed channel.

Глубже. Именно поэтому закрытый канал используют как широковещательный сигнал: любое число получателей увидит закрытие, никто не «съест» сигнал, как это было бы с обычной отправкой одного значения. На этом построен context.Done() и типовой done chan struct{}.

Коротко. См. выше: типизированный конвейер между горутинами со встроенной синхронизацией; make(chan T[, n]), FIFO, блокирующие операции, close, range, select.

Глубже. Если хочется добавить одну фразу сверх шаблона — «канал в Go это одновременно очередь и барьер синхронизации: он не только переносит данные, но и задаёт happens-before, благодаря чему опубликованные до отправки записи в память видны получателю».

Коротко. См. выше про назначение каналов: передача данных и владения между горутинами плюс синхронизация, вместо разделяемой памяти под замком.

Глубже. Отличие от предыдущей формулировки — акцент на «зачем». Три ответа: безопасная передача данных без явных блокировок; координация жизненного цикла горутин (старт, завершение, отмена, ограничение параллелизма); построение пайплайнов, где каждая стадия — независимая функция с чётким контрактом входа и выхода.

Коротко. Четыре классические аксиомы (формулировка Дейва Чейни): отправка в nil-канал блокируется навсегда; приём из nil-канала блокируется навсегда; отправка в закрытый канал паникует; приём из закрытого канала немедленно возвращает нулевое значение.

Глубже. К ним обычно добавляют пятую и шестую практические: close закрытого или nil-канала паникует, и закрывать канал должен отправитель, а не получатель. Эти шесть утверждений покрывают почти все «что будет, если…» вопросы по теме.

Коротко. См. выше про работу select: ожидание нескольких канальных операций сразу, случайный выбор среди готовых, default для неблокирующего режима, блокировка при отсутствии готовых кейсов.

Глубже. Отличие от предыдущих формулировок нулевое — это обрывочная запись того же вопроса; в ответе достаточно дать определение и один пример с ctx.Done() и таймаутом.

Коротко. См. выше: по типу — chan T, chan<- T, <-chan T; по ёмкости — буферизованные и небуферизованные; по состоянию — nil, открытый, закрытый.

Глубже. Здесь слово «типы» стоит трактовать буквально: chan int, chan<- int и <-chan int — это три разных типа в системе типов Go. Присваивание var r <-chan int = ch из chan int работает неявно, обратное преобразование запрещено, а конвертация возможна только через unsafe (что делать не надо).

Рассказать про чтение/запись/закрытие закрытого канала/пустого канала/nil канала;

Заголовок раздела «Рассказать про чтение/запись/закрытие закрытого канала/пустого канала/nil канала;»

Коротко. Закрытый: чтение — нули с ok == false, запись — паника, повторное закрытие — паника. Пустой открытый: чтение блокируется до отправки, запись проходит (в буфер или напрямую получателю), закрытие корректно. nil: чтение и запись блокируются навсегда, закрытие — паника.

Глубже. Полная матрица приведена выше в вопросе «что будет если писать в закрытый канал…». Ещё две детали, которые часто спрашивают вдогонку: закрытый канал в select всегда готов, поэтому его надо либо выводить из игры присваиванием nil, либо выходить из цикла; и len/cap работают на любых каналах, включая nil (оба вернут 0), не паникуя.

Как в select работает чтение из закрытого канала?

Заголовок раздела «Как в select работает чтение из закрытого канала?»

Коротко. Кейс чтения из закрытого канала всегда готов и выбирается наравне с остальными готовыми кейсами, мгновенно возвращая нулевое значение и ok == false. Если такой кейс оставить в цикле, select начнёт крутиться вхолостую на 100% CPU.

Глубже. Правильный приём — обнулять переменную канала, чтобы выключить ветку:

func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // ветка выключена: nil-канал никогда не готов
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}

Коротко. См. выше про виды каналов. Отличие буферизованных от небуферизованных — в моменте разблокировки: небуферизованный требует встречной стороны, буферизованный — только наличия места/данных в буфере. Отличие направленных от двунаправленных — только в проверках компилятора.

Глубже. Ещё одно отличие, о котором забывают: у небуферизованного канала cap == 0, и len всегда 0, поэтому «посмотреть, есть ли там что-то» невозможно в принципе — состояние живёт в очередях ожидания, а не в буфере.

Зачем придумали буферизированный и небуферизированный канал?

Заголовок раздела «Зачем придумали буферизированный и небуферизированный канал?»

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

Глубже. Исторически модель каналов Go выросла из CSP и Newsqueak/Alef Роба Пайка, где базовой была именно синхронная передача. Буфер добавлен как прагматичное расширение: он даёт очередь, backpressure с настраиваемым порогом и возможность неблокирующей отправки, без которых на практике не построить ни семафор, ни обработчик сигналов ОС, ни устойчивый к всплескам пайплайн.

Коротко. Буфер канала — кольцевая очередь (ring buffer) фиксированного размера dataqsiz с индексом записи sendx и индексом чтения recvx, дисциплина строго FIFO. При достижении конца массива индексы заворачиваются в 0; число занятых слотов хранится в qcount.

Глубже. Пустота и полнота различаются не по совпадению индексов, а по qcount (0 — пусто, == dataqsiz — полно), поэтому кольцо использует все слоты без «жертвенной» ячейки. Тонкость: если буфер полон и приходит приём, а в sendq кто-то ждёт, рантайм отдаёт получателю элемент из головы (recvx) и тут же копирует значение ожидающего отправителя в хвост — так сохраняется порядок FIFO и отправитель не ждёт лишнего круга. Память под буфер выделяется один раз в make и не растёт: канал нельзя «расширить».

Что будет, если попытаться отправить данные в закрытый канал?

Заголовок раздела «Что будет, если попытаться отправить данные в закрытый канал?»

Коротко. См. выше: паника panic: send on closed channel, немедленно, без записи данных.

Глубже. Отличие от предыдущих формулировок нет; добавьте только, что паника происходит и когда отправка была бы возможна по буферу — проверка closed идёт раньше проверки места.

Что будет, если попытаться закрыть уже закрытый канал?

Заголовок раздела «Что будет, если попытаться закрыть уже закрытый канал?»

Коротко. См. выше: паника panic: close of closed channel.

Глубже. Проверить «а закрыт ли уже» встроенными средствами нельзя — функции вроде isClosed(ch) в Go нет, и любая её самодельная реализация через select с default содержит гонку и к тому же вычерпает значение из буфера. Правильный путь — дисциплина владения или sync.Once.

Что произойдет, если использовать for range для чтения из канала, который никогда не будет закрыт?

Заголовок раздела «Что произойдет, если использовать for range для чтения из канала, который никогда не будет закрыт?»

Коротко. Цикл прочитает все присланные значения, а затем заблокируется навсегда на ожидании следующего. Горутина никогда не завершится — это утечка горутины; если это последняя живая горутина, программа упадёт с fatal error: all goroutines are asleep - deadlock!.

Глубже. for v := range ch — это синтаксический сахар над for { v, ok := <-ch; if !ok { break }; ... }, то есть выход возможен только по закрытию. Если завершение должно происходить и по отмене, range не подходит — нужен явный select с <-ctx.Done(). Утечки такого рода ловятся go.uber.org/goleak в тестах и профилем pprof goroutine в проде.

Ответ дa или нет. Одно и тоже ли буферизированный канал с емкостью 1 и небуферизированный канал?

Заголовок раздела «Ответ дa или нет. Одно и тоже ли буферизированный канал с емкостью 1 и небуферизированный канал?»

Коротко. Нет. Это разные каналы с разной семантикой: у небуферизованного cap == 0 и отправка требует встречного получателя, у канала с cap == 1 первая отправка проходит без всякого получателя.

Глубже. Проверяется одной строкой: ch := make(chan int, 1); ch <- 1 работает, а ch := make(chan int); ch <- 1 в одиночной горутине даёт дедлок. Плюс cap() возвращает разные значения, и гарантия модели памяти «получатель уже принял» есть только у небуферизованного.

Что произойдет, если попытаться записать данные в закрытый канал?

Заголовок раздела «Что произойдет, если попытаться записать данные в закрытый канал?»

Коротко. См. выше: паника send on closed channel.

Глубже. Дубль вопроса; уместно добавить, что паника возникает в горутине-отправителе, и если это отдельная горутина без recover, упадёт весь процесс — паника не «локализуется» в горутине.

Можно ли читать данные из закрытого канала?

Заголовок раздела «Можно ли читать данные из закрытого канала?»

Коротко. Да. Оставшиеся в буфере значения будут выданы как обычно (с ok == true), после чего чтения будут мгновенно возвращать нулевое значение и ok == false.

Глубже. См. выше про отличие «настоящего нуля» от «нуля из-за закрытия» — форма v, ok := <-ch или range.

Каналы. Нужно написать функцию, которая принимает 2 канала и возвращает третий. Селект это? Какой кейс он выберет? Блокирующие/не блокирующие операции с каналом подробнее

Заголовок раздела «Каналы. Нужно написать функцию, которая принимает 2 канала и возвращает третий. Селект это? Какой кейс он выберет? Блокирующие/не блокирующие операции с каналом подробнее»

Коротко. Это задача fan-in (merge): функция создаёт выходной канал, в отдельной горутине читает оба входных через select и пишет в выход, закрывая его после того, как оба входа закрылись. select выберет случайный кейс среди готовых; если готов один — его. Блокирующие операции — обычные ch <- v и <-ch и select без default; неблокирующие — select с default.

Глубже. Реализация — как в вопросе «Как в select работает чтение из закрытого канала?» выше: обнуляем закрывшийся вход, чтобы его ветка перестала быть готовой, и выходим из цикла, когда оба стали nil. Альтернатива без select, годящаяся для N каналов:

func mergeN[T any](chans ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
for _, c := range chans {
wg.Add(1)
go func(c <-chan T) {
defer wg.Done()
for v := range c {
out <- v
}
}(c)
}
go func() { wg.Wait(); close(out) }()
return out
}

Про «какой кейс выберет»: если оба канала готовы, распределение равномерное — это гарантия спецификации, на неё можно опираться, чтобы не было голодания одного из источников. Про блокировки: заблокированная горутина не потребляет CPU и не держит поток ОС, поэтому «блокирующая операция» в Go стоит на порядки дешевле, чем блокирующий системный вызов в потоке.

При чтении канала режим как особенности? Как узнать сколько в канале элементов если он буферизированный? У небуферизированного канала какая длина?

Заголовок раздела «При чтении канала режим как особенности? Как узнать сколько в канале элементов если он буферизированный? У небуферизированного канала какая длина?»

Коротко. Первая часть — обрывок, вопрос не восстанавливается (вероятно, речь про формы чтения: v := <-ch, v, ok := <-ch, for range). Число элементов в буфере даёт len(ch), ёмкость — cap(ch). У небуферизованного канала len(ch) == 0 и cap(ch) == 0 всегда.

Глубже. len для канала — это hchan.qcount, читаемый без блокировки, поэтому значение устаревает сразу же: использовать его для логики (if len(ch) > 0 { <-ch }) — гонка. Легитимные применения — метрики и диагностика: например, экспортировать заполненность очереди задач в мониторинг. Для nil-канала len и cap возвращают 0 и не паникуют.

Чем основное отличие между каналами (channels) и массивами (arrays) в Go?

Заголовок раздела «Чем основное отличие между каналами (channels) и массивами (arrays) в Go?»

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

Глубже. Дальше следуют варианты ответа этого теста, каждый из которых разобран отдельно ниже; правильного среди них по сути нет — ближе всего к истине формулировка про фиксированный размер массива, но она перевёрнута («каналы можно ресайзить», что неверно).

Каналы могут хранить только целые числа, а массивы могут хранить любые типы данных;

Заголовок раздела «Каналы могут хранить только целые числа, а массивы могут хранить любые типы данных;»

Коротко. Неверно — это вариант ответа к предыдущему тестовому вопросу. Канал типизирован любым типом: chan string, chan struct{}, chan []byte, chan error, chan chan int.

Глубже. Единственное ограничение — элементы канала должны быть одного типа, как и элементы массива; с дженериками (Go 1.18+) можно писать chan T в обобщённом коде.

Каналы можно резайзить, а массивы имеют фиксированный размер;

Заголовок раздела «Каналы можно резайзить, а массивы имеют фиксированный размер;»

Коротко. Неверно. Ёмкость канала задаётся один раз в make и не меняется; массив тоже фиксирован. Изменяемый размер — свойство слайса, а не канала.

Глубже. «Расширить» канал можно только создав новый и переключив на него отправителей — что почти всегда признак неверно выбранной архитектуры: если буфер постоянно переполняется, проблема в дисбалансе продюсера и консьюмера, а не в размере буфера.

Каналы и массивы используются одинаково и не имеют различий;

Заголовок раздела «Каналы и массивы используются одинаково и не имеют различий;»

Коротко. Неверно, различий принципиально много: массив — пассивное хранилище-значение с произвольным доступом по индексу, канал — активный синхронизирующий примитив с FIFO-доступом и блокировками.

Глубже. Формально: массив сравним и копируется целиком при присваивании и передаче в функцию; канал копируется как указатель, и все копии ссылаются на один hchan. У массива нет операции «закрыть» и нет понятия блокировки.

Массивы занимают больше памяти, чем каналы.

Заголовок раздела «Массивы занимают больше памяти, чем каналы.»

Коротко. Неверно как общее утверждение. Расход памяти зависит от типа и количества элементов: [1000000]int займёт 8 МБ, а make(chan int) — десятки байт; но make(chan int, 1000000) займёт те же 8 МБ плюс заголовок hchan.

Глубже. Канал всегда несёт накладные расходы: сама структура hchan (порядка сотни байт) плюс sudog на каждую заблокированную горутину. Массив не имеет накладных расходов вообще и может целиком лежать на стеке, если не убегает в кучу.

Коротко. Канал, созданный как make(chan T, n) с n > 0: внутри него кольцевая очередь на n элементов, отправка не блокируется, пока есть свободное место, приём — пока в буфере есть данные.

Глубже. См. выше про принцип работы буфера и про то, когда буфер уместен. Дальше идут варианты ответа этого теста — ни один из них не корректен.

Каналы, которые хранят данные в оперативной памяти;

Заголовок раздела «Каналы, которые хранят данные в оперативной памяти;»

Коротко. Неверно как определение — это вариант ответа к предыдущему тестовому вопросу. В оперативной памяти живут вообще все каналы и любые данные Go-программы; отличительный признак буферизованного канала — наличие очереди ненулевой ёмкости, позволяющей отправлять без ожидающего получателя.

Каналы, которые автоматически удаляют старые данные при переполнении;

Заголовок раздела «Каналы, которые автоматически удаляют старые данные при переполнении;»

Коротко. Неверно. Ничего автоматически не вытесняется: при полном буфере отправитель блокируется, пока не освободится место. Поведение «дропать при переполнении» реализуется вручную через select с default, а «вытеснять самое старое» — своей структурой поверх мьютекса, канал так не умеет.

Каналы, которые используют дополнительный кеш для хранения данных;

Заголовок раздела «Каналы, которые используют дополнительный кеш для хранения данных;»

Коротко. Неверно по формулировке, хотя ближе всех к истине. Буфер — это не «кеш» (нет вытеснения, нет попаданий/промахов, данные не дублируются), а обычная FIFO-очередь фиксированной ёмкости, выделенная внутри hchan при создании канала.

Коротко. Канал — основной способ передачи данных и синхронизации между горутинами: «не общайтесь разделяемой памятью — разделяйте память через общение». На нём строятся пайплайны, fan-in/fan-out, воркер-пулы, семафоры, сигналы завершения и таймауты.

Глубже. Канал даёт не только транспорт, но и happens-before-гарантии из модели памяти Go: отправка в канал happens-before завершения соответствующего приёма, а закрытие канала happens-before приёма, вернувшего нулевое значение из-за закрытия. Это позволяет безопасно передавать через канал указатели и структуры, не добавляя мьютексов. При этом канал — не универсальная замена sync: для защиты одного счётчика или мапы мьютекс/атомик проще и быстрее, а канал уместен, когда данные «движутся» между стадиями или когда нужен сигнал/квота.

о значениях и указателях, многопоточности, интерфейсах и каналах в Go;

Заголовок раздела «о значениях и указателях, многопоточности, интерфейсах и каналах в Go;»

Коротко. Обрывок исходника, вопрос не восстанавливается — это перечисление тем секции интервью, а не вопрос.

Глубже. Если такое встретилось в описании вакансии, готовиться нужно по четырём блокам: семантика значения против указателя (копирование при присваивании и передаче, набор методов value- и pointer-receiver), конкурентность (горутины, планировщик GMP, sync, гонки и -race), интерфейсы (пара «тип + значение», nil-интерфейс против интерфейса с nil-значением внутри, type assertion и type switch) и каналы (буферизация, select, закрытие, nil).

Что будет, если писать или читать в/из закрытого канала?

Заголовок раздела «Что будет, если писать или читать в/из закрытого канала?»

Коротко. Чтение из закрытого канала не блокируется: сначала выдаются оставшиеся в буфере значения, затем бесконечно — нулевое значение типа с ok == false. Запись в закрытый канал — паника send on closed channel. Повторный close — паника close of closed channel.

Глубже. Важно, что паника при отправке возникает независимо от того, есть ли место в буфере: проверка c.closed != 0 в runtime.chansend идёт до всего остального. Отсюда правило владения: закрывает канал только тот, кто в него пишет, и только когда писателей не осталось. Если писателей несколько, координируйте их через sync.WaitGroup и закрывайте канал в отдельной горутине после wg.Wait(), а не пытайтесь «поймать» панику через recover — это симптом сломанной архитектуры.

ch := make(chan int, 2)
ch <- 1
close(ch)
fmt.Println(<-ch) // 1 — остаток буфера
v, ok := <-ch // v == 0, ok == false
fmt.Println(v, ok)
// ch <- 2 // panic: send on closed channel

Что будет, если писать или читать в/из канала nil?

Заголовок раздела «Что будет, если писать или читать в/из канала nil?»

Коротко. И чтение, и запись в nil-канал блокируются навсегда. Если других готовых горутин нет, рантайм выдаст fatal error: all goroutines are asleep - deadlock!; иначе это просто утечка горутины. close(nil) — паника close of nil channel.

Глубже. Это не баг, а рабочая идиома: в select кейс с nil-каналом никогда не выбирается, поэтому присваивание ch = nil «выключает» ветку. Так динамически отключают исчерпавшиеся источники в fan-in и отключают ветку отправки, пока нечего отправлять. Обратите внимание, что deadlock-детектор рантайма срабатывает только когда спят все горутины; заблокированная на nil-канале горутина в живой программе тихо утечёт вместе со своим стеком.

Что будет, если записать в закрытый канал?

Заголовок раздела «Что будет, если записать в закрытый канал?»

Коротко. Паника send on closed channel — сразу, синхронно, в горутине-отправителе. Если её не перехватить recover, упадёт вся программа.

Глубже. См. выше про закрытые каналы. Отличие этого вопроса — в акценте на защиту: типовые решения — «закрывает только владелец», sync.Once вокруг close, либо отдельный done chan struct{} и select с case <-done: return перед отправкой. Последний вариант всё равно содержит гонку (канал могут закрыть между select и отправкой), поэтому надёжнее не закрывать канал данных вовсе, а сигнализировать завершение отдельным каналом или контекстом.

Что будет с циклом range когда канал буферизированный?

Заголовок раздела «Что будет с циклом range когда канал буферизированный?»

Коротко. Ничего особенного: for v := range ch читает значения по одному, пока канал не закроют, и после закрытия ещё дочитывает всё, что осталось в буфере, затем цикл завершается. Без close цикл заблокируется на пустом канале навсегда.

Глубже. range по каналу разворачивается компилятором в цикл с v, ok := <-ch; if !ok { break }, то есть буферизация влияет только на то, как часто чтение блокируется. Буферизованный канал даёт цикл, который «пачками» разгребает накопившееся, но никаких батчей на уровне языка нет — по-прежнему один элемент за итерацию. Не путайте с range по функции-итератору (iter.Seq, Go 1.23) — это другая конструкция.

Коротко. Потому что всё реализовано в рантайме в user space: блокировка — это короткий спин-мьютекс рантайма, ожидание — парковка горутины (gopark) без ухода в ядро, а передача значения при наличии встречного ждущего идёт одним прямым копированием из стека в стек, минуя буфер.

Глубже. «Быстрые» здесь относительно: по сравнению с IPC ОС или условными переменными на pthread — да, дёшево, порядка сотен наносекунд на операцию. По сравнению с sync/atomic или sync.Mutex на неконкурентном пути — канал заметно дороже: каждая операция берёт общий hchan.lock, поэтому под сильной конкуренцией десятков горутин канал становится точкой сериализации. Легковесность же — про память: сам hchan занимает порядка 96 байт плюс буфер, а ожидающая горутина стоит sudog (небольшая структура из пула) и стек горутины от 8 КБ. Оптимизация прямой передачи (send → стек получателя, минуя буфер) — ключевая причина, почему небуферизованный обмен не медленнее, чем можно было бы ожидать.

Коротко. Вопрос не про Go, а про PostgreSQL: SELECT список_выражений FROM источник [JOIN ...] [WHERE ...] [GROUP BY ...] [HAVING ...] [ORDER BY ...] [LIMIT n] [OFFSET m], опционально с WITH-CTE впереди и FOR UPDATE в конце.

Глубже. На собеседовании обычно хотят услышать не только форму, но и логический порядок вычисления, который отличается от порядка написания: FROM/JOINWHEREGROUP BYHAVINGSELECT (включая оконные функции) → DISTINCTORDER BYLIMIT/OFFSET. Отсюда следуют классические выводы: алиас из SELECT нельзя использовать в WHERE, но можно в ORDER BY; фильтр по агрегату идёт в HAVING; LIMIT без ORDER BY не даёт детерминированного результата. Специфика PostgreSQL — DISTINCT ON (expr), LIMIT ... OFFSET ... вместо TOP, FOR UPDATE SKIP LOCKED для очередей на таблице.

Из чего состоят каналы? (структура, назначение)

Заголовок раздела «Из чего состоят каналы? (структура, назначение)»

Коротко. Канал — это указатель на runtime.hchan: кольцевой буфер buf размера dataqsiz с индексами sendx/recvx и счётчиком qcount, размер и тип элемента, флаг closed, очереди ожидающих sendq/recvq из sudog и мьютекс lock, защищающий всё перечисленное.

Глубже. В Go 1.24 структура выглядит так (src/runtime/chan.go):

type hchan struct {
qcount uint // сколько элементов в буфере
dataqsiz uint // ёмкость кольцевого буфера
buf unsafe.Pointer // сам буфер (nil-подобный для небуферизованных)
elemsize uint16
synctest bool // канал создан внутри synctest-пузыря
closed uint32
timer *timer // таймер, питающий канал (time.Timer с Go 1.23)
elemtype *_type
sendx uint // индекс записи в кольце
recvx uint // индекс чтения из кольца
recvq waitq // ждущие получатели (список sudog)
sendq waitq // ждущие отправители
lock mutex
}

sudog — обёртка над припаркованной горутиной с указателем elem на её слот значения; именно через него делается прямое копирование стек-в-стек. Поле timer появилось, когда таймеры (time.Timer, time.Ticker) стали обслуживаться рантаймом напрямую в Go 1.23, а synctest — вместе с экспериментом testing/synctest в Go 1.24.

Коротко. Паника send on closed channel. См. выше — ответ тот же; отличий от предыдущей формулировки нет.

Глубже. Единственный нюанс, который стоит добавить: если отправка происходит внутри select, паника всё равно возникнет — закрытый канал считается «готовым к отправке», и runtime.selectgo доходит до sendlockedpanic с тем же сообщением. То есть select не защищает от паники при записи в закрытый канал.

Для чего предназначены каналы в Go и как они работают?

Заголовок раздела «Для чего предназначены каналы в Go и как они работают?»

Коротко. См. выше про назначение каналов. Работают так: операция берёт мьютекс hchan, при наличии встречной ждущей горутины копирует значение напрямую и будит её, иначе кладёт/берёт из буфера, иначе паркует текущую горутину в очередь sendq/recvq.

Глубже. Полезно проговорить путь пробуждения: разбуженная горутина не берёт значение из буфера сама — тот, кто её будит, уже положил значение в её слот (sudog.elem), а goready только возвращает её в очередь выполнения планировщика. Это устраняет лишнюю блокировку и «повторную проверку условия», типичную для condvar. Для полного буфера есть симметричная оптимизация: получатель забирает элемент из головы буфера и тут же перекладывает значение ждущего отправителя в хвост, не пробуждая цепочку.

Какой размер буфера небуферизованного канала?

Заголовок раздела «Какой размер буфера небуферизованного канала?»

Коротко. Ноль. make(chan T) эквивалентен make(chan T, 0), cap(ch) == 0 и len(ch) == 0 всегда.

Глубже. В рантайме при mem == 0 буфер физически не выделяется: c.buf указывает на сам hchan (адрес используется только детектором гонок). Поэтому любая отправка требует встречного получателя — «положить и уйти» некуда.

Чтение из закрытого канала, запись в закрытый канал, чтение/запись из канала nil.

Заголовок раздела «Чтение из закрытого канала, запись в закрытый канал, чтение/запись из канала nil.»

Коротко. Чтение из закрытого — мгновенно, остатки буфера, затем нулевое значение и ok == false. Запись в закрытый — паника. Чтение и запись в nil — блокировка навсегда.

Глубже. Удобно держать в голове таблицу состояний:

Операцияnil-каналоткрытый пустой/полныйзакрытый
<-chблокируется навсегдаблокируется до отправкизеро-значение, ok == false
ch <- vблокируется навсегдаблокируется до приёма/местаpanic: send on closed channel
close(ch)panic: close of nil channelзакрываетpanic: close of closed channel
в selectкейс никогда не выбираетсявыбирается, когда готоввсегда готов (для приёма)

Какие типы каналов бывают? Когда какой тип стоит использовать?

Заголовок раздела «Какие типы каналов бывают? Когда какой тип стоит использовать?»

Коротко. По буферизации — небуферизованные (make(chan T)) и буферизованные (make(chan T, n)). По направлению — двунаправленные chan T, только для чтения <-chan T, только для записи chan<- T. Плюс особое состояние — nil-канал.

Глубже. Небуферизованный берут, когда нужна синхронизация и гарантия «сообщение принято» — рукопожатие, сигнал старта, строгая обратная связь по скорости (backpressure). Буферизованный — когда нужно сгладить всплески, ограничить параллелизм (семафор ёмкости N) или отдать результат горутине, которая может уже не читать (буфер 1 в паттерне «результат воркера + таймаут», иначе горутина утечёт). Направленные типы — исключительно про API: функция, возвращающая <-chan T, документирует, что закрывать и писать в него нельзя. Преобразование chan T<-chan T/chan<- T неявное, обратное — невозможно.

func gen(nums ...int) <-chan int { // наружу отдаём только чтение
out := make(chan int, len(nums))
for _, n := range nums {
out <- n
}
close(out)
return out
}

Как устроены буферизированные и небуферизированные каналы в Go?

Заголовок раздела «Как устроены буферизированные и небуферизированные каналы в Go?»

Коротко. Структура одна и та же (hchan), различие — dataqsiz. У небуферизованного он равен нулю, буфер не выделяется, и обмен возможен только через прямую передачу между отправителем и получателем; у буферизованного есть кольцевой массив с индексами sendx/recvx.

Глубже. Для буферизованного канала рантайм выделяет память одним куском вместе с hchan, если тип элемента не содержит указателей (mallocgc(hchanSize+mem, nil, true)), и отдельным блоком, если содержит — чтобы GC мог корректно сканировать элементы. Порядок FIFO обеспечивается кольцом: sendx и recvx инкрементируются по модулю dataqsiz. Прямая передача работает в обоих случаях: если буфер полон и есть ждущий отправитель, приём заберёт элемент из головы и сразу положит значение отправителя в хвост.

Коротко. Буферизованный канал ёмкости N: отправка — «взять слот», приём — «вернуть слот». Захват блокируется, когда все N слотов заняты.

Глубже. Канонический счётный семафор:

type Semaphore chan struct{}
func New(n int) Semaphore { return make(Semaphore, n) }
func (s Semaphore) Acquire() { s <- struct{}{} }
func (s Semaphore) Release() { <-s }
// с отменой и неблокирующей попыткой
func (s Semaphore) AcquireCtx(ctx context.Context) error {
select {
case s <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s Semaphore) TryAcquire() bool {
select {
case s <- struct{}{}:
return true
default:
return false
}
}

Важные детали: struct{}{} не занимает памяти, так что буфер такого канала — фактически только счётчик; Release без парного Acquire не паникует, но «разгоняет» лимит, поэтому освобождать надо через defer сразу после захвата. Для взвешенного семафора и продакшн-кода есть golang.org/x/sync/semaphore, а для ограничения числа горутин в группе — errgroup.Group.SetLimit.

Есть ли разницы в скорости у буферизованных и небуферизованных каналов?

Заголовок раздела «Есть ли разницы в скорости у буферизованных и небуферизованных каналов?»

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

Глубже. Нюанс в том, что небуферизованный канал не всегда медленнее «на операцию»: если получатель уже ждёт в recvq, отправка — это одно копирование стек-в-стек плюс goready, без обращения к буферу; в буферизованном канале при пустом recvq данные сначала копируются в буфер, а потом ещё раз — получателю, то есть копий две. Выигрыш буфера — в амортизации: производитель и потребитель работают с разной скоростью, и буфер сглаживает всплески, сокращая число парковок. Практический вывод для собеседования: разница есть, но она про число переключений контекста и число копий, а не про «магическую» скорость; и в любом случае обе версии упираются в общий hchan.lock, поэтому канал плохо масштабируется на десятки конкурирующих горутин — там выигрывают шардирование или атомики. Всё это надо мерить testing.B с -cpu, а не утверждать априори.

Live coding task: Implement a function that multiplexes channels and returns a result channel without waiting for them to merge.

Заголовок раздела «Live coding task: Implement a function that multiplexes channels and returns a result channel without waiting for them to merge.»

Коротко. Классический fan-in: заводим выходной канал, по горутине на каждый вход, каждая перекладывает значения в выход; отдельная горутина ждёт WaitGroup и закрывает выход. Функция возвращает канал сразу, не дожидаясь окончания слияния.

Глубже. Версия с дженериками и отменой:

package pipeline
import (
"context"
"sync"
)
// Merge возвращает канал немедленно; слияние идёт в фоне.
func Merge[T any](ctx context.Context, ins ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(ins))
for _, in := range ins {
go func(in <-chan T) { // с Go 1.22 параметр не обязателен: переменная цикла своя на итерацию
defer wg.Done()
for v := range in {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}(in)
}
go func() {
wg.Wait()
close(out)
}()
return out
}

Что здесь проверяют: (1) канал возвращается до завершения работы — значит закрытие в отдельной горутине после wg.Wait(), а не в конце функции; (2) закрывает выход ровно одна горутина; (3) есть выход по отмене, иначе при брошенном потребителе горутины залипнут на out <- v; (4) с Go 1.22 переменная цикла создаётся заново на каждой итерации, так что старая ловушка с захватом in исчезла, но в коде под старые версии её всё ещё нужно обходить параметром.

Расскажи про каналы. Какие каналы бывают? А в чем отличие буф от не буф?

Заголовок раздела «Расскажи про каналы. Какие каналы бывают? А в чем отличие буф от не буф?»

Коротко. См. выше про типы каналов. Отличие: у небуферизованного отправка завершается только одновременно с приёмом (рандеву, синхронная передача и гарантия доставки), у буферизованного отправка завершается, как только значение положено в буфер, — доставка асинхронна.

Глубже. Формально это разница в happens-before: для небуферизованного канала спецификация гарантирует, что приём happens-before завершения отправки; для буферизованного — только что отправка happens-before завершения приёма. Практическое следствие: после ch <- v на небуферизованном канале вы знаете, что кто-то уже забрал значение; на буферизованном — не знаете ничего, кроме того, что значение поставлено в очередь.

Можно ли сказать что небуферизованный канал это канал с буфером 1?

Заголовок раздела «Можно ли сказать что небуферизованный канал это канал с буфером 1?»

Коротко. Нет. У канала с буфером 1 отправитель кладёт значение и продолжает работу без получателя; у небуферизованного отправитель обязан дождаться приёмника. Разная семантика синхронизации, а не «на единицу меньше».

Глубже. Разница видна на простом примере: с make(chan int, 1) одиночная горутина может выполнить ch <- 1 и не заблокироваться, а с make(chan int) та же строка приведёт к fatal error: all goroutines are asleep - deadlock!. Ещё одно следствие — обратное давление: небуферизованный канал жёстко синхронизирует темп производителя и потребителя, канал с буфером 1 разрешает производителю опережать на одно сообщение. Отсюда же типичная ошибка в паттерне «worker + timeout»: если результат отдаётся в небуферизованный канал, а вызывающий ушёл по таймауту, воркер повиснет на отправке навсегда; буфер 1 эту утечку снимает.

Что будет при чтении из закрытого канала? А писать?

Заголовок раздела «Что будет при чтении из закрытого канала? А писать?»

Коротко. Чтение — мгновенно: остатки буфера, дальше нулевое значение с ok == false. Запись — паника send on closed channel. См. выше.

Глубже. Отличие этой формулировки только в акценте на «бесконечности» чтения: закрытый канал никогда не блокирует получателя, поэтому for { <-ch } без проверки ok после закрытия превращается в busy-loop, съедающий CPU. Именно поэтому в select закрытый канал нужно обезвреживать присваиванием nil.

Какие типы каналов бывают и как с ними работать?

Заголовок раздела «Какие типы каналов бывают и как с ними работать?»

Коротко. См. выше про типы. Работа: создание make, отправка ch <- v, приём v := <-ch или v, ok := <-ch, обход for v := range ch, закрытие close(ch), мультиплексирование select, размеры len/cap.

Глубже. Компактная шпаргалка операций:

ch := make(chan int, 3) // буферизованный на 3
ch <- 1 // отправка
v := <-ch // приём
v, ok := <-ch // приём с признаком «канал закрыт и пуст»
<-ch // приём с отбрасыванием значения (например, сигнал)
len(ch), cap(ch) // сколько лежит / ёмкость
close(ch) // закрыть (только со стороны отправителя)
for v := range ch { _ = v }
select { // неблокирующее чтение
case v = <-ch:
default:
}
_ = v

len/cap для наблюдения и метрик годятся, но принимать по ним решения нельзя: между проверкой и операцией состояние изменится (гонка). Для «попробовать без блокировки» используйте select+default.

Коротко. Да, и это штатная операция: чтение из закрытого канала всегда успешно и не блокируется. Сначала отдаются значения, оставшиеся в буфере, потом бесконечно нулевое значение типа с ok == false.

Глубже. Отсюда идиома «широковещательного» сигнала: done := make(chan struct{}), все горутины ждут на <-done, а close(done) будит их всех разом — единственный способ разбудить N получателей одной операцией. Именно так устроен context.Context.Done().

Как проверить при чтении из канала получили ли мы валидное значение?

Заголовок раздела «Как проверить при чтении из канала получили ли мы валидное значение?»

Коротко. Форма приёма с двумя значениями: v, ok := <-ch. ok == false означает, что канал закрыт и буфер пуст, то есть v — просто нулевое значение, а не реальные данные.

Глубже. Осторожно: ok == true гарантирует, что значение действительно кто-то отправил, но не гарантирует, что оно «не нулевое» — отправить 0 или nil совершенно легально. Если нужно различать «нет данных» и «прислали нулевое значение», кодируйте это в типе (chan *T, chan Result со своим полем ошибки), а не полагайтесь на зеро-значение. В range проверка ok не нужна: цикл сам завершается при закрытии.

Коротко. Нет: повторный close вызывает панику close of closed channel. Так же паникует close на nil-канале.

Глубже. Проверить «а не закрыт ли уже» без гонки штатными средствами нельзя — приём с ok меняет состояние и не работает для полного буфера. Практические решения: единственный владелец, который закрывает канал; sync.Once вокруг close, если владельцев несколько; либо вообще не закрывать канал данных, а сигналить завершение отдельным done-каналом или context. Обёртка с defer recover() вокруг close формально работает, но скрывает ошибку владения и на ревью считается запашком.

type Closer struct {
ch chan struct{}
once sync.Once
}
func (c *Closer) Close() { c.once.Do(func() { close(c.ch) }) }

Что произойдет если записать в канал у которого нет читателя?

Заголовок раздела «Что произойдет если записать в канал у которого нет читателя?»

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

Глубже. Если читателя не появится никогда, есть два исхода. Когда заблокированы вообще все горутины программы, рантайм печатает fatal error: all goroutines are asleep - deadlock! и завершает процесс — это не паника, её нельзя перехватить recover. Когда программа продолжает работать, горутина просто повисает навсегда: утечка горутины и её стека, которую видно в pprof-профиле goroutine и в счётчике runtime.NumGoroutine(). Защита — отправка внутри select с case <-ctx.Done() либо буфер нужного размера в паттернах «ответ на запрос».

Зачем нужен select ? Как выйти из select не блокируясь? (имеется в виду default ).

Заголовок раздела «Зачем нужен select ? Как выйти из select не блокируясь? (имеется в виду default ).»

Коротко. select ждёт готовности сразу нескольких канальных операций и выполняет одну из готовых. Ветка default выполняется, когда ни один кейс не готов, — так select становится неблокирующим.

Глубже. select без кейсов (select {}) блокируется навсегда. select только с default вырождается в этот default. Типовые применения: неблокирующее чтение/запись, таймаут через case <-time.After(d) (в Go 1.23+ таймер сборщик может собрать до срабатывания, так что утечки прежних версий больше нет, но time.NewTimer+defer Stop() в горячем цикле всё равно предпочтительнее), отмена через case <-ctx.Done(), и слияние потоков. Помните, что break внутри select выходит только из select, а не из объемлющего for — для выхода из цикла нужна метка.

select {
case v := <-ch:
use(v)
default:
// канал пуст — не блокируемся
}

Допустим, у нас есть буферизированный канал, мы его закрываем и хотим читать из него. Что мы получим?

Заголовок раздела «Допустим, у нас есть буферизированный канал, мы его закрываем и хотим читать из него. Что мы получим?»

Коротко. Сначала получим все значения, которые остались в буфере, в порядке FIFO, а после их исчерпания — нулевые значения с ok == false, без блокировки и бесконечно.

Глубже. Закрытие не выбрасывает данные и не освобождает буфер: closechan лишь ставит closed = 1 и будит всех ждущих. Поэтому «дренаж после закрытия» — корректный и распространённый паттерн: продюсер закрывает канал, консьюмер спокойно дочитывает хвост через for v := range ch. Значения, отправленные до close, гарантированно будут доставлены.

Где у канала определяется буфер? На стеке или на куче? И от чего это зависит?

Заголовок раздела «Где у канала определяется буфер? На стеке или на куче? И от чего это зависит?»

Коротко. Всегда на куче. make(chan T, n) компилируется в вызов runtime.makechan, который выделяет и hchan, и буфер через mallocgc; escape-анализ для каналов стековую версию не строит.

Глубже. В cmd/compile/internal/escape узел OMAKECHAN не «спиллится» на стек (в отличие от OMAKESLICE и OMAKEMAP), так что даже локальный неубегающий канал живёт в куче. От чего зависит форма аллокации внутри makechan: если ёмкость нулевая, выделяется только hchan; если элемент не содержит указателей — hchan и буфер одним блоком (mallocgc(hchanSize+mem, nil, true)); если содержит — буфер отдельным типизированным блоком, чтобы GC сканировал элементы. Ещё одна деталь: сама переменная-канал (указатель) может лежать на стеке, но указывает она на кучу — поэтому «канал на стеке» иногда говорят про указатель, и это стоит уточнить у интервьюера.

Написать фукцию которая из канала вычитывает числа и находит K максимальных чисел сделать чтобы можно было прерывать ее работу:

Заголовок раздела «Написать фукцию которая из канала вычитывает числа и находит K максимальных чисел сделать чтобы можно было прерывать ее работу:»

Коротко. Держим min-heap размера K: новое число сравниваем с корнем, если больше — вытесняем корень. Прерывание — select по ctx.Done() параллельно чтению из канала.

Глубже. Сложность O(n log K) по времени и O(K) по памяти, что и хотят услышать вместо «отсортируем всё».

package topk
import (
"container/heap"
"context"
"slices"
)
type minHeap []int
func (h minHeap) Len() int { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *minHeap) Pop() any {
old := *h
n := len(old)
v := old[n-1]
*h = old[:n-1]
return v
}
// TopK читает числа из in, пока канал не закроется или ctx не отменят,
// и возвращает k максимальных по убыванию. При отмене отдаёт то, что успел.
func TopK(ctx context.Context, in <-chan int, k int) ([]int, error) {
if k <= 0 {
return nil, nil
}
h := &minHeap{}
heap.Init(h)
for {
select {
case <-ctx.Done():
return result(h), ctx.Err()
case v, ok := <-in:
if !ok {
return result(h), nil
}
if h.Len() < k {
heap.Push(h, v)
} else if v > (*h)[0] {
(*h)[0] = v
heap.Fix(h, 0)
}
}
}
}
func result(h *minHeap) []int {
out := slices.Clone([]int(*h))
slices.Sort(out)
slices.Reverse(out)
return out
}

На что смотрят: используется куча, а не полная сортировка; отмена реализована через select с ctx.Done(), а не через флаг с гонкой; частичный результат возвращается вместе с ctx.Err(); вытеснение корня сделано через heap.Fix (дешевле, чем Pop+Push).

Коротко. switch выбирает ветку по значению или типу, проверяя кейсы сверху вниз и беря первый истинный; select выбирает ветку по готовности канальной операции, среди готовых — псевдослучайно, и блокируется, если готовых нет и нет default.

Глубже. Дополнительные различия: в select каждый кейс — обязательно канальная операция (приём или отправка), выражений-условий там нет; fallthrough в select запрещён (в switch разрешён); switch не блокируется никогда, select {} блокируется навсегда; в switch есть форма с тегом и type switch, в select — ничего подобного. Общее — break в обоих выходит только из самой конструкции, а не из окружающего цикла, и оба разрешают default.

Коротко. Оператором <-: v := <-ch, либо с признаком закрытия v, ok := <-ch, либо циклом for v := range ch, либо внутри select.

Глубже. Выражение <-ch без присваивания тоже валидно — им «съедают» сигнал (<-done, <-sem). Приём — блокирующая операция для открытого канала без данных; чтобы не блокироваться, добавьте default в select; чтобы ограничить ожидание — case <-time.After(d) или case <-ctx.Done(). Направленный канал chan<- T читать нельзя — ошибка компиляции.

Коротко. Канал — встроенный в язык типизированный конкурентно-безопасный конвейер между горутинами: значения одного типа передаются по FIFO, а сама передача даёт гарантии happens-before. Технически это указатель на структуру runtime.hchan в куче.

Глубже. Канал — тип первого класса: его можно хранить в структурах и мапах, передавать в функции, возвращать, сравнивать с nil и между собой (два значения равны, если указывают на один hchan). Каналы поддерживают только элементы одного объявленного типа; «канал чего угодно» делают через chan any или дженерики. Копирование переменной-канала копирует указатель, а не очередь, — все копии работают с одним и тем же каналом.

Коротко. nil. Объявленный, но не инициализированный var ch chan int равен nil; работать с ним можно только в смысле «блокироваться навсегда», а close на нём паникует.

Глубже. Как и у мапы и слайса, make обязателен: make(chan T) или make(chan T, n). Разница с мапой в том, что для nil-мапы чтение безопасно и возвращает зеро-значение, а для nil-канала чтение — вечная блокировка. Отсюда частый баг: поле-канал в структуре забыли инициализировать в конструкторе, и программа тихо виснет вместо паники.

Коротко. Обе операции блокируются навсегда. См. выше — то же самое, что и в вопросе про nil-канал.

Глубже. Единственное, что стоит добавить сверх сказанного: в select nil-канал не блокирует весь оператор — рантайм просто пропускает такой кейс при построении pollorder, поэтому остальные ветки и default работают нормально. Это делает ch = nil безопасным способом «выключить» ветку.

Коротко. select с веткой default: если в канале ничего нет, выполнится default.

Глубже. Варианты «частично неблокирующего» чтения: ограничить ожидание таймером (case <-time.After(d)), отменяемым контекстом (case <-ctx.Done()) или отдельным done-каналом. Не пытайтесь заменить это проверкой if len(ch) > 0 { v := <-ch } — между len и приёмом другой получатель может забрать значение, и вы всё равно заблокируетесь.

select {
case v, ok := <-ch:
if !ok {
return errClosed
}
handle(v)
case <-ctx.Done():
return ctx.Err()
default:
// прямо сейчас данных нет
}

Какая разница между небуферизованным каналом и буфер.каналом емкостью 1

Заголовок раздела «Какая разница между небуферизованным каналом и буфер.каналом емкостью 1»

Коротко. См. выше про «небуферизованный ≠ буфер 1». Кратко: с буфером 1 отправитель кладёт значение и идёт дальше без получателя; без буфера отправитель ждёт, пока значение заберут.

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

Как читать из несколько каналов и не блокироваться, пока хоть в одном есть данные?

Заголовок раздела «Как читать из несколько каналов и не блокироваться, пока хоть в одном есть данные?»

Коротко. select по всем каналам сразу: он разблокируется, как только готов любой из них; если готовы несколько — выбирается случайный.

Глубже. Для фиксированного числа каналов пишем кейсы руками; для динамического — либо fan-in-горутинами в один общий канал (см. Merge выше), либо reflect.Select с массивом reflect.SelectCase (медленно, но иногда единственный вариант). Не забывайте гасить закрывшиеся источники присваиванием nil, иначе select будет крутиться на них вхолостую.

for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok { a = nil; continue }
use(v)
case v, ok := <-b:
if !ok { b = nil; continue }
use(v)
}
}

Коротко. В select порядок псевдослучайный: рантайм перемешивает кейсы и среди готовых выбирает один равновероятно; порядок записи в коде значения не имеет. В switch — строго сверху вниз, первый подошедший.

Глубже. В runtime.selectgo строятся два порядка: pollorder — случайная перестановка (шафл Фишера—Йетса с cheaprandn), по которой ищется готовый кейс, и lockorder — кейсы, отсортированные по адресу hchan, чтобы блокировать каналы в едином глобальном порядке и не получить дедлок при пересекающихся select. Рандомизация — сознательное решение: она предотвращает голодание кейсов, если бы «левый» канал всегда был готов. default рассматривается только после того, как проход по pollorder не нашёл ни одного готового кейса. Полагаться на порядок кейсов нельзя — если нужен приоритет, делайте вложенный select: сначала неблокирующая попытка на приоритетном канале с default, потом общий.

Что произойдет в select, если один из каналов закроется?

Заголовок раздела «Что произойдет в select, если один из каналов закроется?»

Коротко. Его кейс станет постоянно готовым и будет срабатывать, возвращая нулевое значение с ok == false. Если это не обработать, select в цикле превратится в busy-loop.

Глубже. Лечение — проверять ok и обезвреживать источник: ch = nil (кейс перестанет выбираться) либо break из цикла по метке, если один закрывшийся канал означает конец работы. Отдельно: если в select есть отправка в закрытый канал, этот кейс тоже считается готовым, и его выбор приведёт к панике send on closed channel.

loop:
for {
select {
case v, ok := <-in:
if !ok {
in = nil // выключаем ветку
continue
}
use(v)
case <-ctx.Done():
break loop
}
if in == nil {
break
}
}

Коротко. Присвоить переменной канала nil после того, как приём вернул ok == false: nil-кейс в select никогда не выбирается, то есть источник выключается без пересборки select.

Глубже. Работает только для локальных переменных типа канал (или элементов слайса/мапы, которые вы перечитываете). Для динамического набора источников удобнее держать chans := []<-chan T{...}, при закрытии удалять элемент через slices.Delete и пересобирать []reflect.SelectCase, либо вообще уйти от select к схеме «горутина на источник + общий выход + WaitGroup» — она проще и не требует отбрасывания вручную. Цикл при этом завершают по условию «все каналы стали nil» (for a != nil || b != nil).

Написать код слияния данных из двух каналов в третий (возможны два варианта: с селектом или с отдельными го-рутинами)

Заголовок раздела «Написать код слияния данных из двух каналов в третий (возможны два варианта: с селектом или с отдельными го-рутинами)»

Коротко. Вариант с select — одна горутина читает оба канала, выключая закрывшиеся присваиванием nil. Вариант с горутинами — по горутине на вход плюс WaitGroup и закрытие выхода в отдельной горутине.

Глубже.

package merge
import "sync"
// Вариант 1: одна горутина и select.
func WithSelect(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}
// Вариант 2: по горутине на источник.
func WithGoroutines(ins ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(ins))
for _, in := range ins {
go func(in <-chan int) {
defer wg.Done()
for v := range in {
out <- v
}
}(in)
}
go func() {
wg.Wait()
close(out)
}()
return out
}

Разница: select-вариант сохраняет ровно одну горутину и не требует WaitGroup, но плохо обобщается на N каналов; вариант с горутинами масштабируется на любое N и есть в примерах из «Go Concurrency Patterns». В обоих случаях выходной канал закрывает ровно один владелец, и в обоих в продакшене нужна ветка отмены по ctx.Done(), иначе при брошенном потребителе горутины залипнут на out <- v.

Коротко. Канал, созданный с ненулевой ёмкостью — make(chan T, n). У него есть внутренняя очередь на n элементов: отправка не блокируется, пока в очереди есть место, приём — пока очередь не пуста.

Глубже. cap(ch) возвращает ёмкость, len(ch) — текущее число элементов в буфере (не считая ждущих отправителей). Очередь строго FIFO, реализована кольцевым массивом. Ёмкость фиксируется в момент make и не меняется. Типовые применения: сглаживание всплесков в пайплайне, семафор ограничения параллелизма, буфер 1 для «результат без гарантии, что его прочтут».

Как создать буферизированный канал на 50 сообщений?

Заголовок раздела «Как создать буферизированный канал на 50 сообщений?»

Коротко. ch := make(chan T, 50), где T — тип элемента. Например ch := make(chan string, 50).

Глубже. Второй аргумент make — именно ёмкость буфера, а не число элементов; len(ch) сразу после создания равен нулю, cap(ch) — 50. Ёмкость может быть переменной (make(chan int, n)), но отрицательное значение вызовет панику makechan: size out of range в рантайме, а отрицательная константа — ошибку компиляции.

Что будет, если читать из неинициализированного канала?

Заголовок раздела «Что будет, если читать из неинициализированного канала?»

Коротко. Неинициализированный канал — это nil, чтение из него блокируется навсегда. Если больше нечему выполняться, программа упадёт с fatal error: all goroutines are asleep - deadlock!.

Глубже. См. выше про nil-каналы. Практический совет: инициализируйте канальные поля в конструкторе структуры и не полагайтесь на зеро-значение — в отличие от sync.Mutex или bytes.Buffer, у канала «полезного нуля» нет.

Что такое select? Какой порядок срабатывания кейсов у SELECT? Что будет с кейсом, если канал закрыт?

Заголовок раздела «Что такое select? Какой порядок срабатывания кейсов у SELECT? Что будет с кейсом, если канал закрыт?»

Коротко. select ждёт готовности одной из перечисленных канальных операций и выполняет её; порядок кейсов псевдослучайный среди готовых; кейс с закрытым каналом на приём всегда готов и выдаёт нулевое значение с ok == false, а кейс с отправкой в закрытый канал приведёт к панике.

Глубже. Сводя воедино сказанное выше: selectgo строит случайный pollorder и адресный lockorder, ищет готовый кейс, при отсутствии готовых берёт default, а если и его нет — регистрирует горутину во всех очередях ожидания и паркуется; пробуждение снимает регистрацию из остальных очередей. Отсюда три практических правила: приоритет кейсов задаётся вложенными select, а не порядком; закрывшиеся источники надо обнулять; в select без кейсов и без default программа висит навсегда.

Коротко. Цикл на n итераций с приёмом и проверкой ok, чтобы корректно выйти, если канал закроют раньше.

Глубже.

// ReadN читает не более n элементов; возвращает прочитанное и признак «канал закрыт».
func ReadN[T any](ctx context.Context, ch <-chan T, n int) ([]T, bool) {
out := make([]T, 0, n)
for range n { // Go 1.22+: range по целому числу
select {
case v, ok := <-ch:
if !ok {
return out, true
}
out = append(out, v)
case <-ctx.Done():
return out, false
}
}
return out, false
}

Нюансы: без проверки ok цикл на закрытом канале молча наберёт n нулевых значений; без ветки отмены вызов повиснет, если элементов меньше n и канал не закроют. Форма for range n доступна с Go 1.22, до неё — обычный for i := 0; i < n; i++.

  • Путают поведение nil-канала и nil-мапы: говорят «будет паника», хотя операции с nil-каналом просто блокируются навсегда, а паникует только close(nil).
  • Утверждают, что make(chan T, 1) эквивалентен make(chan T) — забывая, что у небуферизованного канала есть гарантия «получатель уже забрал значение», а у буферизованного её нет.
  • Считают, что select выбирает первый по порядку готовый кейс. Выбор равномерно случайный; порядок в коде не даёт приоритета.
  • Пишут if !closed(ch) { close(ch) } или проверяют закрытость через select/len — все такие проверки содержат гонку; корректно только владение или sync.Once.
  • Забывают, что break внутри select/switch не выходит из цикла, и получают бесконечный цикл вместо завершения.
  • Оставляют закрытый канал в select без обнуления и получают busy-loop на 100% CPU вместо ожидания.
  • Считают, что закрытие канала «уничтожает» его или что после закрытия из него нельзя читать; на деле остатки буфера дочитываются, а потом бесконечно идут нули с ok == false.
  • Говорят, что заблокированная на канале горутина занимает поток ОС; на самом деле она паркуется, а M уходит выполнять другую работу.
  • Отправляют указатель на структуру и продолжают её менять после отправки — передача владения нарушена, получается гонка, которую копирование значения предотвратило бы.
  • Говорить, что небуферизованный канал — это «канал с буфером 1». Это разная семантика: рандеву против асинхронной постановки в очередь.
  • Утверждать, что кейсы select проверяются сверху вниз. Порядок псевдослучайный (pollorder), приоритета у первого кейса нет.
  • Путать close с освобождением ресурсов: закрытие нужно только чтобы сообщить получателям «данных больше не будет», память освобождает GC, а незакрытый канал сам по себе не течёт.
  • Считать, что панику при записи в закрытый канал можно надёжно предотвратить проверкой перед отправкой. Проверки без гонки не существует — нужна дисциплина владения, sync.Once или context.
  • Не обрабатывать ok в select: закрытый канал становится всегда готовым и превращает цикл в busy-loop на 100% CPU.
  • Говорить «каналы быстрее мьютексов». На неконкурентном пути мьютекс и атомик дешевле; канал берёт свой hchan.lock на каждой операции и плохо масштабируется под конкуренцией.
  • Забывать про ok == true при нулевом значении: ok говорит только о том, закрыт ли канал, а не о «валидности» данных.
  • Отдавать результат воркера в небуферизованный канал при наличии таймаута у вызывающего — классическая утечка горутины, лечится буфером 1 или select с ctx.Done().

Список исходных вопросов с привязкой к компаниям: ../questions/channels.md