1. Вступ
У багатопотокових застосунках наявність стану гонки — питання не «якщо», а «коли». Навіть якщо ви вважаєте, що ваш код надійний, а у вас лише «два маленькі потоки», де «все очевидно й просто», стан гонки може причаїтися у найневиннішій частині логіки.
Що взагалі таке стан гонки і чому він такий небезпечний? Уявіть, що двоє людей намагаються одночасно редагувати один і той самий аркуш — один пише, інший витирає. Іноді все гаразд, а іноді виходить щось нерозбірливе. У програмуванні наслідки можуть бути ще серйознішими: помилки виявляються не завжди, а лише за певних, майже випадкових, умов.
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++ не є атомарною. Насправді її виконують так (спрощено):
- Прочитати поточне значення counter (наприклад, 0)
- Збільшити на 1 (виходить 1)
- Записати назад (counter = 1)
Якщо два потоки одночасно прочитають старе значення, обидва можуть записати його з новим значенням, але фактично додадуть один і той самий інкремент.
Візуалізація на прикладі двох потоків:
Припустімо, counter = 0.
- Потік 1: читає 0
- Потік 2: читає 0
- Потік 1: обчислює 0 + 1 = 1
- Потік 2: обчислює 0 + 1 = 1
- Потік 1: записує 1
- Потік 2: записує 1 (втрачає інкремент потоку 1)
«Вітаємо», щойно втратили одне збільшення! У масштабах тисяч і мільйонів операцій результат сильно «плаває».
3. Ще приклади: не лише інкремент!
Метушня на кухні
Для наочності уявімо маленьке кафе. Двоє кухарів смажать омлет на одній сковороді, але не узгоджують свої дії:
- Перший кладе омлет, другий відразу кладе свій зверху — заважають один одному.
- Один вважає: «я вже поклав два омлети», другий думає так само, але насправді на сковороді — три, тоді як чекали чотири.
- Починається хаос…
У програмуванні стан гонки так само призводить до «хаосу»: результат роботи залежить від низки швидких і неконтрольованих операцій.
Коли потоки заважають один одному: одночасний доступ до даних
Припустімо, ви реалізуєте банківський застосунок, і клієнт одночасно поповнює й знімає гроші з одного рахунку, використовуючи два потоки (наприклад, один — онлайн-переказ, другий — каса):
account.Balance += 500; // Потік 1: поповнення
account.Balance -= 300; // Потік 2: зняття
Якщо ці операції не захищені, підсумковий баланс може бути некоректним: частина їх просто «загубиться» під час одночасної роботи потоків.
4. Корисні нюанси
Чому стан гонки — це проблема?
Важко виявити й відтворити. Помилка може виявитися лише на перевантаженій машині або за рідкісних умов.
Важко налагоджувати. Під час налагодження потоки можуть «розбігтися» інакше — і помилка зникне.
Порушення цілісності даних. Отримуєте некоректні, пошкоджені дані, іноді зовсім непомітно.
Безпека. У критичних застосунках стан гонки може призвести до витоків, руйнування даних і навіть до вразливостей.
Діаграма «таймінгів гонки»
+-----------------------+ +-----------------------+
| Потік 1 | | Потік 2 |
+-----------------------+ +-----------------------+
| 1. Читати counter | | |
| 2. Збільшити counter | | |
| (але не записувати) | | |
| | | 1. Читати counter |
| | | 2. Збільшити counter |
| | | 3. Записати counter |
| | | (counter = 1) |
| 3. Записати counter | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
Обидва потоки зробили інкремент, але в результаті записано лише один інкремент!
Де часто трапляється стан гонки
- Будь-які глобальні або статичні змінні, до яких звертаються кілька потоків.
- Списки, черги, колекції, які заповнюються з різних потоків.
- Події й делегати, якщо підписка/відписка відбувається одночасно (наприклад, у UI + фонові завдання).
- Кешування, словники, керування зʼєднаннями.
- Будь-яка взаємодія з файлами, журналами, базами даних без транзакцій або блокувань.
Як уникати стану гонки: короткий вступ
- Синхронізація! (детальніше — на наступних лекціях).
- Використовуйте спеціальні конструкції мови й бібліотеки: lock, Monitor, мʼютекси, семафори тощо.
- Для простих операцій — атомарні методи (Interlocked.Increment тощо).
- Використовуйте потокобезпечні колекції (ConcurrentBag, ConcurrentDictionary).
- Завжди запитуйте себе: «що станеться, якщо дві мої функції виконаються одночасно?»
5. Корисні поради
Поради з пошуку та діагностики гонок
- Не довіряйте навіть найпростішим операціям (інкремент ++, присвоєння), якщо використовуєте кілька потоків.
- За можливості уникайте спільного доступу до змінних.
- Якщо спостерігаєте «плаваючі» помилки, які важко відтворити, — подумайте про гонки!
- Використовуйте інструменти аналізу потоків (dotTrace, Concurrency Visualizer, Thread Sanitizer).
- Проводьте навантажувальні тести — чим більше потоків і операцій, тим вищий шанс знайти помилку.
Що можна і що не можна без синхронізації
| Операція | Безпечно в багатопотоковому середовищі? | Пояснення |
|---|---|---|
| Присвоєння int | 🟩 Іноді* | Лише якщо один потік пише, інші читають, інакше — гонка. |
| Інкремент (++/--) | 🟥 Ні | Неатомарно. Стан гонки. |
| Читання string | 🟩 Іноді* | Якщо рядок не змінюється після створення. |
| Присвоєння обʼєкта | 🟩 Іноді* | За умови, що немає одночасних записів. |
| Додати в List<T> | 🟥 Ні | List<T> не є потокобезпечним. |
|
🟩 Так | Спеціальний атомарний метод. |
— «Іноді» означає, що якщо лише один потік пише, а всі інші лише читають, це безпечно; якщо кілька потоків можуть писати одночасно — завжди гонка.
6. Типові помилки та пастки
У демо-коді вище ми бачили counter++ як проблему. Ще одна пастка — збільшення або перевірка значення в умові.
Приклад: Кумедна помилка з «першим запуском»
if (!alreadyStarted)
{
alreadyStarted = true;
// Робимо ініціалізацію…
}
Якщо таку умову виконують кілька потоків одночасно, кожен із них може побачити alreadyStarted == false та увійти всередину! Результат — щось ініціалізується двічі, що може спричинити збій.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ