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. На перший погляд це здається безпечним: що може піти не так? Виявляється, навіть читання й запис булевої змінної можуть бути небезпечними! Причина: оптимізації компілятора, кеш процесора, перестановки інструкцій у багатопроцесорній системі.
Що може статися?
Один потік може так і не помітити зміну змінної, продовжуючи крутитися в нескінченному циклі, навіть якщо інший потік уже давно встановив 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‑checked 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).
5. Типові помилки студентів під час роботи з Race Condition
Помилка №1: думають, що lock потрібен тільки для «чогось великого».
Насправді навіть проста змінна може «зламатися» без захисту.
Помилка №2: блокують не той об’єкт.
Наприклад, рядок або об’єкт, який доступний ззовні класу. У підсумку синхронізація не працює так, як задумано.
Помилка №3: покладаються на те, що результат «майже завжди правильний».
Якщо баг проявляється рідко — це не означає, що ресурси можна не захищати.
Помилка №4: використовують асинхронні методи без синхронізації.
Сподіваються «якось пронесе» й отримують хаотичні помилки в найнесподіваніших місцях.
Помилка №5: пишуть Singleton із подвійною перевіркою, але без lock.
Замість одного об’єкта в пам’яті з’являється кілька копій, і налагодження перетворюється на жах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ