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. На перший погляд це здається безпечним: що може піти не так? Виявляється, навіть читання й запис булевої змінної можуть бути небезпечними! Причина: оптимізації компілятора, кеш процесора, перестановки інструкцій у багатопроцесорній системі.

Що може статися?
Один потік може так і не помітити зміну змінної, продовжуючи крутитися в нескінченному циклі, навіть якщо інший потік уже давно встановив 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++

У термінах процесора це:

  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).

5. Типові помилки студентів під час роботи з Race Condition

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

Помилка №2: блокують не той об’єкт.
Наприклад, рядок або об’єкт, який доступний ззовні класу. У підсумку синхронізація не працює так, як задумано.

Помилка №3: покладаються на те, що результат «майже завжди правильний».
Якщо баг проявляється рідко — це не означає, що ресурси можна не захищати.

Помилка №4: використовують асинхронні методи без синхронізації.
Сподіваються «якось пронесе» й отримують хаотичні помилки в найнесподіваніших місцях.

Помилка №5: пишуть Singleton із подвійною перевіркою, але без lock.
Замість одного об’єкта в пам’яті з’являється кілька копій, і налагодження перетворюється на жах.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ