1. Вступ
У багатопоточному застосунку спільний ресурс — це все, до чого одночасно можуть отримати доступ два чи більше потоків. Це може бути:
- Змінна (наприклад, глобальний лічильник або список).
- Об’єкт (наприклад, колекція користувачів).
- Файл або мережевий сокет.
- Будь-яка структура даних, яку змінюють різні потоки.
У нашому консольному застосунку ми найчастіше стикатимемося зі змінними та об’єктами, які спільно використовують різні потоки.
Аналогія
Уявіть двох людей, які одночасно намагаються записати щось в один і той самий зошит, не домовившись про чергу. У кращому разі — вийде крива нотатка, у гіршому — хтось перепише чужі дані. У програмуванні ситуація така сама, тільки ці «люди» — потоки.
Коротко про типові ресурси з гонками даних
У таблиці нижче — найчастіші ресурси, небезпечні для спільного доступу з різних потоків:
| Ресурс | Групи проблем | Приклад |
|---|---|---|
| Змінні типу int | Некоректне збільшення/зменшення | Лічильники, індекси |
| Спільні колекції | Втрата/пошкодження елементів, винятки | Спільний список замовлень |
| Об’єкти | Непослідовні зміни стану | Прапорці, властивості |
| Файли | Пошкодження даних, некоректне читання/запис | Лог-файли, конфігурація |
2. Стан гонки: як це проявляється?
Приклад: лічильник відвідувань
Припустімо, ми хочемо порахувати, скільки разів користувач натиснув кнопку (або, у нашому прикладі, скільки разів різні потоки інкрементували змінну). Проста версія коду:
int counter = 0;
void Increment() {
counter++;
}
Тепер створимо два потоки, у кожному з яких по 100 000 разів викликається Increment():
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Очікували: 200 000, отримали: {counter}");
}
}
Скільки разів логічно має бути збільшений counter? 200 000! Але якщо ви запустите цей код кілька разів, майже напевно побачите різні числа: 185 000, 192 500, 198 765… Чому?
3. Чому counter++ — не атомарна операція?
Як насправді працює counter++
У C# та інших мовах високого рівня програма транслюється в набір машинних інструкцій. Оператор counter++, на жаль, не перетворюється на одну магічну команду «додай 1 до змінної». Ось що відбувається насправді:
- Потік ЧИТАЄ значення з пам’яті (counter).
- Збільшує це значення на 1 (у регістрі процесора).
- Записує нове значення назад у пам’ять (counter).
Якщо два потоки роблять це майже одночасно, вони можуть обидва прочитати одне й те саме старе значення, збільшити його і обидва записати результат назад, втративши один інкремент.
Сценарій гонки
Припустімо, counter дорівнював 1000. Обидва потоки прочитали це значення (крок 1), обидва у себе збільшили його до 1001 (крок 2), а потім обидва записали назад 1001 (крок 3). Маємо проблему: один інкремент просто втрачено!
Візуалізація гонки
| Момент часу | Потік 1 | Потік 2 | Значення counter |
|---|---|---|---|
| 1 | Читання 1000 | 1000 | |
| 2 | Читання 1000 | 1000 | |
| 3 | Інкремент до 1001 | Інкремент до 1001 | 1000 (поки ще не відбувся запис) |
| 4 | Запис 1001 | 1001 | |
| 5 | Запис 1001 | 1001 |
У підсумку за два інкременти значення збільшилося всього на 1!
4. Ще трохи прикладів: «невидимі помилки»
А що, якщо race condition трапляється не з числами?
Уявіть тепер, що кілька потоків додають елементи в один і той самий список:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Очікували: 20 000, отримали: {numbers.Count}");
}
}
Цей код теж може дати різні результати на кожному запуску: інколи програма аварійно завершується (виняток), інколи ви побачите менше елементів, ніж очікувалося.
Чому? Річ у тім, що колекція List<T> за замовчуванням не потокобезпечна. Тобто, коли два потоки одночасно викликають Add, може статися пошкодження внутрішньої структури списку.
5. Атомарність операцій
Що таке атомарна операція?
Атомарною називається операція, яка виконується цілком, без можливості бути перерваною іншим потоком посередині. Це щось на кшталт «транзакції»: або все, або нічого.
- Операції присвоєння типу int myVar = 42; на більшості платформ атомарні (якщо тільки це не величезний об’єкт).
- А от counter++ не атомарна — це три послідовні дії.
Спеціальні атомарні операції
У .NET є спеціальні класи для атомарних операцій: наприклад, Interlocked. Такий підхід ми розглянемо в наступних лекціях.
Приклад атомарного інкремента за допомогою Interlocked.Increment:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // атомарна операція!
6. Чому важко виявити стан гонки?
Стан гонки небезпечний тим, що:
- Може проявлятися лише під високим навантаженням.
- Трапляється не у 100 %, а у 5 % або навіть 0,01 % випадків.
- Помилка проявляється начебто випадково й виникає там, де її ніхто не чекає.
Як розпізнати проблему?
Якщо при кожному запуску програми ви отримуєте різні (і неправильні) результати, варто запідозрити гонку даних.
Жарти програмістів
«Якщо помилка з’являється рідко і зникає після додавання Thread.Sleep(50) — у вас проблеми серйозніші, ніж здається.»
7. Корисні нюанси
Синхронізація
Щоб захистити критичні секції (ділянки коду, де відбувається робота зі спільними ресурсами), їх потрібно синхронізувати. Але це тема наступних лекцій. Зараз головне — навчитися помічати й пояснювати проблему.
Типові помилки новачків
Багато початківців думають: «У мене counter++ — що тут може піти не так?» На жаль, щойно у вас з’являється більше ніж один потік, усе може піти не так! Навіть, здавалося б, прості речі — читання й запис змінних, додавання елементів у список, зміна стану об’єкта та багато іншого.
Місце гонок даних у реальній розробці
У сучасних багатопоточних застосунках (наприклад, у серверних API, обробці веб-запитів, іграх і мобільних застосунках) майже завжди є спільні ресурси. Без синхронізації гонки даних призводять до неправильної обробки замовлень, збоїв у роботі, витоків пам’яті й величезних складнощів під час налагодження.
На співбесідах на позиції Middle/Senior обов’язково спитають: «Що таке стан гонки? Як його уникнути?» Якщо ви зможете навести наведені вище приклади і пояснити механіку, рекрутери залишаться задоволені!
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ