Представьте, что на собеседовании на позицию Go-разработчика вам дают такой код:
package main
func main() {
ch := make(chan bool)
ch <- true
go func() {
<-ch
}()
ch <- true
}
И спрашивают: «Что не так с этим кодом и как это исправить?»
Кажется, все просто: создали канал, записали в него, запустили читающую горутину, записали еще раз. Но код никогда не завершится! Давайте разберемся, почему.
- Глубокое понимание небуферизованных каналов
- Каналы — это не просто очереди
- Что происходит в коде построчно
- Три пути решения
- 1. «Сначала слушатель» (синхронный подход)
- 2. «Буфер как почтовый ящик» (асинхронный подход)
- 3. «Отправитель в отдельном потоке» (децентрализованный подход)
- Практические примеры из реальных проектов
- Пример 1: Ожидание инициализации
- Пример 2: Ограничение одновременных запросов
- Распространенные антипаттерны
- 1. Канал-одиночка без получателя
- 2. Слишком маленький буфер
- 3. Забытое чтение
- Как правильно выбирать между подходами
- Ключевой вывод
Глубокое понимание небуферизованных каналов
Каналы — это не просто очереди
Первое и самое важное заблуждение: считать каналы на Go обычными очередями. Небуферизованный канал — это механизм синхронизации, а не структура данных.
Когда вы пишите:
ch := make(chan bool) // небуферизованный ch <- true
Вы говорите: «Я готов передать значение, но буду ждать, пока кто-то не будет готов его принять». Это похоже на встречу двух людей: тот, кто пришел первым, ждет второго.
Что происходит в коде построчно
Давайте пройдемся по оригинальному коду с комментариями:
package main
func main() {
// 1. создали синхронную точку встречи
ch := make(chan bool)
// 2. "Я пришел с данными и жду получателя"
// Но получателя еще нет! DEADLOCK
ch <- true
// 3. Эту горутину даже не запустим
// Потому что предыдущая строка заблокировалась навсегда
go func() {
<-ch
}()
// 4. Сюда никогда не дойдем
ch <- true
}
Ключевой момент: небуферизованный канал блокирует отправителя до появления получателя, и наоборот. Это делает его идеальным для синхронизации, но опасным при неправильном использовании.
Три пути решения
1. «Сначала слушатель» (синхронный подход)
Если использовать канал как инструмент синхронизации, нужно сначала подготовить слушателя:
package main
func main() {
ch := make(chan bool)
// запускаем слушателя ПЕРЕД отправкой
go func() {
fmt.Println("Горутина: Жду первое значение...")
first := <-ch
fmt.Println("Горутина: Получил", first)
fmt.Println("Горутина: Жду второе значение...")
second := <-ch
fmt.Println("Горутина: Получил", second)
}()
// только теперь отправляем
fmt.Println("Main: Отправляю первое значение")
ch <- true
fmt.Println("Main: Отправляю второе значение")
ch <- true
time.Sleep(100 * time.Millisecond)
}
Аналогия: Вы не кричите в пустую комнату «Принесите кофе!» и ждете ответа. Вы сначала находите бариста, устанавливаете зрительный контакт, и только тогда делаете заказ.
2. «Буфер как почтовый ящик» (асинхронный подход)
Буферизованный канал — это уже очередь, но с ограничением:
package main
func main() {
// почтовый ящик на 2 письма
ch := make(chan bool, 2)
// могу положить 2 письма, даже если почтальона нет
ch <- true // письмо 1 в ящике
ch <- true // письмо 2 в ящике
// ch <- true // а вот третье уже не войдет и заблокируется!
// почтальон забирает
go func() {
<- ch // забираю первое
<- ch // забираю второе
}()
time.Sleep(100 * time.Millisecond)
}
Важно: буфер не делает канал «безопасным» — он просто добавляет место для временного хранения данных. Если буфер заполнен, вы снова блокируетесь!
3. «Отправитель в отдельном потоке» (децентрализованный подход)
Иногда проще отправить данные тоже из горутины:
package main
func main() {
ch := make(chan bool)
// запускаем поставщика данных
go func() {
ch <- true // поставка 1
ch <- true // поставка 2
}()
// основной поток получает
<-ch // получение 1
<-ch // получение 2
}
Паттерн: Этот подход часто используется в worker pool, где несколько горутин-работников отправляют результаты в основной поток.
Практические примеры из реальных проектов
Пример 1: Ожидание инициализации
// типичная ошибка
func StartServer() {
ready := make(chan bool)
go func() {
// долгая инициализация
time.Sleep(2 * time.Second)
ready <- true // говорим, что сервер готов
}()
<-ready // ждем готовности
fmt.Println("Сервер запущен")
}
// правильно: готовим получателя заранее
func StartServerCorrect() {
ready := make(chan bool)
// сразу запускаем ожидание готовности
go func() {
<-ready
fmt.Println("Сервер запущен")
}()
// потому инициализируем
time.Sleep(2 * time.Second)
ready <- true
}
Пример 2: Ограничение одновременных запросов
func ProcessTasks(tasks []string, maxConcurrent int) {
// семафор через канал
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t string) {
defer wg.Done()
// занимаем слот
sem <- struct{}{}
defer func() {
<-sem // освобождаем слот
}()
// выполняем задачу
process(t)
}(task)
}
wg.Wait()
}
Распространенные антипаттерны
1. Канал-одиночка без получателя
// ПЛОХО: кто будет читать?
func logMessage(msg string) {
ch := make(chan string)
ch <- msg // Deadlock!
}
2. Слишком маленький буфер
// ПЛОХО: 10 горутин пытаются писать в канал с буфером 1
func Process() {
ch := make(chan int, 1) // буфер всего 1
for i := 0; i < 10; i++ {
go func(x int) {
ch <- x // 9 горутин будут ждать вечно
}(i)
}
}
3. Забытое чтение
package main
func main() {
ch := make(chan int, 5)
for i := 0; i < 5; i++ {
// заполняем буфер
ch <- i
}
// а читать забыли!
// канал заполнен, но программа завершится
// данные в буфере будут потеряны
}
Как правильно выбирать между подходами

Простое правило: Если не знаете, какой размер буфера выбрать, начните с небуферизованного канала. Он заставит вас правильно проектировать взаимодействие горутин.
Ключевой вывод
Представьте каналы не как трубы или очереди, а как точки встречи:
- Небуферизованный канал — это встреча один на один. Оба участника должны прийти одновременно.
- Буферизованный канал — это ящик для писем. Можно оставить письмо, даже если адресата нет, но только если ящик не полон.
Эта задачка — не просто проверка знания синтаксиса. Это проверка того, понимаете ли вы философию конкурентности Go: «Не общайтесь разделением памяти. Разделяйте память через общение. (Don’t communicate by sharing memory, share memory by communicating.)»
Напишите в комментариях, сталкивались ли вы с подобными deadlock’ами на практике?