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

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

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

Middle ~22 мин 10 вопросов

Горутины и каналы: вопросы на собеседовании Golang с ответами

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

Как отвечать: определение → как работает внутри → где ловушка → пример. Отвечайте вслух до чтения ответа — собеседование это устный жанр, и «понимаю про себя» не равно «объясняю словами».

1. Что такое горутина и чем она отличается от потока ОС?

Горутина — это легковесный поток в рантайме Go, которым управляет планировщик Go, а не операционная система. Отличия трёх уровней. Размер: горутина стартует со стеком около 2 КБ и растёт по необходимости (до гигабайта), поток ОС резервирует мегабайты. Цена создания: запуск горутины — сотни наносекунд и несколько сотен байт служебных данных, создание потока — системный вызов на микросекунды и килобайты. Планирование: тысячи горутин мультиплексируются на небольшое число потоков ОС (модель M:N): планировщик Go сам раздаёт горутины на логические процессоры (GOMAXPROCS), вытесняет долгие вычисления по точкам безопасного останова и умеет красть работу у занятых потоков (work stealing).

go func() {
    fmt.Println("горутина запущена одним словом go")
}()

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

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

ch := make(chan int)     // небуферизованный: send ждёт receive
ch2 := make(chan int, 3) // буфер на 3: первые 3 send проходят сразу

3. Что произойдёт при отправке в закрытый канал и при чтении из закрытого?

Правило двух сторон. Отправка в закрытый канал — паника «send on closed channel»: закрытие говорит «новых значений не будет», поэтому писать после него нельзя. Чтение из закрытого канала паники не вызывает: приём немедленно возвращает нулевое значение типа и false во второй переменной (comma-ok). Закрывает канал всегда отправитель; если отправителей несколько, закрытие согласовывают через sync.Once или отдельный канал сигнала. Закрывать канал со стороны получателя — классическая ошибка: отправитель на не закрытый вовремя канал зависнет, а на закрытый — запаникует.

ch := make(chan int, 2)
ch <- 1
close(ch)
v, ok := <-ch // 1, true
v, ok = <-ch  // 0, false — канал закрыт и пуст

4. Как работает select? Что делает default и что будет при нескольких готовых case?

select ждёт готовности нескольких операций с каналами одновременно: срабатывает тот case, чья операция может выполниться. Если готовы несколько — рантайм выбирает случайный, равновероятный; это защищает от голодания одного из каналов. Пустой select{} блокирует горутину навсегда. Ветвь default срабатывает, когда ни одна операция не готова — это превращает select в «попробовать без ожидания» (non-blocking) или в опрос с таймаутом через time.After. Таймаут в select — идиома, которую спрашивают почти всегда: ожидание значения и time.After(d) в одном select, таймаут-ветвь спасает от вечного зависания.

select {
case v := <-ch:
    fmt.Println("получили", v)
case <-time.After(2 * time.Second):
    fmt.Println("таймаут")
default:
    fmt.Println("нечего читать — идём дальше")
}

5. Горутина пишет в канал, а читать её никто не будет. Что случится?

Утечка горутины — самый популярный практический вопрос. Отправка в небуферизованный (или полный) канал без читателя блокирует горутину навсегда: она не будет собрана сборщиком мусора, пока жив канал, и держит свой стек. Классические сценарии: горутина ждёт значение из канала, который уже никто не закроет; worker забыл обработать отмену контекста; время.After в цикле при пропуске чтения. Лечение — проектировать выход: контекст с отменой, select с ctx.Done() и таймаутами, отдельный канал-сигнал завершения. Диагностика: pprof goroutine profile и инструменты вроде goleak в тестах.

func worker(ctx context.Context, ch <-chan int) {
    for {
        select {
        case v, ok := <-ch:
            if !ok {
                return // канал закрыт
            }
            process(v)
        case <-ctx.Done():
            return // отмена — горутина не утекает
        }
    }
}

6. Что такое состояние гонки (data race) и как его найти?

Состояние гонки — одновременный доступ двух горутин к одной переменной, когда хотя бы один доступ — запись, и никакой синхронизации нет. Гонка — это не «неудачное чередование», а неопределённое поведение: чтение может увидеть частично записанное значение. Go ловит это детектором: тесты или бинарник с флагом -race инструментируют доступ к памяти и печатают трассу при обнаружении. Детектор находит только те гонки, которые реально произошли в прогоне, поэтому важен параллельный тест с t.Parallel и нагрузкой. Защита — по ситуации: каналы («не общайтесь памятью, делитесь памятью через общение»), sync.Mutex для критических секций, sync/atomic для счётчиков и флагов.

go test -race ./...

7. Для чего нужны sync.WaitGroup и errgroup?

sync.WaitGroup ждёт завершения набора горутин: Add(n) перед запуском, Done() через defer внутри каждой, Wait() блокирует до нуля. Подводный камень — Add после Wait: счётчик надо наращивать до старта ждущего. errgroup из golang.org/x/sync — надстройка для задач «запусти пачку и собери первую ошибку»: Go возвращает ошибку, Wait дожидается всех и отдаёт первую, WithContext привязывает группу к контексту и отменяет всех при первой ошибке. На собеседовании полезно назвать разницу: WaitGroup — только «дождись», errgroup — «дождись и обработай ошибку с отменой».

g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
    u := u
    g.Go(func() error { return fetch(ctx, u) })
}
if err := g.Wait(); err != nil {
    log.Fatal(err) // первая ошибка; остальные отменены через ctx
}

8. Как ограничить число одновременно работающих горутин?

Три идиомы. Семафор на канале: make(chan struct{}, N), горутина перед работой пишет в него (захват слота) и читает по завершении — больше N одновременно не войдут. Пул воркеров: N горутин читают задачи из общего канала до его закрытия — самый частый паттерн в продакшене. errgroup.SetLimit(N) — готовое ограничение поверх errgroup: Go блокируется, пока не освободится слот. В ответе стоит сказать и про ошибку «запустить десять тысяч горутин разом»: память стека мелкая, но работа обычно упирается в системы ниже по потоку — базу, сеть, — и ограничение параллелизма защищает их.

sem := make(chan struct{}, 5)
for _, job := range jobs {
    job := job
    go func() {
        sem <- struct{}{}        // захват слота
        defer func() { <-sem }() // освобождение
        handle(job)
    }()
}

9. Почему main может завершиться раньше горутин?

Завершение main завершает всю программу, не дожидаясь остальных горутин: спавн горутины не создаёт связи «родитель ждёт ребёнка». Если результат горутин нужен — ждите явно: sync.WaitGroup, чтение из каналов-результатов, errgroup.Wait. Если результат не нужен, но работу важно довести — это отдельный вопрос жизненного цикла: graceful shutdown по сигналу, контекст с таймаутом, отложенные задачи в очередь (asynq). Ответ «поставлю time.Sleep» — красный флаг: sleep фиксирует гонку со временем вместо синхронизации.

10. Что выведет программа: value := <-ch из nil-канала?

Операции с nil-каналом блокируются навсегда: отправка, приём и close на нём вечны (close — паника). select с nil-каналом просто игнорирует этот case — на этом построен приём «отключить ветку»: присвоить case-каналу nil после того, как он больше не нужен, и select перестанет его опрашивать. Нулевой канал получается из make без инициализации, из map или структуры, где поле-канал не заполнили. На собеседовании это вопрос на знание трёх состояний канала: nil — блокируется навсегда; открытый — работает; закрытый — отдаёт нулевое значение и ok=false, отправка паникует.

Прокачать тему в самоучителе

Читается быстро, запоминается практикой. Эти курсы закрывают тему с запуском кода прямо на странице: