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++
В терминах процессора, это:
- Прочитать из памяти: register = counter;
- Увеличить: register = register + 1;
- Записать обратно: 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? | Как защититься |
|---|---|---|
| Несколько потоков читают данные | Нет (если нет изменения) | - |
| Один поток пишет, другой читает | Да | |
| Несколько потоков пишут | Да | |
| Несколько потоков изменяют коллекцию | Да | Потокобезопасные коллекции |
| Несколько потоков используют флаг | Да | 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.
Вместо одного объекта в памяти появляется несколько копий, и отладка превращается в кошмар.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ