1. Введение
Livelock — это ситуация в многопоточном приложении, когда два и более потока пытаются избежать взаимной блокировки (Deadlock), но в итоге лишь бесконечно реагируют на действия друг друга, не двигаясь вперёд. В отличие от Deadlock, где потоки застыли и ничего не могут сделать, при Livelock программа выглядит живой: потоки исполняются, но ни один не выполняет полезную работу.
Это как если бы вы с коллегой в узком коридоре постоянно делали шаг назад, чтобы пропустить друг друга, и из-за этого так и не проходили дальше. Забавно со стороны, но ужасно в программе.
Пример Livelock: программисты в дверях
Представьте двух очень вежливых программистов, которые идут навстречу друг другу по узкому коридору. Они одновременно оказываются перед дверью и оба решают уступить: отходят вбок, чтобы дать пройти другому. Оба снова видят, что ситуация не изменилась, и опять пытаются уступить — бесконечно. Никто не стоит на месте, все двигаются, но дверь никто не проходит.
В многопоточной программе это выглядит так: поток видит ресурс занятым, отступает, проверяет ещё раз, снова уступает, и так без конца.
2. Livelock на практике
Давайте реализуем аналогичную ситуацию в коде. Допустим, у нас есть два ресурса (например, два банковских счёта), и два потока одновременно пытаются перевести деньги друг другу, стараясь не «встать в клинч».
class Account
{
public int Balance { get; set; }
public object LockObj { get; } = new object();
}
public class Program
{
static void Transfer(Account from, Account to, int amount)
{
while (true)
{
bool lockedFrom = false;
bool lockedTo = false;
try
{
Monitor.TryEnter(from.LockObj, 100, ref lockedFrom);
Monitor.TryEnter(to.LockObj, 100, ref lockedTo);
if (lockedFrom && lockedTo)
{
from.Balance -= amount;
to.Balance += amount;
break; // Перевод выполнен!
}
}
finally
{
if (lockedFrom) Monitor.Exit(from.LockObj);
if (lockedTo) Monitor.Exit(to.LockObj);
}
// Не получилось? Отступим и попробуем снова — вежливо!
Thread.Sleep(1);
}
}
// ... Запуск двух потоков с Transfer(A, B, ...); Transfer(B, A, ...);
}
В этом примере оба потока многократно пытаются захватить замки. Но если оба всё время видят, что замки заняты, и уступают, то всё сводится к «вежливому» уступанию и ни к какому переводу денег. Полагаться на случайность планировщика — плохая стратегия для надёжной программы. Добавляйте паузы и backoff, не закручивайте короткие «пустые» циклы на Monitor.TryEnter и Thread.Sleep.
Сравнение Livelock vs Deadlock
| Deadlock | Livelock | |
|---|---|---|
| Потоки | Остановились, ждут | Активно крутятся, ждут |
| Ресурсы | Заблокированы навсегда | Явно не заблокированы |
| CPU | Почти не грузится | Может грузиться на 100% |
| Решение | Таймауты, порядок блоков | Случайность, паузы, backoff |
3. Starvation (голодание): когда до ресурса не доходит очередь
Starvation (буквально «голодание») — это ситуация, когда один или несколько потоков постоянно обходятся вниманием и не получают доступа к нужному ресурсу, потому что другие потоки всё время оказываются впереди.
В отличие от Deadlock и Livelock, здесь никто не блокирован навсегда и никто бесконечно не уступает — просто некоторым потокам постоянно не достаётся «куска пирога».
Иллюстрация: реал-лайф аналогия
Представьте столовую с двумя очередями: «VIP» (например, касса для сотрудников года) и «для всех остальных». VIP-очередь короткая, но туда всегда кто-то успевает раньше. «Обычные» посетители стоят по полчаса и смотрят, как VIP снова проходят вперёд. Это и есть Starvation!
4. Starvation на C#: практика и отладка
Рассмотрим ситуацию с lock, когда один поток, обладающий большим приоритетом, постоянно успевает войти в критическую секцию, а другой «отстаёт»:
private static readonly object _locker = new object();
static void Greedy()
{
while (true)
{
lock (_locker)
{
Console.WriteLine("Жадный поток захватил ресурс...");
Thread.Sleep(10); // дольше держит замок
}
Thread.Sleep(1);
}
}
static void Poor()
{
while (true)
{
lock (_locker)
{
Console.WriteLine("Бедный поток попытался войти...");
}
}
}
Если оба потока запустить, то "Greedy" («Жадный») долго держит замок и быстро захватывает его снова, а "Poor" («Бедный») почти не попадает внутрь и фактически «голодает» без ресурса. В реальных задачах starvation может быть менее очевидным: например, если у одного потока ниже приоритет или его чаще вытесняет ОС.
Где встречается starvation?
- В потоках с низким приоритетом.
- При неправильной организации очередей задач (нет честной FIFO-политики).
- В ReaderWriterLockSlim с настройками по умолчанию: если писатель приходит редко, а читатели идут бесконечным потоком, то писатель будет всё время «голодать», так как читатели занимают Read Lock и не дают получить Write Lock.
5. Почему Starvation — не всегда баг, но всегда проблема
Starvation, в отличие от Deadlock, не приводит к полной остановке программы, но результат работы становится непредсказуемым и несправедливым: данные обрабатываются неравномерно, некоторые задачи ждут бесконечно, производительность падает. Это особенно критично в серверных приложениях, где все должны получать равные шансы.
Как обнаружить и диагностировать Livelock и Starvation
- Сильно выросло потребление CPU, но задача «стоит на месте» — возможно, у вас Livelock.
- Потоки вроде работают, но некоторые задачи никогда не завершаются — вероятен Starvation.
- Логи: если журнал событий показывает, что определённые потоки или задачи никогда не получают доступ к ресурсу — стопроцентное «голодание».
Как избежать Livelock
- Не ограничивайтесь попытками захвата ресурсов только через «уступчивые» non-blocking методы типа Monitor.TryEnter. Если не удалось, добавляйте случайные паузы (Thread.Sleep(Random.Next(1, 10)))), чтобы потоки не совпадали по времени.
- Не используйте постоянные короткие циклы с захватом ресурса — иначе цикл «отступил — попробовал» станет бесконечным.
- Иногда помогает изменить алгоритм: ввести «право старшего», чтобы только один поток мог уступать, а другой — нет.
Как избежать Starvation
- Следите за приоритетами потоков: фоновые или приоритетные задачи не должны вечно мешать остальным.
- Используйте очереди (ConcurrentQueue<T>, каналы, очередь задач) с честной политикой FIFO, чтобы задачи обслуживались по порядку.
- В ReaderWriterLockSlim можно приостановить новых читателей в пользу писателей, чтобы писатель всё-таки дождался Write Lock.
- Применяйте таймауты и логи: если поток пытается получить замок слишком долго, выводите предупреждение.
- Уменьшайте длительность удержания блокировок и разносите критические секции.
Сравнение Deadlock, Livelock и Starvation
| Сценарий | Deadlock | Livelock | Starvation |
|---|---|---|---|
| Потоки | Стоят мёртвым | Бегают, но впустую | Кто-то работает, кто-то нет |
| Доступ к ресурсу | Нет | Нет | Неравномерный, иногда нет |
| CPU | Не грузится | Грузится сильно | Грузится, но не всеми |
| Критическая ошибка? | Да | Да | Иногда |
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ