Правильно Имитируем Состояние Гонки в C#. Окончание
Начало
Как гарантировать состояние гонки?
1. Мы можем увеличить количество параллельных потоков. Это приведёт к большему количеству одновременного доступа, увеличивая конкуренцию и вероятность возникновения проблем, но не гарантирует возникновения состояния гонки каждый раз.
2. Добавление искусственных задержек, таких как Thread.Sleep(1) внутри метода Withdraw, увеличивает вероятность возникновения состояния гонки каждый раз (см. полный пример в предыдущем посте https://t.me/NetDeveloperDiary/2676):
public void Withdraw(int amount)
{
if (_balance > 0)
{
Thread.Sleep(1);
_balance -= amount;
Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} – Баланс после: {_balance}");
}
}
Приостанавливая выполнение в критический момент, этот подход гарантирует, что несколько потоков одновременно получат доступ к _balance и изменят его, делая состояние гонки видимым практически при каждом выполнении.
Thread.Sleep(1) заставляет операционную систему приостановить выполнение текущего потока и разрешить другим потокам работать. Это прерывание происходит после проверки баланса, но до его обновления, создавая окно, в котором другой поток может войти в метод, прочитать тот же (устаревший) баланс и продолжить своё выполнение. Т.к. оба потока видят один и тот же начальный баланс до того, как какой-либо из них его обновит, они выполняют вычисления на основе устаревших данных, что приводит к неверным конечным значениям.
Без Thread.Sleep состояние гонки может возникнуть непредсказуемо из-за загрузки системы и планирования потоков, но после его добавления проблема последовательно воспроизводится, что упрощает наблюдение и отладку.
3. Также мы можем выстроить потоки в точке гонки и использовать семафор, чтобы освободить их все одновременно. Этот метод гарантирует, что несколько потоков войдут в критическую секцию вместе, естественным образом увеличивая конкуренцию:
var acc = new BankAccount(1000);
int threadCount = 15;
var threads = new Thread[threadCount];
// изначально семафор закрыт
var semaphore = new SemaphoreSlim(0);
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() =>
{
semaphore.Wait();
acc.Withdraw(100);
});
threads[i].Start();
}
// ждём старта всех потоков
Thread.Sleep(1000);
// открываем семафор
semaphore.Release(threadCount);
foreach (var thread in threads)
thread.Join();
Console.WriteLine($"Итого: {acc.GetBalance()} (Ожидается: 0 или <0 при состоянии гонки)");
Используя семафор для удержания всех потоков и их одновременного освобождения, мы увеличиваем вероятность того, что несколько потоков попытаются изменить общий ресурс в одно и то же время. Это заставляет состояние гонки возникать более предсказуемо, что упрощает наблюдение и анализ.
В этом конкретном примере операция уменьшения (_balance -= amount;) выполняется так быстро, что вероятность чередования потоков крайне мала. Поэтому без искусственных задержек (Thread.Sleep) или дополнительных шагов чтения-изменения-записи перед вычитанием, большинство ЦП будут выполнять каждое изъятие последовательно, а не параллельно.
Планировщик ОС также может сериализовать выполнение между потоками, уменьшая вероятность потери обновлений. Т.е. при запуске нескольких потоков каждый поток, скорее всего, видит и изменяет самое последнее значение _balance, а не устаревшее.
Но в более сложных примерах этот подход успешно сымитирует конкуренцию, позволяя тестировать условия гонки, не полагаясь на непредсказуемые задержки.
Итого
Подход с семафором позволяет получить представление о том, как взаимодействуют параллельные операции, и выявить потенциальные проблемы синхронизации. Если вы разрабатываете многопоточное приложение, включение контролируемого тестирования условий гонки в ваш рабочий процесс может помочь предотвратить тонкие ошибки параллелизма до того, как они попадут в производство.
Источник: https://dev.to/thecodewrapper/how-to-properly-simulate-a-race-condition-in-c-37o8