JavaRush /Курси /C# SELF /Взаємні блокування: Deadlocks

Взаємні блокування: Deadlocks

C# SELF
Рівень 57 , Лекція 1
Відкрита

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: потік може «відпустити» керування в найневдаліший момент і заблокувати всю систему.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ