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: поток может «отпустить» управление в самый неподходящий момент и заблокировать всю систему.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ