JavaRush /Курсы /C# SELF /Углубленный разбор Race Conditions

Углубленный разбор Race Conditions

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

1. Более сложные примеры Race Condition

Race Condition — это один из самых коварных видов ошибок. Почему? Потому что они обычно происходят редко, зависят от скорости процессора, от задержек ОС, от случайных переключений между потоками, и воспроизводятся тогда, когда вам уже давно нужно идти домой. Эти ошибки не ловит ни один статический анализатор. Их не улавливают юнит-тесты (если только вы не экстрасенс), но однажды... они проявляются в продакшене.

Чтобы избежать таких неприятных сюрпризов, нужно хорошо понимать, как возникают Race Conditions и что с ними делать.

Пример 1. Банк без банковской этики

Допустим, у нас есть очень простой класс банковского счета:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

Представьте, что два потока одновременно пытаются пополнить счёт на 100 и 200 евро, а затем каждый поочередно снимает 50 евро. Если бы это был однопоточный мир, баланс всегда был бы таков: (0 + 100 + 200 - 50 - 50) = 200.

А что будет в многопоточной реальности? Давайте проведём эксперимент:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // А вот тут каждый запуск может давать разный ответ!

Пояснение:
Если оба потока прочитали Balance как 0, затем один записал 100, другой — 200, но между этими действиями случилась очередность переключений — баланс может «потерять» или «пририсовать» деньги. Банк без блокировок — мечта мошенника!

Пример 2. Переменная-флаг

Очень частая ошибка — использовать переменную-флаг как индикатор состояния:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // ждём...
    }
    // что-то делаем
}

Ещё один поток может записать isReady = true. На первый взгляд, выглядит безопасно: что может пойти не так? Оказывается, даже чтение и запись булевой переменной могут быть небезопасны! Причина: оптимизации компилятора, кеш процессора, reorder инструкций в многопроцессорной системе.

Что может случиться?
Один поток может так и не заметить изменения переменной, продолжая крутиться в бесконечном цикле, даже если другой поток уже давно установил isReady = true. Для передачи «флагов» между потоками всегда используйте специальные примитивы (volatile, Interlocked, события и т.д. — подробнее см. официальную документацию).

Пример 3. Проверка на null и создание объекта

Представьте класс-одиночку (Singleton):

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

Код кажется тривиальным? Но если много потоков одновременно вызовут Instance, вполне может быть создано несколько экземпляров класса! Это классический пример double-check locking без синхронизации: поле _instance инициализируется гонкой, если не защититься lock.

2. Как Race Condition появляется в коде

Операция «Прочитать-модифицировать-записать» — анатомия зла

Возьмём снова наш инкремент: counter++

В терминах процессора, это:

  1. Прочитать из памяти: register = counter;
  2. Увеличить: register = register + 1;
  3. Записать обратно: counter = register;

Что будет, если два потока выполнят этот блок почти одновременно?

  • Оба потока читают counter = 0.
  • Оба увеличивают внутренний регистр до 1.
  • Оба записывают обратно 1.

Результат: при двух инкрементах значение увеличилось только на 1! Один из приростов «съеден» — и никто не заметил.

Визуализация «столкновения» двух потоков


Поток A        |   Поток B        |   counter-------------------------------------------
Читает (0)     |                  |   0
               |   Читает (0)     |   0
Инкремент (1)  |                  |   0
               |   Инкремент (1)  |   0
Пишет (1)      |                  |   1
               |   Пишет (1)      |   1  <-- Oops! Ожидали 2, получили 1

Почему Race Condition так трудно поймать?

  • Race Condition «маскируется». Добавите в код пару Console.WriteLine — и ошибка может исчезнуть. Просто потому, что потоки пошли другим путём.
  • Ошибка зависит от числа ядер, их загрузки, версии ОС и .NET. Вчера всё работало, а сегодня всё развалилось.
  • Результат плавающий: ошибка проявляется не всегда, а только «по особым случаям».
  • Дебажить это — то ещё развлечение.

3. Race Condition в коллекциях и классах .NET

Race Condition бывает не только с переменными. Коллекции, очереди и даже стандартные классы .NET не всегда защищены.

Пример: List<T> не потокобезопасен

Если два потока одновременно делают .Add() в обычный List<T>, последствия могут быть катастрофическими:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Очень вероятно, что будет < 10000

Здесь Race Condition возникает в методе Add(), ведь внутри коллекция может расширять внутренний массив, копировать элементы, а в этот момент другой поток уже что-то добавляет... Итог — потерянные данные, исключения или повреждённая коллекция.

Пример: Несколько потоков используют один и тот же индексатор

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

В результате в элементе array[5] может оказаться либо 1, либо 2 — в зависимости от того, кто из потоков последний записал туда данные. Иногда это поведение даже желаемо, но чаще — источник тонких и трудноуловимых багов.

4. Полезные нюансы

Race Condition и неатомарные операции

Будьте особенно осторожны, если операция кажется «маленькой», но не гарантирует атомарности.

  • i++, i--
  • a = b
  • myObject.Property = value
  • «Проверил флаг — изменил значение»
  • «Получил объект — изменил его внутреннее состояние»

Всё это по отдельности — не атомарные действия! Их могут «подхватить» другие потоки между микрошагами.

Мифы и подводные камни Race Condition

Миф 1: «Если переменная — int, то с ней всё безопасно, это же примитив».
Реальность: Даже операции над int не гарантируют атомарности, если они не помечены как volatile и не используются через специальный API.

Миф 2: «Если я пишу в коллекцию, а другой поток только читает — всё ок».
Реальность: Нет! В .NET коллекции не гарантируют целостности при одновременном чтении и записи, можно получить как «битые» объекты, так и странные исключения.

Миф 3: «Race Condition проявляется только в супернагруженных сервисах».
Реальность: Даже в небольших утилитах, играх, десктоп-программах можно нарваться на состояние гонки. Просто оно проявляется реже, либо не сразу.

Как защититься от Race Condition?

  • Использовать примитивы синхронизации (lock, Mutex, Monitor и т.д.).
  • Для базовых операций увеличения/уменьшения числовых переменных — применять Interlocked (документация).
  • Для коллекций — использовать специальные потокобезопасные классы (ConcurrentBag, ConcurrentDictionary, и т.д. — см. документацию).
  • Не использовать переменные-флаги и состояния без защиты (volatile, события, примитивы синхронизации).

Когда возникает Race Condition

Сценарий Может быть Race Condition? Как защититься
Несколько потоков читают данные Нет (если нет изменения) -
Один поток пишет, другой читает Да
lock/volatile
Несколько потоков пишут Да
lock/Interlocked
Несколько потоков изменяют коллекцию Да Потокобезопасные коллекции
Несколько потоков используют флаг Да volatile, lock, события

Атомарность — не панацея

Иногда даже атомарные операции не спасают от логических «гонок».

Пример:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

Если два потока пройдут в этот if одновременно, оба увидят, что ключа нет, и оба создадут новое значение — а второй перезапишет результат первого! Здесь не спасут ни атомарные операции над int, ни Interlocked — нужна полная блокировка либо специализированная функция (GetOrAdd в ConcurrentDictionary).

4. Типичные ошибки студентов при работе с Race Condition

Ошибка №1: думают, что lock нужен только для «чего-то большого».
На деле даже простая переменная может сломаться без защиты.

Ошибка №2: блокируют не тот объект.
Например, строку или объект, который доступен извне класса. В итоге синхронизация не работает так, как задумывалась.

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

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

Ошибка №5: пишут Singleton с двойной проверкой, но без lock.
Вместо одного объекта в памяти появляется несколько копий, и отладка превращается в кошмар.

2
Задача
C# SELF, 57 уровень, 0 лекция
Недоступна
Синхронизация доступа к ресурсу через `Monitor`
Синхронизация доступа к ресурсу через `Monitor`
Комментарии (2)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Evgeniy Fedorov Уровень 2
4 марта 2026
С правильным решением задача не проходит проверку 😁😁😁 Оставлю скриншотик здесь 😂😂😂
Ильнур Уровень 66
19 декабря 2025
Опять проблемы с проверкой решения задачи...