Канал — это синхронизация, а не только очередь: отправка в небуферизованный канал ждёт получателя, закрывает канал отправитель, а результаты собирают так, чтобы порядок не зависел от планировщика. Таблица операций с nil и закрытыми каналами из спецификации отвечает на многие вопросы о дедлоках и паниках.
Задача 1: что напечатает программа и где дедлок
В программе три части. Отправка в буферизованный канал ёмкостью 1 проходит без получателя, и печатается 1. Во второй части отправитель — отдельная горутина, main принимает, выводится 2. Третья часть печатает 0, 1, 2 и падает: продюсер забыл close(nums), range ждёт следующего значения, и среда выполнения сообщает «fatal error: all goroutines are asleep - deadlock!» с пометкой goroutine 1 [chan receive]; go run дописывает exit status 2.
Типичный неверный ответ — «программа просто зависнет». Функция checkdead в runtime сообщает о дедлоке, когда все горутины заблокированы, ни один поток не работает и нет взведённых таймеров; продюсер уже завершился, поэтому здесь сообщение гарантировано. Но детектор легко обмануть: если другая горутина спит в time.Sleep, ошибка появится только после её пробуждения, а горутина, ждущая сеть (например, в ln.Accept()), выключает его совсем — программа молча висит. Простейший вариант той же ошибки — ch <- 1 в main без получателя: то же сообщение, но с goroutine 1 [chan send].
package main
import "fmt"
func main() {
buf := make(chan int, 1)
buf <- 1 // в буфере есть место: отправка не блокируется
fmt.Println(<-buf)
unbuf := make(chan int)
go func() { unbuf <- 2 }() // отправитель ждёт получателя в main
fmt.Println(<-unbuf)
nums := make(chan int)
go func() {
for i := range 3 {
nums <- i
}
// close(nums) забыт
}()
for n := range nums {
fmt.Println(n)
}
}Задача 2: close, range и операции с закрытым каналом
Что выведет программа после close(ch), если в буфере остались два значения? range дочитывает буфер — range: a, range: b — и выходит. Следующее чтение не блокируется и возвращает нулевое значение с ok == false: после close: "" false. Отправка в закрытый канал, повторный close и close(nil) — паники: send on closed channel, close of closed channel и close of nil channel. Хелпер try ловит их через recover, чтобы показать все три за один запуск.
Отсюда правило, которое ждут на собеседовании: закрывает канал отправитель, и ровно один раз. Получатель не знает, будут ли ещё отправки, а второй close — паника. Если отправителей несколько, канал закрывает тот, кто дождался их всех, обычно через WaitGroup. Таблица сводит поведение по спецификации Go. Вечная блокировка на nil-канале полезна в select: присвойте каналу nil, и ветка выключится.
package main
import "fmt"
func try(name string, f func()) {
defer func() { fmt.Printf("%s: %v\n", name, recover()) }()
f()
}
func main() {
ch := make(chan string, 2)
ch <- "a"
ch <- "b"
close(ch)
for s := range ch { // дочитывает буфер и выходит
fmt.Println("range:", s)
}
v, ok := <-ch
fmt.Printf("после close: %q %v\n", v, ok)
try("send", func() { ch <- "c" })
try("close again", func() { close(ch) })
var nilCh chan int
try("close nil", func() { close(nilCh) })
}| Операция | nil-канал | Закрытый | Открытый |
|---|---|---|---|
| ch <- v | блокируется навсегда | паника | блокируется, пока нет получателя или места в буфере |
| <-ch | блокируется навсегда | остаток буфера, затем нулевое значение и ok = false | блокируется, пока нет значения |
| close(ch) | паника | паника | закрывает канал |
| for range ch | блокируется навсегда | дочитывает буфер и выходит | работает до close |
Задача 3: буферизованный канал против небуферизованного и что под капотом
Сколько отправок пройдёт без получателя? В канал ёмкостью 2 — две: trySend печатает sent 1 len 1 и sent 2 len 2, а третья попытка — would block on 3. После одного чтения: recv 1 len 1 cap 2. У небуферизованного канала len и cap равны 0, и неблокирующая отправка не проходит, пока никто не ждёт на приёме: would block on 1. select с default — стандартный способ проверить это, не повесив программу.
Под капотом канал — структура hchan из runtime/chan.go: кольцевой буфер buf с индексами sendx и recvx, счётчик qcount, очереди ждущих отправителей sendq и получателей recvq и мьютекс lock. Отправка сначала ищет ждущего получателя и копирует значение прямо ему, минуя буфер, затем пробует свободное место и только потом паркует горутину. Модель памяти Go добавляет гарантию: приём из небуферизованного канала синхронизирован до завершения соответствующей отправки.
package main
import "fmt"
func trySend(ch chan int, v int) {
select {
case ch <- v:
fmt.Println("sent", v, "len", len(ch))
default: // отправка заблокировалась бы
fmt.Println("would block on", v)
}
}
func main() {
buf := make(chan int, 2)
trySend(buf, 1)
trySend(buf, 2)
trySend(buf, 3) // буфер полон, получателя нет
fmt.Println("recv", <-buf, "len", len(buf), "cap", cap(buf))
unbuf := make(chan int)
trySend(unbuf, 1) // никто не ждёт на приёме
fmt.Println("len", len(unbuf), "cap", cap(unbuf))
}Задача 4: select с таймаутом и context
Напишите обёртку, которая ждёт результат не дольше заданного времени. withTimeout создаёт context.WithTimeout, запускает работу в горутине и в select ждёт либо результат, либо ctx.Done(). Вывод: "fast" <nil> — результат пришёл без ошибки, и "" context deadline exceeded — результат пустой, а ошибка взята из ctx.Err(). Он не зависит от удачи: в первом случае таймаут — час, а во втором работа ждёт канал release, который main закрывает только после возврата из withTimeout.
Буфер ёмкостью 1 у res — не мелочь: без него поздний ответ навсегда заблокировал бы горутину на отправке, ведь получателя уже нет, — классическая утечка. Ещё два частых вопроса. Если готовы несколько веток select, спецификация выбирает одну псевдослучайно, поэтому порядок case ничего не гарантирует. А time.After в цикле с Go 1.23 больше не копит память до срабатывания: таймер, на который не осталось ссылок, сборщик мусора может забрать сразу, даже без Stop. Заодно канал таймера стал небуферизованным, и len и cap у него теперь 0. Оба изменения действуют, только если в go.mod главного модуля указано go 1.23.0 или новее.
package main
import (
"context"
"fmt"
"time"
)
func withTimeout(d time.Duration, work func(context.Context) string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
res := make(chan string, 1) // буфер 1: поздний ответ не повесит горутину
go func() { res <- work(ctx) }()
select {
case r := <-res:
return r, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
fast := func(context.Context) string { return "fast" }
release := make(chan struct{})
stuck := func(context.Context) string { <-release; return "late" }
r, err := withTimeout(time.Hour, fast)
fmt.Printf("%q %v\n", r, err)
r, err = withTimeout(20*time.Millisecond, stuck)
fmt.Printf("%q %v\n", r, err)
close(release) // stuck разблокирован: "late" уйдёт в буфер res, отправка не повиснет
}Задача 5: worker pool с WaitGroup и сбором ошибок
Постановка: обработать восемь заданий тремя воркерами и вернуть все ошибки. Задания кладутся в буферизованный канал jobs, затем он закрывается, и range в каждом воркере завершится, когда задания кончатся. Метод sync.WaitGroup.Go, появившийся в Go 1.25, сам делает Add и Done. Сигнатура worker(jobs <-chan int, …) делает канал однонаправленным: попытка отправить в него не скомпилируется с ошибкой «invalid operation: cannot send to receive-only channel <-chan int jobs (variable of type <-chan int)».
Ошибки пишутся в errs[id]: у каждого задания свой элемент среза, поэтому гонки нет — запуск с -race это подтвердил, — а wg.Wait() гарантирует, что после него все записи видны. errors.Join отбрасывает nil и склеивает остальное через перевод строки, и вывод всегда job 3: invalid input, затем job 7: invalid input, какой бы воркер ни взял задание. Если результаты нужны потоком, заведите канал results и закройте его в отдельной горутине после wg.Wait().
package main
import (
"errors"
"fmt"
"sync"
)
func process(id int) error {
if id%4 == 3 {
return fmt.Errorf("job %d: invalid input", id)
}
return nil
}
func worker(jobs <-chan int, errs []error) { // только чтение из jobs
for id := range jobs { // выход после close(jobs) и опустошения буфера
errs[id] = process(id) // у каждого id свой элемент: гонки нет
}
}
func main() {
jobs, errs := make(chan int, 8), make([]error, 8)
for id := range 8 {
jobs <- id
}
close(jobs)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() { worker(jobs, errs) })
}
wg.Wait() // все записи в errs видны после Wait
fmt.Println(errors.Join(errs...))
}Задача 6: ограничение параллелизма семафором
Если можно запускать по горутине на задачу, но одновременно должно работать не больше трёх, хватит буферизованного канала struct{} ёмкостью limit. Отправка занимает слот, чтение в defer освобождает, а четвёртая горутина блокируется на отправке, пока кто-то не закончит. Модель памяти Go прямо описывает этот приём как счётный семафор: число элементов в канале — число занятых слотов, ёмкость — максимум одновременных использований. Освобождение через defer важно: если функция вернётся раньше, например на ошибке, слот всё равно освободится, и остальные горутины не застрянут.
Программа считает одновременно работающие горутины атомарным счётчиком и печатает limit exceeded: false и [0 1 4 9 16 25 36 49 64 81]. «Максимум одновременно» мы сознательно не печатаем: он зависит от планировщика и может оказаться 1, 2 или 3, а проверяемое утверждение — «никогда больше трёх». Замыкание использует i без копирования: с Go 1.22 у каждой итерации цикла своя переменная (если go.mod объявляет go 1.22 или новее). В отличие от пула, горутин здесь столько же, сколько задач.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
const limit = 3
sem := make(chan struct{}, limit) // ёмкость буфера = число слотов
var running atomic.Int32
var exceeded atomic.Bool
var wg sync.WaitGroup
squares := make([]int, 10)
for i := range 10 { // с Go 1.22 у каждой итерации своя i
wg.Go(func() {
sem <- struct{}{} // занять слот: четвёртая горутина ждёт
defer func() { <-sem }() // освободить слот
if running.Add(1) > limit {
exceeded.Store(true)
}
squares[i] = i * i
running.Add(-1)
})
}
wg.Wait()
fmt.Println("limit exceeded:", exceeded.Load())
fmt.Println(squares)
}Как отвечать и что повторить
В любой из этих задач держите в голове три вопроса: кто закрывает канал, кто ждёт завершения горутин и как результат собирается без зависимости от порядка. Если просят предсказать вывод, сначала найдите все блокирующие операции и для каждой — кто может её разблокировать. Вопросы про typed nil в интерфейсах — отдельная тема, у неё есть свой разбор.
Для повторения перепишите задачи 5 и 6 по памяти, затем измените одну деталь — уберите close(jobs), сделайте res небуферизованным, добавьте второй close — и предскажите итог до запуска. Каждую версию прогоняйте с -race: детектор находит гонки только на реально выполненном коде, поэтому чистый запуск не доказывает их отсутствие. Сборник из ста вопросов по Go и план подготовки закроют остальные темы.
Коротко
Частые вопросы
Нужно ли закрывать каждый канал?
Нет. Tour of Go прямо говорит, что каналы не похожи на файлы: закрытие нужно, только когда получателю надо сообщить, что значений больше не будет, например чтобы завершить range. Там же правило: закрывает отправитель, а не получатель.
Чем буферизованный канал отличается от очереди с мьютексом?
Буферизованный канал — очередь фиксированной ёмкости с блокировками внутри, но он ещё встраивается в select и поддерживает close как сигнал завершения. Мьютекс со срезом уместнее, когда горутины делят состояние, а не передают значения друг другу.
Как задать приоритет одной ветке select?
Порядком case — никак: спецификация выбирает готовую ветку равномерно псевдослучайно. Приоритет делают двумя select: сначала неблокирующая проверка важного канала с default, затем обычный select по всем каналам. Тот же приём используют, чтобы сначала проверить ctx.Done().
Источники
Источники и редакционная политика
Сведения проверены 7 ноября 2026 г. Ссылки рядом с разделами указывают источники фактов и технических объяснений. Выводы, учебные сценарии и рекомендации по подготовке — редакционная работа RecallDeck.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.