Featured image of post Почему всё останавливается, когда каждый ждёт другого: Теория взаимных блокировок (Deadlock), графы распределения ресурсов, алгоритм банкира и Livelock

Почему всё останавливается, когда каждый ждёт другого: Теория взаимных блокировок (Deadlock), графы распределения ресурсов, алгоритм банкира и Livelock

Фундаментальное математическое и системное исследование дедлоков: задача об обедающих философах Дейкстры, четыре условия Коффмана (1971), теоремы о циклах графа распределения ресурсов (RAG), доказательство полного упорядочения блокировок, трассировка матрицы 5×3 алгоритма банкира, графы ожидания (WFG) в СУБД, Wait-Die против Wound-Wait, алгоритм страуса и Livelock против голодания.

1. Введение: Задача об обедающих философах и четыре условия Коффмана (1971)

В 1965 году нидерландский информатик Эдсгер В. Дейкстра (Edsger W. Dijkstra) сформулировал знаменитую «задачу об обедающих философах» (Dining Philosophers Problem): пять философов $P_0 \dots P_4$ сидят за круглым столом; между каждыми двумя тарелками спагетти лежит одна вилка ($F_0 \dots F_4$). Чтобы есть, каждому философу нужно одновременно держать левую вилку $F_i$ и правую вилку $F_{(i+1)\bmod 5}$. Если все пятеро проголодаются в одну и ту же микросекунду и одновременно возьмут левую вилку $F_i$, каждый будет вечно ждать, пока сосед справа положит вторую вилку — вся система навсегда замрёт. Это явление называется взаимной блокировкой или тупиком (Deadlock).

Формально непустое подмножество процессов $D \subseteq \mathcal{P}$ находится в состоянии дедлока тогда и только тогда, когда каждый процесс $P_i \in D$ заблокирован в ожидании события (освобождения ресурса), которое может быть вызвано только другим процессом $P_j \in D$ из того же самого множества ожидания.

В 1971 году Эдвард Коффман (Edward G. Coffman Jr.), М. Элфик и А. Шошани доказали, что ресурсный тупик возможен лишь при одновременном выполнении четырёх необходимых условий:

  1. Взаимное исключение (Mutual Exclusion): Хотя бы один ресурс удерживается в монопольном (неразделяемом) режиме.
  2. Удержание и ожидание (Hold and Wait): Процесс уже удерживает минимум один ресурс и одновременно блокируется, запрашивая дополнительные ресурсы, занятые другими процессами.
  3. Отсутствие вытеснения (No Preemption): Выделенные ресурсы нельзя принудительно отобрать у процесса; он освобождает их только добровольно.
  4. Циклическое ожидание (Circular Wait): Существует замкнутая цепь процессов $\{P_0, P_1, \dots, P_{k-1}\}$, где каждый $P_i$ ждёт ресурс, удерживаемый $P_{(i+1)\bmod k}$.

Поскольку все четыре условия необходимы, логическое отрицание хотя бы одного из них делает дедлок математически невозможным ($0\,\%$).


2. Граф распределения ресурсов (RAG) и теоремы о циклах (Holt, 1972)

В 1972 году Ричард Холт представил состояние ресурсов в виде двудольного ориентированного графа распределения ресурсов (RAG) $G = (V, E)$, где $V = \mathcal{P} \cup \mathcal{R}$ (вершины процессов $P_i$ и типов ресурсов $R_j$ с $W_j \ge 1$ идентичными экземплярами), а дуги делятся на дуги запросов $P_i \to R_j$ и дуги назначений $R_j \to P_i$.

Связь между ориентированными циклами в $G$ и тупиками определяют три теоремы:

  • Теорема 1 (Ацикличность $\implies$ Отсутствие тупика): Если в $G$ нет ориентированных циклов (граф является DAG), ни один процесс не находится в тупике.
  • Теорема 2 (Критерий для единичных экземпляров): Если каждый ресурс имеет ровно один экземпляр ($\forall j, W_j = 1$), то ориентированный цикл в $G \iff$ тупик (необходимое и достаточное условие).
  • Теорема 3 (Недостаточность цикла при кратных экземплярах): Если хотя бы один ресурс имеет несколько экземпляров ($\exists j, W_j > 1$), наличие цикла является необходимым, но НЕ достаточным условием тупика!
  flowchart TD
    subgraph RAG_Single["Случай А: Одноэкземплярный RAG (Цикл <==> Гарантированный тупик)"]
        PA1(("P1 (Блок)")) -->|"Запрос"| RA1["R1 (1 экз.: ●)"]
        RA1 -->|"Выдан"| PA2(("P2 (Блок)"))
        PA2 -->|"Запрос"| RA2["R2 (1 экз.: ●)"]
        RA2 -->|"Выдан"| PA1
    end

    subgraph RAG_Multi["Случай Б: Многоэкземплярный RAG (Цикл P1->R1->P3->R2->P1 есть, но тупика НЕТ!)"]
        PB1(("P1 (Блок)")) -->|"Запрос"| RB1["R1 (2 экз.: ●●)"]
        RB1 -->|"Экз. #1"| PB3(("P3 (Блок)"))
        PB3 -->|"Запрос"| RB2["R2 (2 экз.: ●●)"]
        RB2 -->|"Экз. #1"| PB1
        RB1 -->|"Экз. #2"| PB2(("P2 (РАБОТАЕТ: Нет запросов!)"))
        RB2 -->|"Экз. #2"| PB4(("P4 (РАБОТАЕТ: Нет запросов!)"))
    end

В Случае Б есть цикл $P_1 \to R_1 \to P_3 \to R_2 \to P_1$, но процессы $P_2$ и $P_4$ имеют полустепень исхода $0$ (активные стоки): завершив работу и вернув свои экземпляры $R_1$ и $R_2$, они разрушат цикл!


3. Стратегия 1 (Предотвращение): Доказательство полного упорядочения блокировок

В ядрах ОС (валидатор lockdep в Linux) и многопоточном коде условие 4 (циклическое ожидание) устраняется полным упорядочением блокировок (Total Lock Ordering): каждому мьютексу $L \in \mathcal{L}$ присваивается уникальный ранг $\phi(L) \in \mathbb{N}$, и поток, удерживающий множество $\mathcal{H}$, имеет право запрашивать новый замок $L_{\text{new}}$ только со строго большим рангом:

$$\forall L_{\text{held}} \in \mathcal{H}: \quad \phi(L_{\text{held}}) < \phi(L_{\text{new}})$$

【Доказательство от противного】: Допустим, возник цикл ожидания $\{P_0, P_1, \dots, P_{k-1}\}$ ($k \ge 2$), где $P_i$ удерживает $L_i$ и ждёт $L_{(i+1)\bmod k}$. Поскольку все потоки соблюдают правило строгого возрастания $\phi$, вдоль цикла получаем:

$$\phi(L_0) < \phi(L_1) < \phi(L_2) < \cdots < \phi(L_{k-1}) < \phi(L_0) \implies \phi(L_0) < \phi(L_0)$$

Ни одно число не может быть строго меньше самого себя — противоречие ($\bot$)! Будь то банковский перевод (всегда блокировать сначала счёт с меньшим ID) или обедающие философы ($P_4$ берёт сначала правую вилку $F_0$, а затем левую $F_4$, так как $0 < 4$), циклическое ожидание становится невозможным. $\blacksquare$


4. Стратегия 2 (Избежание): Алгоритм банкира Дейкстры (Трассировка 5×3)

Когда порядок захвата динамичен, алгоритм банкира Дейкстры (1965 / Habermann, 1969) требует заранее заявить максимальную потребность $\mathbf{Max}$ и перед каждой выдачей за время $O(m \cdot n^2)$ проверяет, остаётся ли состояние безопасным (Safe State) с хотя бы одной безопасной последовательностью (Safe Sequence) ($\text{Тупик} \subsetneq \text{Небезопасное состояние}$).

Для $n=5$ процессов $\{P_0 \dots P_4\}$ и $m=3$ ресурсов с $\mathbf{Total} = (A=10, B=5, C=7)$ при сумме выданных $(7,2,5)$ начальный свободный вектор равен $\mathbf{Available} = (10,5,7) - (7,2,5) = \mathbf{(3, 3, 2)}$:

ПроцессВыдано $\mathbf{Allocation}$ $(A,B,C)$Макс. $\mathbf{Max}$ $(A,B,C)$Остаток $\mathbf{Need} = \mathbf{Max} - \mathbf{Allocation}$Поиск безопасной последовательности ($\mathbf{Work}_0 = (3,3,2)$)
$P_0$$(0, 1, 0)$$(7, 5, 3)$$(7, 4, 3)$Шаг 4 выполнен: $(7,4,3) \le (7,4,5) \to \mathbf{Work}=(7,5,5)$
$P_1$$(2, 0, 0)$$(3, 2, 2)$$(1, 2, 2)$Шаг 1 выполнен: $(1,2,2) \le (3,3,2) \to \mathbf{Work}=(5,3,2)$
$P_2$$(3, 0, 2)$$(9, 0, 2)$$(6, 0, 0)$Шаг 5 выполнен: $(6,0,0) \le (7,5,5) \to \mathbf{Work}=(10,5,7)$
$P_3$$(2, 1, 1)$$(2, 2, 2)$$(0, 1, 1)$Шаг 2 выполнен: $(0,1,1) \le (5,3,2) \to \mathbf{Work}=(7,4,3)$
$P_4$$(0, 0, 2)$$(4, 3, 3)$$(4, 3, 1)$Шаг 3 выполнен: $(4,3,1) \le (7,4,3) \to \mathbf{Work}=(7,4,5)$

Безопасная последовательность $\langle P_1, P_3, P_4, P_0, P_2 \rangle$ доказывает безопасность $t_0$. Если $P_1$ запросит $(1,0,2)$, пробная выдача оставит $\mathbf{Available}=(2,3,0) \ge \mathbf{Need}_1=(0,2,0)$ (одобрено мгновенно). Но если следом $P_0$ запросит $(0,2,0)$, остаток упадёт до $(2,1,0)$, чего не хватит ни одному процессу (небезопасное состояние): банкир отклонит запрос и заблокирует $P_0$!


5. Стратегии 3 и 4 (WFG, Wait-Die/Wound-Wait, Страус) и Livelock против голодания

Реляционные СУБД (PostgreSQL, MySQL InnoDB) строят граф ожидания (Wait-For Graph, WFG) и ищут циклы обходом в глубину (DFS) за $O(|V|+|E|)$, откатывая (rollback) транзакцию-жертву с наименьшим Undo-логом (MySQL проверяет мгновенно; PostgreSQL — через deadlock_timeout = 1s). Распределённые СУБД (Google Spanner) используют протоколы с временными метками $\text{TS}(T_i)$ Розенкранца и др. (1978): Wait-Die (невытесняющий) и Wound-Wait (вытесняющий, меньше холостых перезапусков), обязательно сохраняя исходную метку $\text{TS}(T_i)$ при перезапуске для защиты от голодания. Для редчайших переполнений системных таблиц ОС общего назначения применяют алгоритм страуса (Ostrich Algorithm), игнорируя проблему ради отсутствия ежедневных накладных расходов.

  flowchart TD
    Req["Распределённая транзакция Ti запрашивает замок, удерживаемый Tj"] --> Cmp{"Сравнение меток времени: TS(Ti) < TS(Tj) ?<br/>(Запрашивающая Ti СТАРШЕ удерживающей Tj?)"}
    Cmp -->|"Да: Ti СТАРШЕ (Older)"| WD1["[Wait-Die (невытесняющий)]: Старшая Ti ЖДЁТ младшую Tj (Старший -> Младший)<br/>[Wound-Wait (вытесняющий)]: Старшая Ti РАНИТ (откатывает) Tj и забирает замок!"]
    Cmp -->|"Нет: Ti МЛАДШЕ (Younger)"| WD2["[Wait-Die (невытесняющий)]: Младшая Ti УМИРАЕТ (откат и повтор со старым TS)<br/>[Wound-Wait (вытесняющий)]: Младшая Ti ТЕРПЕЛИВО ЖДЁТ старшую Tj (Младший -> Старший)"]

Наконец, сравним три вида отказов живучести (Liveness):

Критерий диагностикиВзаимная блокировка (Deadlock)Активный тупик (Livelock)Голодание (Starvation)
МеханизмЦиклическое ожидание спящих потоков, удерживающих ресурсыСимметричные циклы trylock и немедленного освобождения без продвижения («танец вежливости» в коридоре)Система в целом работает, но низкоприоритетный поток вечно обходят в очереди
Статус ОС и загрузка CPUSLEEPING / BLOCKED ($0\,\%$ CPU)RUNNING ($\approx 100\,\%$ CPU вхолостую при нулевом прогрессе!)Ожидание в очереди ($0\,\%$ CPU у жертвы)
Инженерное решениеПолный порядок ($\phi(L_1)<\phi(L_2)$), алгоритм банкира, откат по WFGСлучайная экспоненциальная задержка с джиттером (Random Exponential Backoff + Jitter)Старение приоритета (Aging), сохранение TS, честные FIFO-замки (MCS lock)
comments powered by Disqus