Взаимная блокировка (deadlock) при работе с каналами в Go

Взаимная блокировка deadlock

Перед нами код на Go, который использует горутины и каналы для вычисления суммы квадратов. Для начала попробуем ответить на вопрос: что выведет программа и почему? А затем разберемся, как исправить все имеющиеся проблемы.

Исходный код

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int)
    wg := &sync.WaitGroup{}
    wg.Add(3)

    for i := 0; i < 3; i++ {
        go func(v int) {
            defer wg.Done()
            ch <- v * v
        }(i)
    }

    wg.Wait()
    var sum int
    for v := range ch {
        sum += v
    }

    fmt.Printf("result: %d\n", sum)
}

Что происходит в исходном коде?

Сначала проанализируем, что пытается сделать программа:

  1. Создается небуферизованный канал ch := make(chan int)
  2. Запускаются 3 горутины, каждая из которых:
    1. Вычисляет квадрат чисел 0, 1 и 2
    2. Отправляет результат в канал
    3. Вызывает wg.Done() для уменьшения счетчика WaitGroup
  3. Главная горутина ждет завершения всех горутин через wg.Wait()
  4. Пытается прочитать все значения из канала с помощью range ch
  5. Суммирует значения и выводит результат

Проблема №1. Взаимная блокировка (Deadlock)

Это главная и самая критическая проблема.

ch := make(chan int) // небуферизованный канал

Что происходит:

Небуферизованный канал требует одновременной готовности отправителя и получателя. В нашем случае:

  • Горутины-отправители пытаются отправить данные в канал: ch <- v * v
  • Главная горутина в это время заблокирована на wg.Wait() и не читает из канала
  • Результат: 3 горутины заблокированы на операции отправки, а главная горутина заблокирована на ожидании.

Как проявляется:

fatal error: all goroutines are asleep - deadlock!

Проблема №2. Незакрытый канал

Даже если бы не было взаимной блокировки, есть вторая проблема:

for v := range ch {
    sum += v
}

Конструкция range ch будет читать из канала до тех пор, пока канал не будет закрыт. Но в коде нет вызова close(ch), поэтому цикл будет вечно ждать новые данные даже после получения всех 3-х значений.

Проблема №3. Несинхронизированное взаимодействие

В коде пытаются использовать WaitGroup для синхронизации завершения горутин, но не синхронизируется сам процесс обмена данными через канал.

Как можно исправить?

Рассмотрим 3 способа, как это можно исправить.

1. Запуск получателя до ожидания

Получатель (range по каналу) запускается в главной горутине ДО того, как отправлены все данные. При этом используется отдельная горутина, которая закрывает канал сразу после того, как все отправители завершили работу.

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int)
    wg := &sync.WaitGroup{}

    wg.Add(3)

    // запускаем горутины для отправки данных
    for i := 0; i < 3; i++ {
        go func(v int) {
            defer wg.Done()
            ch <- v * v
        }(i)
    }

    // запускаем отдельную горутину для закрытия канала
    go func() {
        wg.Wait()
        // закрываем канал после завершения всех отправителей
        close(ch)
    }()

    // читаем из канала в главной горутине
    var sum int
    for v := range ch {
        sum += v
    }

    fmt.Printf("result: %d\n", sum)
    // result: 5 (0 + 1 + 2 = 0² + 1² + 4² = 5)
}

2. Использование буферизованного канала

Создаётся канал с буфером, куда помещаются все данные без блокировки отправителей (пока буфер не заполнится). После того как все отправители завершились, канал закрывается в главной горутине, и затем происходит чтение.

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int, 3) // буфер на 3 элемента
    wg := &sync.WaitGroup{}

    wg.Add(3)
    for i := 0; i < 3; i++ {
        go func(v int) {
            defer wg.Done()
            ch <- v * v
        }(i)
    }

    wg.Wait()
    // важно закрыть канал после завершения всех отправок
    close(ch)

    var sum int
    for v := range ch {
        sum += v
    }

    fmt.Printf("result: %d\n", sum) // result: 5
}

3. Полное разделение ответственности

Отправители, получатель и синхронизация живут отдельно. Получатель запущен в своей горутине, отправители — в своих. Есть отдельный канал done, сигнализирующий о том, что получатель закончил работу.

package main 

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int)
    done := make(chan struct{})

    // горутина-получатель
    go func() {
        var sum int
        for v := range ch {
            sum += v
        }
        fmt.Printf("result: %d\n", sum)
        close(done)
    }()

    // горутины-отправители
    wg := &sync.WaitGroup{}
    wg.Add(3)
    for i := 0; i < 3; i++ {
        go func(v int) {
            defer wg.Done()
            ch <- v * v
        }(i)
    }
  
    // синхронизация
    wg.Wait()
    // закрываем канал данных
    close(ch)
    // ждем завершения получателя
    <- done
}

Ключевые выводы

  1. Небуферизованные каналы требуют одновременной готовности отправителя и получателя.
  2. Всегда закрывайте каналы со стороны отправителя, когда используете range.
  3. WaitGroup хорош для синхронизации завершения работы, но не для синхронизации доступа к данным.
  4. Порядок операций критически важен при работе с каналами.
  5. Используйте буферизованные каналы, когда отправители и получатели работают асинхронно.

Правильное использование горутин и каналов — ключ к написанию эффективных и надежных concurrent-программ на Go.

Напишите в комментариях — как вы обнаружили проблему и какой способ решения оказался самым эффективным? Может быть, у вас есть свои приемы для избежания deadlock в горутинах.

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

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