1. Приклад взаємного блокування у C#
Deadlock — це ситуація, коли кожен потік у групі зайняв ресурс і чекає звільнення іншого ресурсу, який утримує інший потік із тієї самої групи, — і ніхто нічого не дочекається.
Розберімося на практиці. Припустімо, маємо два об’єкти-блокувальники і два потоки. Кожен потік блокує один об’єкт, а потім намагається захопити другий.
using System;
using System.Threading;
class Program
{
static readonly object lockerA = new object();
static readonly object lockerB = new object();
static void Main()
{
Thread thread1 = new Thread(Thread1Work);
Thread thread2 = new Thread(Thread2Work);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("Обидва потоки завершили роботу (якщо не сталося взаємного блокування)");
}
static void Thread1Work()
{
lock (lockerA)
{
Console.WriteLine("Потік 1: захопив lockerA");
Thread.Sleep(100); // Дамо шанс другому потоку захопити lockerB
lock (lockerB)
{
Console.WriteLine("Потік 1: захопив lockerB");
}
}
}
static void Thread2Work()
{
lock (lockerB)
{
Console.WriteLine("Потік 2: захопив lockerB");
Thread.Sleep(100); // Дамо шанс першому потоку захопити lockerA
lock (lockerA)
{
Console.WriteLine("Потік 2: захопив lockerA");
}
}
}
}
Запустіть цей код кілька разів — і ви майже напевно зіткнетеся з тим, що застосунок «завис». Deadlock трапився! Кожен потік захопив свій об’єкт і тепер чекає, доки інший звільнить другий.
Візуалізація Deadlock
Схематично це виглядає так:
sequenceDiagram
participant Потік 1
participant Потік 2
participant lockerA
participant lockerB
Потік 1->>lockerA: захопити lockerA
Потік 2->>lockerB: захопити lockerB
Потік 1->>lockerB: чекає на звільнення lockerB
Потік 2->>lockerA: чекає на звільнення lockerA
У результаті: Потік 1 тримає lockerA і чекає lockerB, Потік 2 тримає lockerB і чекає lockerA. Ніхто не поступається — програма зависла.
2. Як виявити взаємне блокування?
Чому це відбувається?
Щоб зрозуміти причину, погляньмо на кілька складових:
- Множинне блокування: коли потік захоплює одразу кілька блокувань.
- Неправильний порядок блокувань: якщо потоки захоплюють блокування в різному порядку, може виникнути deadlock.
- Можливість очікування: потік чекає звільнення ресурсу, який зайнятий іншим потоком.
- Немає примусового захоплення: один потік не може «відібрати» в іншого блокування в C# — лише чекати.
Класична «пастка»: якщо у вас N потоків і N ресурсів, а потоки блокують їх у різному порядку, — ви на порозі взаємного блокування.
Типові ознаки взаємного блокування
Deadlock зазвичай виглядає ззовні як звичайне «зависання» програми. Потоки живі, але стоять в очікуванні доступу до ресурсів, нескінченно чекаючи один одного.
Типові ознаки:
- Програма раптово перестала відповідати (іноді лише за певного навантаження).
- Зневаджувач показує, що потоки застрягли на lock, Monitor.Enter, WaitOne, EnterReadLock або на аналогічних операціях синхронізації.
- Системні засоби (наприклад, Task Manager або інструменти Rider/Visual Studio) показують 0 % завантаження процесора — «усе стоїть».
- У журналі — жодних помилок, але й жодної активності.
Якщо ви використовуєте JetBrains Rider або Visual Studio, подивіться у зневаджувачі, де застрягли ваші потоки (Stack Trace). Якщо бачите, що кілька потоків стоять у lock/Mutex.WaitOne і чекають один одного — вітаємо (а радше співчуваємо): це взаємне блокування!
Класичні сценарії виникнення Deadlock
Нескоординований порядок блокувань. Як у нашому прикладі вище: Потік 1 блокує lockerA, потім lockerB; Потік 2 — навпаки. Усе, пастка готова.
Вкладені блокування. Коли в одному блоці lock ви робите новий блок lock по іншому об’єкту.
Пересічні ресурси в методах. Особливо складно відстежити, якщо блокування розкидані по різних методах/класах і присутні в різних комбінаціях. Сценарії ускладнюються, коли блокувань більше двох.
3. Запобігання взаємним блокуванням
Гарна новина: від deadlock можна захиститися!
Погана новина: доведеться бути уважнішими під час проєктування багатопоточного коду.
Ось кілька «життєвих» порад (запам’ятайте їх — і на співбесіді точно отримаєте додаткові бали):
Завжди захоплюйте блокування в одному й тому самому порядку
Якщо у вас є кілька об’єктів, які можуть блокуватися разом, — визначте єдиний порядок (наприклад, за ім’ям або за індексом) і завжди дотримуйтеся його.
Приклад — правильне захоплення блокувань
static void SafeLock(object objA, object objB)
{
// Порівнюємо посилання — щоб потоки завжди блокували об’єкти в одному порядку
object first = objA.GetHashCode() < objB.GetHashCode() ? objA : objB;
object second = objA.GetHashCode() < objB.GetHashCode() ? objB : objA;
lock (first)
{
lock (second)
{
// критична секція
}
}
}
Тут обидва потоки спершу зайдуть у lock(objA), а потім у lock(objB), якщо objA «менший» за objB.
Використовуйте таймаути
Якщо потік не може захопити блокування за прийнятний час — нехай кидає виняток або коректно виходить. Підійде Monitor.TryEnter.
Приклад з Monitor.TryEnter
bool lockTakenA = false;
bool lockTakenB = false;
try
{
Monitor.TryEnter(lockerA, 500, ref lockTakenA);
if (!lockTakenA)
{
// Не вдалося отримати блокування за 500 мс — здаємося без взаємного блокування
return;
}
Monitor.TryEnter(lockerB, 500, ref lockTakenB);
if (!lockTakenB)
{
return;
}
// Критична секція...
}
finally
{
if (lockTakenB)
Monitor.Exit(lockerB);
if (lockTakenA)
Monitor.Exit(lockerA);
}
Якщо не вдалося отримати блокування — не зависаємо!
Намагайтеся мінімізувати блокування
У критичних секціях тримайте лише мінімально необхідний код.
Не змішуйте різні механізми синхронізації
Якщо ви одночасно використовуєте lock, Mutex, Semaphore, ReaderWriterLockSlim, — зростає ризик заплутатися й отримати взаємне блокування.
4. Взаємні блокування з Mutex, Semaphore та ReaderWriterLockSlim
Взаємні блокування можуть виникати не лише з класичними lock або Monitor, а й з іншими механізмами синхронізації.
Взаємне блокування з Mutex
static Mutex mutexA = new Mutex();
static Mutex mutexB = new Mutex();
void Work1()
{
mutexA.WaitOne();
Thread.Sleep(100);
mutexB.WaitOne();
// Критична секція
mutexB.ReleaseMutex();
mutexA.ReleaseMutex();
}
Якщо буде другий потік, який захоплює їх у зворотному порядку, — отримаєте deadlock.
Взаємне блокування з ReaderWriterLockSlim
ReaderWriterLockSlim, попри свою гнучкість, не захищає від взаємних блокувань, якщо потік уже захопив один тип блокування і намагається отримати інший.
Наприклад, якщо потік тримає read-lock (EnterReadLock) і намагається зайти у write-lock (EnterWriteLock), — deadlock гарантований, оскільки write-lock не може бути отриманий, доки не будуть звільнені всі read-lock.
5. Як уникати взаємних блокувань у реальних застосунках
Розгляньмо, як це може виглядати в нашому демонстраційному застосунку. Припустімо, ми розвиваємо застосунок-симулятор інтернет-магазину, де одночасно працюють:
- Потоки, що оновлюють складські залишки (записувачі)
- Потоки, що перевіряють наявність товару (читачі)
- Адміністратор (спецпотік), який і читає, і пише
Погано:
lock (stockLock)
{
// оновлення складу
lock (userLock)
{
// щось із користувачами
}
}
// ... а десь навпаки!
lock (userLock)
{
lock (stockLock) { }
}
Добре:
Домовляємося: завжди спочатку намагаємося заблокувати stockLock, потім userLock у всіх потоках.
6. Як не впіймати deadlock на роботі та співбесіді
У реальних проєктах:
- Під час проєктування малюйте схему потоків і блокувань (можна на папері чи дошці).
- Діліться домовленістю про порядок блокувань з усією командою.
- Використовуйте інструменти статичного аналізу (Rider/Visual Studio, ReSharper) — вони вміють знаходити потенційні взаємні блокування.
- Читайте статті в Microsoft Docs про Deadlock.
На співбесіді:
- Покажіть, що розумієте проблему та її класичні симптоми.
- Згадайте основні способи захисту: єдиний порядок, таймаути (TryEnter), мінімальні критичні секції.
- Наведіть приклад із TryEnter і поясніть, чому важливо звільняти всі захоплені ресурси у блоці finally.
7. Типові помилки та неочікувані взаємні блокування
Помилка №1: неочевидні блокування всередині сторонніх бібліотек.
Класика — логування всередині критичної секції. Потік застрягає, а ви навіть не підозрюєте, що справа в чужому коді.
Помилка №2: повторне блокування того самого ресурсу.
Це реентрантне взаємне блокування (reentrant deadlock): потік намагається увійти в уже захоплене блокування, але механізм не підтримує повторний вхід.
Помилка №3: використання асинхронних методів усередині критичних секцій.
Особливо небезпечно з await: потік може «відпустити» керування в найневдаліший момент і заблокувати всю систему.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ