Deadlock в каналах: разбор классической задачи

deadlock-v-kanalah

Представьте, что на собеседовании на позицию Go-разработчика вам дают такой код:

package main

func main() {
    ch := make(chan bool)
    ch <- true
    go func() {
        <-ch
    }()
    ch <- true
}

И спрашивают: «Что не так с этим кодом и как это исправить?»

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

Глубокое понимание небуферизованных каналов

Каналы — это не просто очереди

Первое и самое важное заблуждение: считать каналы на 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’ами на практике?

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: