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

Взаимные блокировки: Deadlocks

C# SELF
57 уровень , 1 лекция
Открыта

1. Пример Deadlock на 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("Оба потока завершили работу (если не случился Deadlock)");
    }

    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?

Почему это происходит?

Чтобы понять причину, взглянем на несколько составляющих:

  • Множественное блокирование: Когда поток захватывает сразу несколько замков.
  • Неправильный порядок блокировок: Если потоки захватывают замки в разном порядке, может возникнуть deadlock.
  • Возможность ожидания: Поток ждёт освобождения ресурса, который занят другим потоком.
  • Нет принудительного захвата: Один поток не может «выдрать» у другого блокировку в C# — только ждать.

Классическая «ловушка»: если у вас N потоков и N ресурсов, и потоки блокируют их в разном порядке, — вы на пороге deadlock-а.

Типичные признаки Deadlock

Deadlock обычно выглядит со стороны как обычное "зависание" программы. Потоки живы, но стоят в ожидании доступа к ресурсам, бесконечно ожидая друг друга.

Типичные признаки:

  • Программа неожиданно перестала отвечать (иногда только при определённой нагрузке).
  • Дебаггер показывает, что потоки застряли на lock, Monitor.Enter, WaitOne, EnterReadLock или аналогичных операциях синхронизации.
  • Системные средства (например, Task Manager или средства Rider/Visual Studio) показывают 0% загрузки процессора — «всё стоит».
  • В логе — никаких ошибок, но и никакой активности.

Если вы используете JetBrains Rider или Visual Studio, посмотрите дебаггером, где застряли ваши потоки (Stack Trace). Если видите, что несколько потоков стоят в lock/Mutex.WaitOne друг на друге — поздравляем (или скорее, сочувствуем): это deadlock!

Классические сценарии возникновения Deadlock

Несогласованный порядок блокировок. Как в нашем примере выше: Поток 1 блокирует lockerA, потом lockerB; Поток 2 — наоборот. Всё, ловушка готова.

Вложенные блокировки. Когда в одном блоке lock вы делаете новый блок lock по другому объекту.

Пересекающиеся ресурсы в методах. Особенно сложно отследить, если блокировки разбросаны по разным методам/классам и присутствуют в разных комбинациях. Сценарии усложняются, когда замков больше двух.

3. Предотвращение Deadlock

Хорошая новость: от 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)
    {
        // Не удалось получить lock за 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. Deadlock с Mutex, Semaphore и ReaderWriterLockSlim

Взаимные блокировки могут возникать не только с классическими lock или Monitor, но и с другими механизмами синхронизации.

Deadlock с 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.

Deadlock с ReaderWriterLockSlim

ReaderWriterLockSlim, несмотря на свою гибкость, не защищает от взаимных блокировок, если поток уже захватил один тип блокировки и пытается получить другой.

Например, если поток держит read-lock (EnterReadLock) и пытается войти в write-lock (EnterWriteLock) — deadlock обеспечен, поскольку write-lock не может быть получен, пока не будут освобождены все read-lock-и.

5. Как избежать Deadlock в реальных приложениях

Давайте рассмотрим, как это может выглядеть в нашем демонстрационном приложении. Допустим, мы развиваем приложение-симулятор интернет-магазина, где одновременно работают:

  • Потоки, обновляющие складские остатки (писатели)
  • Потоки, проверяющие наличие товара (читатели)
  • Администратор (спец-поток), который и читает, и пишет

Плохо:

lock (stockLock)
{
    // обновление склада
    lock (userLock)
    {
        // что-то с пользователями
    }
}
// ... а где-то наоборот!
lock (userLock)
{
    lock (stockLock) { }
}

Хорошо:

Договориться — всегда сначала пытаемся заблокировать stockLock, потом userLock во всех потоках.

6. Как не получить Deadlock на работе и собеседовании

В реальных проектах:

  • При проектировании — рисуйте схему потоков и блокировок (можно на бумаге/доске).
  • Делитесь соглашением о порядке блокировок со всей командой.
  • Используйте инструменты статического анализа (Rider/Visual Studio, ReSharper) — они умеют находить потенциальные deadlock-и.
  • Читайте статьи в Microsoft Docs по Deadlock.

На собеседовании:

  • Покажите, что понимаете проблему и её классические симптомы.
  • Упомяните основные способы защиты: единый порядок, таймауты (TryEnter), минимальные критические секции.
  • Приведите пример с TryEnter и объясните, почему важно освобождать все захваченные ресурсы в finally.

7. Типичные ошибки и неожиданные Deadlock-и

Ошибка №1: неочевидные блокировки внутри сторонних библиотек.
Классика — логирование внутри критической секции. Поток застревает, а вы даже не догадываетесь, что дело в чужом коде.

Ошибка №2: повторная блокировка одного и того же ресурса.
Это reentrant deadlock: поток пытается войти в уже захваченную блокировку, но механизм не поддерживает повторный вход.

Ошибка №3: использование асинхронных методов внутри критических секций.
Особенно опасно с await: поток может «отпустить» управление в самый неподходящий момент и заблокировать всю систему.

2
Задача
C# SELF, 57 уровень, 1 лекция
Недоступна
Демонстрация Deadlock
Демонстрация Deadlock
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ