← Назад к вопросам

Почему случается deadlock?

1.7 Middle🔥 151 комментариев
#Конкурентность и горутины

Комментарии (1)

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Что такое Deadlock и почему он возникает?

Deadlock (взаимная блокировка) — это ситуация в многопоточном или распределённом программировании, когда два или более потока/процесса бесконечно ожидают друг друга, не имея возможности продолжить выполнение. Это критическая проблема параллелизма, приводящая к "зависанию" системы.

Необходимые условия для возникновения Deadlock

Для возникновения взаимной блокировки должны одновременно выполняться четыре условия Коффмана:

1. Взаимное исключение (Mutual Exclusion)

Ресурс не может использоваться несколькими потоками одновременно. Если один поток захватил ресурс, другие должны ждать его освобождения.

var mu sync.Mutex
mu.Lock() // Ресурс заблокирован для других
// ... критическая секция
mu.Unlock()

2. Удержание и ожидание (Hold and Wait)

Поток удерживает как минимум один ресурс и одновременно ожидает получения другого ресурса, который удерживается другим потоком.

3. Отсутствие вытеснения (No Preemption)

Ресурсы нельзя принудительно забрать у потока — только сам поток может добровольно освободить захваченные ресурсы.

4. Круговое ожидание (Circular Wait)

Существует циклическая цепочка потоков, где каждый поток ждёт ресурс, удерживаемый следующим потоком в цепочке.

Пример Deadlock в Go

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mu1, mu2 sync.Mutex
    
    // Горутина 1: захватывает mu1, затем пытается захватить mu2
    go func() {
        mu1.Lock()
        fmt.Println("Горутина 1 захватила mu1")
        time.Sleep(100 * time.Millisecond)
        
        mu2.Lock() // Блокировка: ждёт mu2, который у горутины 2
        fmt.Println("Горутина 1 захватила mu2")
        
        mu2.Unlock()
        mu1.Unlock()
    }()
    
    // Горутина 2: захватывает mu2, затем пытается захватить mu1
    go func() {
        mu2.Lock()
        fmt.Println("Горутина 2 захватила mu2")
        time.Sleep(100 * time.Millisecond)
        
        mu1.Lock() // Блокировка: ждёт mu1, который у горутины 1
        fmt.Println("Горутина 2 захватила mu1")
        
        mu1.Unlock()
        mu2.Unlock()
    }()
    
    time.Sleep(2 * time.Second)
    fmt.Println("Программа завершена (это сообщение может не вывестись)")
}

Типичные сценарии возникновения Deadlock в Go

1. Неправильный порядок захвата мьютексов

Самая частая причина — нарушение соглашения о порядке блокировок:

// ПРАВИЛЬНО: одинаковый порядок захвата
func transfer(a, b *Account, amount int) {
    a.mu.Lock()
    b.mu.Lock()
    defer a.mu.Unlock()
    defer b.mu.Unlock()
    // ... операции
}

// ОПАСНО: разный порядок в разных вызовах
func process1(x, y *Resource) {
    x.mu.Lock()
    y.mu.Lock() // Потенциальный deadlock
}

func process2(x, y *Resource) {
    y.mu.Lock()
    x.mu.Lock() // Потенциальный deadlock
}

2. Забытые Unlock() и повторные Lock()

func problematic() {
    var mu sync.Mutex
    mu.Lock()
    
    if condition {
        // Забыли mu.Unlock() перед возвратом
        return // УТЕЧКА БЛОКИРОВКИ
    }
    
    mu.Lock() // DEADLOCK: повторная блокировка того же мьютекса
    mu.Unlock()
    mu.Unlock()
}

3. Взаимодействие через каналы

func channelDeadlock() {
    ch := make(chan int)
    
    // Горутина ждёт чтения
    go func() {
        <-ch // Ждём данные
    }()
    
    // Основной поток тоже ждёт
    ch <- 42 // DEADLOCK: нет готовых получателей при небуферизованном канале
    
    // Решение: использовать буферизованный канал
    // ch := make(chan int, 1)
}

4. WaitGroup с ошибками

func waitGroupDeadlock() {
    var wg sync.WaitGroup
    
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            // Забыли wg.Done() 
            if id == 1 {
                return // Один воркер не вызывает Done()
            }
            wg.Done()
        }(i)
    }
    
    wg.Wait() // DEADLOCK: бесконечное ожидание
}

Как предотвращать Deadlock в Go

1. Соблюдать порядок блокировок

Всегда захватывать мьютексы в одинаковом порядке во всей программе. Можно использовать приоритизацию ресурсов:

type Resource struct {
    ID    int
    mutex sync.Mutex
}

func lockResources(resources ...*Resource) {
    // Сортируем по ID перед блокировкой
    sort.Slice(resources, func(i, j int) bool {
        return resources[i].ID < resources[j].ID
    })
    
    for _, r := range resources {
        r.mutex.Lock()
    }
}

2. Использовать timeout-механизмы

func safeLock(mu *sync.Mutex) bool {
    acquired := make(chan struct{})
    
    go func() {
        mu.Lock()
        close(acquired)
    }()
    
    select {
    case <-acquired:
        return true
    case <-time.After(100 * time.Millisecond):
        return false // Не удалось захватить за разумное время
    }
}

3. Применять Select с default для неблокирующих операций

func nonBlockingChannel() {
    ch := make(chan int, 1)
    
    select {
    case ch <- 42:
        fmt.Println("Отправлено")
    default:
        fmt.Println("Канал занят, пропускаем")
    }
}

4. Использовать инструменты детектирования

  • Go race detector: go run -race main.go
  • Статический анализ: go vet, staticcheck
  • pprof для анализа блокировок

5. Паттерн "worker pool" для каналов

func safeWorkerPool() {
    jobs := make(chan int, 10)
    results := make(chan int, 10)
    
    // Запускаем воркеры
    for w := 1; w <= 3; w++ {
        go func(id int) {
            for job := range jobs {
                results <- job * 2
            }
        }(w)
    }
    
    // Отправляем задания
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
    
    // Собираем результаты
    for i := 1; i <= 5; i++ {
        fmt.Println(<-results)
    }
}

Заключение

Deadlock — это системная проблема, возникающая при совпадении четырёх условий. В Go наиболее уязвимыми местами являются мьютексы с нарушением порядка захвата, каналы без буфера и ошибки с WaitGroup. Профилактика включает:

  • Строгий протокол порядка блокировок
  • Использование таймаутов
  • Тщательное проектирование concurrent-логики
  • Регулярное тестирование с race detector

Понимание механизмов возникновения deadlock позволяет писать более надёжный конкуррентный код, что критически важно для высоконагруженных Go-приложений.