43. Deadlock, Livelock и типичные ошибки конкурентности
«Управление сложностью — вот сама суть программирования».
— Брайан Керниган
Парадигма конкурентности в Go (горутины и каналы) значительно упрощает написание асинхронного кода по сравнению с адом функций обратного вызова (Callback Hell) или ручным низкоуровневым управлением системными потоками ОС (POSIX threads в C/C++). Но за кажущуюся легкость синтаксиса go f() мы платим появлением целого класса тонких архитектурных ловушек.
Когда сотни тысяч независимых горутин пытаются разделить между собой общие структуры данных или обменяться сообщениями через каналы, система начинает подчиняться строгим законам теории очередей и параллельных вычислений. В этой главе мы разберем главных врагов инженера распределенных систем: взаимные блокировки (Deadlock), динамические зависания (Livelock), ресурсное голодание (Starvation) и коварные утечки горутин.
1. Deadlock (Взаимная блокировка)
Deadlock — это состояние системы, при котором две или более горутины бесконечно ждут освобождения ресурсов друг от друга, из-за чего прогресс выполнения программы полностью останавливается.
В компьютерных науках взаимная блокировка строго описывается четырьмя условиями Коффмана (Coffman conditions). Deadlock возможен, только если выполняются все четыре условия одновременно (условия необходимые; достаточными они становятся лишь для ресурсов в единственном экземпляре, как мьютекс):
- Mutual exclusion (Взаимное исключение): Ресурс является неделимым и не может использоваться несколькими горутинами одновременно (например, мьютекс или ячейка небуферизованного канала).
- Hold and wait (Удержание и ожидание): Горутина уже захватила ресурс А и одновременно пытается захватить ресурс Б.
- No preemption (Отсутствие вытеснения): Рантайм или ОС не могут принудительно отобрать ресурс (мьютекс) у горутины, пока она добровольно его не освободит.
- Circular wait (Круговое ожидание): Образуется замкнутый цикл зависимостей: Горутина 1 ждет ресурс Горутины 2, а Горутина 2 ждет ресурс Горутины 1.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
G1["Горутина 1"]:::accent -->|"Захватила"| MA["Mutex A"]
G1 -->|"Пытается захватить (Ждет)"| MB["Mutex B"]
G2["Горутина 2"] -->|"Захватила"| MB
G2 -->|"Пытается захватить (Ждет)"| MA
Классический AB-BA Deadlock
Самый распространенный сценарий взаимной блокировки в коде:
func main() {
var muA, muB sync.Mutex
go func() { // Горутина 1
muA.Lock()
time.Sleep(10 * time.Millisecond) // Имитация работы
muB.Lock() // ЖДЕТ МЬЮТЕКС B
muB.Unlock()
muA.Unlock()
}()
// Горутина 2 (Main)
muB.Lock()
time.Sleep(10 * time.Millisecond) // Имитация работы
muA.Lock() // ЖДЕТ МЬЮТЕКС A
muA.Unlock()
muB.Unlock()
}
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
G1["Горутина 1<br>Владеет Mutex A"]:::accent -->|"Блокируется<br>ожидая Mutex B"| MB["Mutex B"]
G2["Горутина 2<br>Владеет Mutex B"]:::accent -->|"Блокируется<br>ожидая Mutex A"| MA["Mutex A"]
MB -.->|"Принадлежит"| G2
MA -.->|"Принадлежит"| G1
Под капотом: Глобальный vs Локальный Deadlock
Как лечить:
- Всегда захватывать мьютексы в строго одинаковом порядке во всех частях программы (например, всегда сначала
muA, потомmuB). Если порядок зафиксирован глобально, круговое ожидание (Circular wait) становится физически невозможным. - Использовать неблокирующие конструкции (например, каналы с таймаутами через
select).
2. Livelock (Динамическая блокировка)
Если Deadlock — это когда две горутины уснули навсегда, то Livelock — это когда они обе бодрствуют, постоянно меняют свое состояние, реагируя друг на друга, но система в целом не делает никакой полезной работы.
Аналогия из жизни: Вы идете по узкому коридору и сталкиваетесь лицом к лицу с коллегой. Вы шагаете вправо, чтобы пропустить его, и он одновременно шагает вправо (относительно вас). Вы шагаете влево, и он синхронно влево. Вы непрерывно двигаетесь (нет Deadlock), тратите энергию, но ни один из вас не может продвинуться вперед (нет полезного прогресса).
Пример из архитектуры (Polling с откатом)
Часто Livelock возникает в распределенных системах или при реализации кастомных спин-локов (Spinlocks), когда две горутины пытаются захватить два ресурса, и при неудаче "вежливо" отпускают первый и пробуют заново с теми же таймингами.
// Псевдокод Livelock
func worker(mu1, mu2 *sync.Mutex) {
for {
mu1.Lock()
if !mu2.TryLock() { // Если второй занят
mu1.Unlock() // Отпускаем первый (вежливость)
continue // Начинаем заново
}
// ... полезная работа ...
mu2.Unlock()
mu1.Unlock()
break
}
}
Важно, что воркеры захватывают мьютексы в разном порядке:
var a, b sync.Mutex
go worker(&a, &b) // Воркер 1: сначала a, потом b
go worker(&b, &a) // Воркер 2: сначала b, потом a
(При одинаковом порядке, worker(&a, &b) в обеих горутинах, второй воркер просто ждал бы на mu1.Lock(), и livelock бы не было.) Если два таких воркера войдут в цикл одновременно, они будут бесконечно захватывать по одному мьютексу, видеть, что второй занят, отпускать свой и начинать заново.
sequenceDiagram
autonumber
participant W1 as Воркер 1
participant W2 as Воркер 2
participant M1 as Mutex 1
participant M2 as Mutex 2
W1->>M1: Lock(M1) - Успех
W2->>M2: Lock(M2) - Успех
W1->>M2: TryLock(M2) - Занято!
W2->>M1: TryLock(M1) - Занято!
Note over W1,W2: Синхронная вежливость: оба отпускают свои замки
W1->>M1: Unlock(M1)
W2->>M2: Unlock(M2)
Note over W1,W2: Повторный заход на следующем такте... (100% CPU)
Mechanical Sympathy: В отличие от Deadlock, где горутины паркуются и не потребляют CPU, при Livelock горутины находятся в статусе _Grunning. Они сжигают 100% ядер процессора, но пропускная способность сервиса равна нулю.
Как лечить: Вносить случайность (Jitter) в задержки перед повторными попытками. Вместо мгновенного continue, нужно сделать time.Sleep(random_duration), чтобы разбить синхронность их действий.
3. Goroutine Leaks (Утечки горутин)
Это самая распространенная проблема конкурентности в Go.
Память в Go управляется сборщиком мусора (GC). Если вы забудете закрыть файл, ОС в конце концов закроет его при завершении процесса (или GC удалит объект).
Но горутина никогда не удаляется сборщиком мусора. Стек горутины и ее структура g остаются в куче, пока функция горутины не сделает return.
Сценарий утечки: Брошенный канал
func processRequest() error {
ch := make(chan Result)
go func() {
// Делаем тяжелый запрос к внешней API
res := fetchFromAPI()
ch <- res // ОПАСНОСТЬ: отправка в небуферизованный канал
}()
select {
case <-ch:
return nil
case <-time.After(1 * time.Second):
return errors.New("timeout") // Мы выходим по таймауту!
}
}
Как лечить:
- Использовать буферизованный канал:
make(chan Result, 1). Тогда горутина положит результат в буфер (даже если его никто не прочитает) и успешно завершится. - Использовать отмену через
Context(из статьи 39. Context. Управление жизненным циклом операций).
4. Starvation (Голодание)
Голодание возникает, когда горутина готова к выполнению, но планировщик Go или примитив синхронизации постоянно отдают ресурсы другим, более "агрессивным" горутинам.
Проблема "жадных" горутин
До появления асинхронного вытеснения в Go 1.14 (статья 35. Scheduler Go. G, M, P и work stealing), горутина, выполняющая бесконечный математический цикл (без вызова других функций или выделения памяти), могла захватить поток ОС (M) навсегда. Другие горутины в очереди этого процессора (P) голодали.
Сейчас планировщик вытесняет горутины по sysmon сигналу SIGURG. Однако голодание на мьютексах все еще возможно, если одна горутина постоянно дергает Lock() / Unlock() в плотном цикле, а другая долго висит в ожидании своей очереди.
5. Ошибки дизайна: "Закрытие закрытого"
Работа с каналами требует строгой дисциплины в отношении жизненного цикла (Ownership). Отсутствие дисциплины приводит к панике в рантайме.
Правила, нарушающие целостность CSP:
- Запись в закрытый канал вызывает panic.
- Повторное закрытие канала вызывает panic.
func badDesign() {
ch := make(chan int)
// Воркер 1
go func() {
ch <- 1
close(ch)
}()
// Воркер 2
go func() {
ch <- 2 // Если Воркер 1 уже сделал close(), здесь будет PANIC!
}()
<-ch
<-ch
}
Паттерн лечения: Закрывать канал должен только тот, кто в него пишет (Sender). Если писателей несколько (N Send, 1 Receive), никто из них не имеет права закрывать data-канал. Вместо этого Receiver (или оркестратор) должен закрыть специальный done-канал, который писатели слушают через select, понимая, что пора остановиться.
Итог и диагностика
Любой высоконагруженный бэкенд на Go рано или поздно сталкивается с этими багами. Ваш инструментарий:
- Детектор гонок (
-race): Находит Data Races во время тестирования. - pprof (
net/http/pprof): Зайдя на эндпоинт/debug/pprof/goroutine?debug=1, вы можете увидеть дамп стеков всех текущих горутин. Если их количество аномально растет и они все висят наchan receive— у вас Goroutine Leak. - Trace (
go tool trace): Позволяет увидеть, кто и как долго ждет на мьютексах (выявление Starvation и Lock Contention). - Профиль
goroutineleak(Go 1.27): Эндпоинт/debug/pprof/goroutineleakпоказывает горутины, навсегда заблокированные на каналах и мьютексах, недостижимых из живых горутин.
Мы прошли долгий путь изучения языка: от базового синтаксиса до внутреннего устройства планировщика, управления памятью и паттернов конкурентности. В следующей, финальной статье этого раздела, мы соберем всю картину воедино и подведем черту перед переходом к "Deep Go": 44. Итоги раздела. Что нужно знать перед переходом к Deep Go.