1. Класичні умови Кофмана
У 1971 році компʼютерний науковець Едвард Кофман (Edward G. Coffman Jr.) описав чотири умови, без яких deadlock неможливий. Ці правила давно стали «абеткою» для співбесід і курсів з багатопоточності. Їх корисно знати не лише напамʼять, а й розуміти, навіщо вони потрібні:
- Взаємне виключення (Mutual Exclusion): принаймні один ресурс може бути захоплений лише одним потоком одночасно.
- Утримання та очікування (Hold and Wait): потік, що захопив один ресурс, може чекати інші ресурси.
- Відсутність примусового відбирання (No Preemption): ресурс не можна відібрати у потоку примусово — лише сам потік може його звільнити.
- Кругове очікування (Circular Wait): існує ланцюжок потоків, де кожен чекає ресурс, який тримає наступний.
Якщо виконуються усі чотири умови, deadlock можливий. Якщо порушити бодай одну — ймовірність взаємного блокування зникає.
Швидке дерево рішень
flowchart TD
A[Треба заблокувати кілька ресурсів?] -- Ні --> B[Звичайний lock]
A -- Так --> C[Чи можна завжди брати в одному порядку?]
C -- Так --> D[Завжди беремо в одному порядку]
C -- Ні --> E[Уникайте вкладених lockʼів]
D --> F[Мінімізуйте час під замком]
E --> F
B --> F
F --> G{Усе одно лячно?}
G -- Так --> H[Додайте таймаути та повторні спроби]
G -- Ні --> I[Спіть спокійно!]
2. Основні стратегії запобігання deadlock
Оскільки deadlock — невідʼємна частина багатопоточного світу, наше завдання — навчитися з ним співіснувати. У цьому допоможуть різні стратегії: від простих до складніших.
Завжди блокуйте ресурси в одному й тому ж порядку
Суть проста: якщо всі потоки завжди захоплюють ресурси в одному й тому ж порядку, то кругове очікування просто не зможе виникнути. Цей прийом часто називають ordered locking — «упорядковане блокування».
Приклад
Небезпечний код:
// Потік 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// Потік 2
lock (resB)
{
lock (resA)
{
// ...
}
}
Тут можливий deadlock: перший потік захопив resA і чекає resB, другий — навпаки. Обидва «зависають» і ніколи не розблокуються.
Правильний код:
// Завжди: спочатку resA, потім resB (ніколи навпаки)
lock (resA)
{
lock (resB)
{
// ...
}
}
Тепер порядок єдиний, і deadlock неможливий. Байдуже, скільки у вас ресурсів і як вони називаються — головне, щоб усі потоки брали блокування в однаковому порядку.
Типова помилка: якщо в коді знайдеться бодай одне місце, де порядок порушено, ризик deadlock повертається. Тому будьте обережні!
Уникайте блокування кількох ресурсів одночасно
Це найпростіше: якщо можна не брати замки одразу на два і більше ресурси — не беріть! Чим більше вкладених блокувань, тим вищий ризик застрягнути. Натомість можна, наприклад, скопіювати потрібні дані у тимчасові змінні, відпустити замок і вже тоді працювати з іншими ресурсами.
Використовуйте таймаути під час захоплення замка
Якщо дуже потрібно взяти кілька блокувань, допоможуть таймаути. Наприклад, замість звичного lock використовуйте методи, що дозволяють «спробувати» взяти замок, а якщо не вийшло — акуратно відкотити спробу, наприклад Monitor.TryEnter.
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// Пробуємо взяти обидва замки максимум за 2 секунди
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // не вийшло, виходимо
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return;
// Критична секція
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
Якщо другий замок не вдається взяти за 2 секунди, ми відпускаємо перший замок і виходимо. У підсумку deadlock не станеться, а деякі потоки просто відкотять свої спроби.
Мінімізуйте обсяг коду всередині блокування
Чим менша ділянка коду «під замком», тим менше часу інша гілка буде чекати. Не варто робити складні обчислення, мережеві виклики й операції з диском усередині критичної секції. Захопили замок — швидко змінили спільний ресурс — відпустили. Усе інше робіть поза ним.
Розділяйте стан — уникайте спільних ресурсів
Іноді краще не боротися з блокуваннями, а просто уникати спільного стану:
- Використовуйте незмінні обʼєкти (immutable).
- Працюйте з копіями або локальними змінними замість глобальних.
- Передавайте дані повідомленнями (Actor Model, черга повідомлень).
- Використовуйте потокобезпечні колекції: ConcurrentDictionary, ConcurrentQueue та інші з System.Collections.Concurrent.
3. Розвʼязання deadlock, якщо вже трапився
Вручну «вбити» та перезапустити (не найкращий варіант)
Найпростіший спосіб — завершити всі заблоковані процеси. Однак це несе ризик втрати даних. Це крайній випадок, до якого краще не доводити.
Виявлення deadlock у процесі роботи
Деякі системи дозволяють автоматично виявляти deadlock. Якщо потік не може взяти замок надто довго, він може записувати подію до журналу, сповіщати систему моніторингу та ініціювати відновлення.
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("Схоже, ми потрапили в deadlock або хтось тримає замок надто довго!");
// Можна ініціювати відновлення, зняти дамп або повідомити користувача
}
У розподілених системах використовують окремі механізми виявлення, що аналізують граф очікування й примусово розблоковують завислі транзакції.
Автоматичний відкат і повторна спроба
Зручна практика — «здатися» та спробувати знову: якщо не вдалося взяти всі блокування за розумний час, відкотитися, трохи почекати (часто — випадковий час, щоб розвести спроби в часі) і повторити.
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// Критична секція
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// Не вийшло — чекаємо й пробуємо знову
Thread.Sleep(500 + new Random().Next(500));
}
4. Найкращі практики та рекомендації
- Аналізуйте порядок захоплення замків. Зберіть точки входу та перевірте послідовність.
- За можливості використовуйте колекції з System.Collections.Concurrent.
- Застосовуйте інструменти аналізу коду. IDE на кшталт Rider або Visual Studio допомагають виявляти вкладені блокування.
- Записуйте до журналу тривалі спроби захоплення.
- Проводьте стрес‑тестування багатопоточних компонентів.
- Документуйте домовленості про порядок блокувань. Коментарі рятують від порушень упорядкованості.
5. Практичні приклади
Приклад: База даних
У СУБД deadlock — частий гість: дві транзакції оновлюють ті самі таблиці, але в різному порядку (A→B і B→A). Більшість СУБД уміють детектувати такі ситуації й «вбивають» одну з транзакцій.
Приклад: Асинхронні методи
У .NET якщо змішувати асинхронність і блокування, можна отримати взаємне блокування: await блокує потік, що утримує замок, а інший потік чекає той самий ресурс. Плануйте межі критичних секцій і уникайте очікувань усередині них.
6. Типові помилки під час роботи з блокуваннями
Помилка № 1: блокування рядків.
lock (myString) — погана ідея. У .NET рядки інтерновані, тож ви фактично блокуєте глобальну таблицю рядків.
Помилка № 2: різний порядок захоплення обʼєктів.
Якщо в різних місцях програми блокування беруться в різному порядку, імовірність deadlock різко зростає.
Помилка № 3: надто довгі блокування.
Тримати замок довше, ніж потрібно, або викликати зовнішні методи всередині критичної секції — легкий спосіб спричинити зависання й взаємні блокування.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ