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.), М. Элфик и А. Шошани доказали, что ресурсный тупик возможен лишь при одновременном выполнении четырёх необходимых условий:
- Взаимное исключение (Mutual Exclusion): Хотя бы один ресурс удерживается в монопольном (неразделяемом) режиме.
- Удержание и ожидание (Hold and Wait): Процесс уже удерживает минимум один ресурс и одновременно блокируется, запрашивая дополнительные ресурсы, занятые другими процессами.
- Отсутствие вытеснения (No Preemption): Выделенные ресурсы нельзя принудительно отобрать у процесса; он освобождает их только добровольно.
- Циклическое ожидание (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}}$ только со строго большим рангом:
【Доказательство от противного】: Допустим, возник цикл ожидания $\{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 и немедленного освобождения без продвижения («танец вежливости» в коридоре) | Система в целом работает, но низкоприоритетный поток вечно обходят в очереди |
| Статус ОС и загрузка CPU | SLEEPING / 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) |
