JavaRush /Курсы /C# SELF /Race Condition (состояние гонки)

Race Condition (состояние гонки)

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

1. Введение

В многопоточных приложениях наличие race condition — это вопрос не "если", а "когда это случится". Даже если вы думаете, что ваш код надёжен, и у вас всего "два маленьких потока", где "всё очевидно и просто", состояние гонки может затаиться в самом безобидном участке логики.

Что вообще такое race condition и почему оно такое страшное? Представьте, что два человека пытаются одновременно редактировать одну и ту же бумагу — один записывает, другой стирает. Иногда всё ок, а иногда получается нечто нечитабельное. В программировании последствия бывают ещё веселее: ошибки проявляются не всегда, а только в определённых, почти случайных, условиях.

Race condition (состояние гонки) — ситуация, при которой исход выполнения программы зависит от того, какой поток первым получил доступ к ресурсу или выполнил действие. Эта проблема возникает только при конкурентном (многопоточном) доступе, когда два или более потока обращаются к разделяемым данным или ресурсам.

Что происходит при гонке?

Вот простая схема. Представьте, что у нас есть два потока и один разделяемый ресурс (например, переменная X):


     +---------+           +---------+          
     | Поток 1 |           | Поток 2 |          
     +----+----+           +----+----+          
          |                     |              
          |     Чтение X        |              
          | <-------------------|              
          |                     |              
          |     Увеличение X    |              
          |-------------------> |              
          |                     |              
          |     Запись X        |              
          | <-------------------|              

Если оба потока одновременно читают значение переменной X, увеличивают его и записывают обратно, кто-то "затрёт" изменения другого, и общее количество увеличений не совпадет с ожидаемым.

2. Классический пример Race Condition

Давайте рассмотрим на примере. Допустим, мы хотим посчитать количество нажатий кнопки из разных потоков или количество обработанных задач.

Берём простую переменную и несколько потоков, которые её увеличивают:

using System;
using System.Threading;

class Program
{
    static int counter = 0; // Разделяемый ресурс

    static void Main()
    {
        Thread t1 = new Thread(IncrementCounter);
        Thread t2 = new Thread(IncrementCounter);

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

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

        Console.WriteLine("Ожидаемое значение: 200000");
        Console.WriteLine("Фактическое значение: " + counter);
    }

    static void IncrementCounter()
    {
        for (int i = 0; i < 100_000; i++)
        {
            counter++; // << Вот здесь может возникнуть проблема!
        }
    }
}

Что мы ожидаем?

Поскольку каждый поток увеличивает counter по 100000 раз, мы ждём, что итоговое значение будет 200000.

Что получаем на самом деле?

Иногда — да, 200000. Но чаще значение будет меньше — иногда сильно меньше. Повторяйте эксперимент, результат будет гулять!

Почему так?

Операция counter++ не атомарная. Она на самом деле выполняется так (упрощённо):

  1. Считать текущее значение counter (например, 0)
  2. Увеличить на 1 (получается 1)
  3. Записать назад (counter = 1)

Если два потока прочитают старое значение одновременно, оба могут записать его с новым значением, но по сути прибавят один и тот же инкремент.

Визуализация на примере двух потоков:

Допустим, counter = 0.

  • Поток 1: читает 0
  • Поток 2: читает 0
  • Поток 1: считает 0 + 1 = 1
  • Поток 2: считает 0 + 1 = 1
  • Поток 1: записывает 1
  • Поток 2: записывает 1 (теряет инкремент Потока 1)

"Поздравляем", только что потеряли одно увеличение! В масштабах тысяч и миллионов операций — результат сильно "плавает".

3. Ещё примеры: не только инкремент!

Суета на кухне

Чтобы было веселее, представьте маленькое кафе. Два повара жарят омлет на одной сковородке, но не согласовывают свои действия:

  • Первый кладёт один омлет, второй тут же кладёт свой поверх — они мешаются друг другу;
  • Один считает, что "я уже положил два омлета", второй думает то же самое, но на деле в сковородке три, а думали, что четыре;
  • Начинается хаос...

В программировании race condition точно так же приводит к "хаосу": результат работы зависит от череды быстрых и неконтролируемых операций.

Когда потоки мешают друг другу: одновременный доступ к данным

Допустим, вы реализуете банковское приложение, и клиент одновременно пополняет и снимает деньги с одного счета, используя два потока (например, один — онлайн-перевод, второй — касса):

account.Balance += 500;    // Поток 1: пополнение
account.Balance -= 300;    // Поток 2: снятие

Если эти операции не защищены, итоговый баланс может быть некорректным: часть операций просто "затеряется", если потоки работают одновременно.

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

Почему race condition — это проблема?

Сложно поймать и воспроизвести. Ошибка может проявиться только на перегруженной машине или в редких условиях.

Трудно отлаживать. При отладке потоки могут "разбежаться" иначе, и ошибка исчезнет.

Нарушение целостности данных. Вы получаете некорректные, повреждённые данные, иногда совсем незаметно.

Безопасность. В критических приложениях race condition могут привести к утечкам, разрушению данных и даже уязвимостям.

Диаграмма "таймингов гонки"


+-----------------------+     +-----------------------+
| Поток 1               |     | Поток 2               |
+-----------------------+     +-----------------------+
| 1. Читать counter     |     |                       |
| 2. Увеличить counter  |     |                       |
| (но не записывать)    |     |                       |
|                       |     | 1. Читать counter     |
|                       |     | 2. Увеличить counter  |
|                       |     | 3. Записать counter   |
|                       |     | (counter = 1)         |
| 3. Записать counter   |     |                       |
| (counter = 1)         |     |                       |
+-----------------------+     +-----------------------+

Оба потока сделали инкремент, но итог — только один инкремент записан!

Где часто встречается состояние гонки

  • Любые глобальные или статические переменные, к которым обращаются несколько потоков.
  • Списки, очереди, коллекции, которые заполняются из разных потоков.
  • События и делегаты, если подписка/отписка происходит одновременно (например, в UI + фоновые задачи).
  • Кэширование, словари, управление соединениями.
  • Любое взаимодействие с файлами, логами, базами данных без транзакций или блокировок.

Как избежать race condition: маленькое введение

  • Синхронизация! (подробнее — на следующих лекциях).
  • Используйте специальные конструкции языка и библиотеки: lock, Monitor, мьютексы, семафоры и т.д.
  • Для простых операций — атомарные методы (Interlocked.Increment и др.).
  • Используйте потокобезопасные коллекции (ConcurrentBag, ConcurrentDictionary).
  • Всегда думайте: "а что будет, если две мои функции вызваны одновременно?"

5. Полезные советы

Советы по поиску и диагностике гонок

  • Не доверяйте даже самым простым операциям (инкремент ++, присваивание), если используете несколько потоков.
  • По возможности избегайте совместного доступа к переменным.
  • Если видите "плавающие" баги, ошибки, которые трудно воспроизвести — подумайте о гонках!
  • Используйте инструменты анализа потоков (dotTrace, Concurrency Visualizer, Thread Sanitizer).
  • Проводите нагрузочные тесты — чем больше потоков и операций, тем выше шанс обнаружить ошибку.

Что можно и что нельзя без синхронизации

Операция Безопасно в многопоточной среде? Пояснение
Присваивание int 🟩 Иногда* Только если один поток пишет, другие читают, иначе — гонка
Инкремент (++/--) 🟥 Нет Не атомарно! Race Condition
Чтение string 🟩 Иногда* Если строка не изменяется после создания
Присваивание объекта 🟩 Иногда* При условии, что нет одновременных записей
Добавить в List<T> 🟥 Нет List<T> не потокобезопасен
Interlocked.Increment
🟩 Да Специальный атомарный метод

— "Иногда" означает, что если только один поток пишет, а все остальные только читают, то это безопасно; если несколько потоков могут писать одновременно — всегда гонка.

6. Типичные ошибки и ловушки

В коде-демонстрации выше мы видели counter++ как проблему. Ещё одна ловушка: увеличение или проверка значения в условии.

Пример: Забавная ошибка с "первым запуском"

if (!alreadyStarted)
{
    alreadyStarted = true;
    // Делаем инициализацию...
}

Если такое условие выполняют несколько потоков одновременно, каждый из них может увидеть alreadyStarted == false и войти внутрь! Итог — инициализируют что-то дважды, что может привести к сбою.

2
Задача
C# SELF, 55 уровень, 4 лекция
Недоступна
Рабочие и их задачи
Рабочие и их задачи
1
Опрос
Введение в многопоточность, 55 уровень, 4 лекция
Недоступен
Введение в многопоточность
Основы многопоточности в C#
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ