Race condition, deadlock, livelock, starvation

↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая

Обязательно~4 мин чтенияУровень: middle

Зачем это на собесе

Вопросы вида «приведи пример deadlock и как его диагностировать» задают почти всем. Нужно показать понимание условий возникновения и практических способов борьбы.

Объяснение

ПроблемаСутьСимптом
Race conditionрезультат зависит от порядка выполнения потоковплавающие неверные данные, «иногда падает»
Data raceодновременный доступ к памяти без синхронизации, хотя бы одна записьпорча данных, torn reads
Deadlockпотоки ждут друг друга бесконечнозависание, 0% CPU
Livelockпотоки активно уступают друг другу, но прогресса нетвысокий CPU, ничего не происходит
Starvationпоток годами не получает ресурсотдельные задачи «застревают»

Race condition: check-then-act

// Плохо: между проверкой и записью может вклиниться другой поток
if (!_cache.ContainsKey(key))
    _cache[key] = Load(key);
 
// Хорошо
var value = _concurrentCache.GetOrAdd(key, k => Load(k));

Обратите внимание: GetOrAdd с фабрикой может вызвать фабрику несколько раз. Если фабрика дорогая — храните Lazy<T>.

Deadlock: четыре условия Коффмана

  1. Взаимное исключение.
  2. Удержание и ожидание (hold and wait).
  3. Отсутствие принудительного освобождения.
  4. Циклическое ожидание.

Достаточно сломать любое из них.

// Поток A: lock(a) -> lock(b); поток B: lock(b) -> lock(a)
void Transfer(Account from, Account to)
{
    lock (from) lock (to) { /* ... */ }
}
 
// Исправление: единый порядок захвата
void TransferSafe(Account from, Account to)
{
    var (first, second) = from.Id < to.Id ? (from, to) : (to, from);
    lock (first) lock (second) { /* ... */ }
}

Отдельно — async-deadlock: блокирующий .Result в контексте с однопоточным SynchronizationContext (см. Антипаттерны: async void, .Result, sync over async).

Livelock и starvation

Livelock — два вежливых человека в коридоре, каждый уступает дорогу в ту же сторону; лечится случайной задержкой (jitter) или приоритетом. Starvation — несправедливые блокировки, длинные задачи, вытесняющие короткие; лечится честными очередями, лимитами и приоритетами.

Нюансы и подводные камни

  • Гонки не воспроизводятся стабильно: тестируйте стресс-тестами и с разным числом потоков.
  • Monitor.TryEnter(obj, timeout) и SemaphoreSlim.WaitAsync(timeout) превращают deadlock в диагностируемую ошибку.
  • Неизменяемые данные и передача сообщений (Channels, actor-подход) исключают большинство гонок.
  • Диагностика: dotnet-dump analyze + clrstack -all, dotnet-stack, окно Parallel Stacks в Visual Studio.

Практика

  1. Напишите минимальный deadlock на двух замках, снимите dump и найдите его в clrstack.
  2. Исправьте счётчик с x++ из 8 потоков через Interlocked.
  3. Сымитируйте starvation: длинные задачи в ThreadPool и короткая, которая не выполняется.

Вопросы с ответами

Связанные темы