Перейти к содержанию
Языки

Задачи на горутины и каналы в Go: дедлоки, select, worker pool и семафор

На Go-собеседовании конкурентность почти всегда проверяют кодом: показывают программу и спрашивают, что она напечатает, или просят написать пул воркеров. Ниже шесть таких задач. Все программы полные, запускались через go run на Go 1.26.6 и дают одинаковый вывод при каждом запуске: порядок не зависит от планировщика, а примеры с общей памятью дополнительно прогнаны с флагом -race. Сначала предскажите вывод, потом читайте разбор.

Автор: Опубликовано

9 мин чтенияРедакционный разборОбновлено
  • Go
  • горутины
  • каналы
  • конкурентность
  • собеседование
Главная мысль

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

Начать подготовку

Продолжить подготовку