1. Секрет стійкого багатопоточного коду
Якщо уявити багатопоточність як команду з кількох працівників, які одночасно лагодять машину, то очевидно: варто лише одному з них схопити чужий інструмент — і все, ремонт зупинився. У коді — те саме: необережне поводження зі спільними даними призводить до прихованих багів, що проявляються лише «у бою».
У цій лекції ви побачите, як писати багатопоточний код, що не розсипається, наче картковий будиночок. А ще — які інструменти допоможуть, якщо все ж щось піде не так.
1. Мінімізуйте критичні секції (lock)
Чим менше коду всередині блокування lock, тим краще. Поки один потік тримає блокування, інші чекають.
Приклад:
// ПОГАНО: Уся бізнес-логіка всередині lock — усі потоки чекають
lock(_locker)
{
// Довга операція (не повʼязана зі спільним ресурсом)
Thread.Sleep(500);
counter++;
}
// ДОБРЕ: Тільки мінімально необхідна дія
// поза lock — уся важка робота поза блокуванням
Thread.Sleep(500);
lock(_locker)
{
counter++;
}
Реальність: Якщо всередині блокування опинився виклик у мережу або тривале обчислення, продуктивність різко падає.
2. Не використовуйте як ключ блокування універсальні об’єкти
Писати lock(this) або lock(typeof(MyClass)) — погана ідея.
Чому? Якщо хтось ще використовує той самий об’єкт для свого блокування, ви отримаєте взаємні блокування або приховані баги. Завжди використовуйте окремий приватний об’єкт:
private readonly object _locker = new object();
lock(_locker)
{
// Ваші дії
}
Заборонено: рядки (string), публічні поля, типи значення.
3. Завжди використовуйте try...finally для звільнення захоплених ресурсів
Будь-яке захоплення Mutex, семафора, ReaderWriterLockSlim — має бути звільнене у finally.
_mutex.WaitOne();
try
{
// Критична секція
}
finally
{
_mutex.ReleaseMutex();
}
4. Не зловживайте синхронізацією
Синхронізуйте лише доступ до реальних спільних ресурсів (наприклад, колекцій), а не кожну дрібницю. Зайві блокування перетворюють код на чергу очікування.
5. Використовуйте потокобезпечні колекції та типи
.NET надає спеціальні колекції для багатопоточних сценаріїв: ConcurrentDictionary, ConcurrentQueue, ConcurrentBag, BlockingCollection тощо. Вони вже мають внутрішній захист.
using System.Collections.Concurrent;
ConcurrentDictionary<int, string> users = new ConcurrentDictionary<int, string>();
users.TryAdd(1, "Іван");
users[2] = "Олена";
6. Остерігайтеся взаємних блокувань (deadlock)
Типова пастка — захоплення кількох блокувань у різному порядку.
// Потік 1
lock(obj1)
{
lock(obj2)
{
// Щось робимо
}
}
// Потік 2
lock(obj2)
{
lock(obj1)
{
// Щось робимо
}
}
Порада: Завжди захоплюйте блокування в одному й тому самому порядку в усіх потоках.
7. За можливості використовуйте незмінний стан (immutable state)
Якщо об’єкт не змінює стан після створення — його безпечно читати з будь-яких потоків. Приклади: string, Tuple, DateTime, власні DTO лише для читання.
2. Інструменти для діагностики проблем багатопоточності
Помилки синхронізації підступні: проявляються рідко й непередбачувано. Використовуйте інструменти та підходи, які допомагають їх ловити й аналізувати.
1. Логування подій і потоків
Логуйте поточний Thread.ManagedThreadId і ключові операції — це простий спосіб зрозуміти, «хто й коли» увійшов/вийшов із критичної секції.
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Увійшов у критичну секцію");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Вийшов із критичної секції");
Для реальних застосунків використовуйте Microsoft.Extensions.Logging, NLog, Serilog.
2. Thread Sanitizer & Race Detector
У .NET немає «ідеального» вбудованого Thread Sanitizer, але є корисні інструменти:
- JetBrains ReSharper — інспекції виявляють частину небезпечних шаблонів.
- Roslyn Analyzers — статичний аналіз коду.
- Concurrency Visualizer — аналіз очікувань, блокувань і завантаження потоків.
3. Visual Studio Diagnostics Tools
Профілювальники Visual Studio допомагають побачити:
- які потоки існують у застосунку;
- де потоки простоюють (waiting);
- де виникають блокування та конкуренція за блокування;
- коли виникають взаємні блокування (deadlock) і конкуренція (contention).
Знімайте трасування, щоб отримати детальний граф використання блокувань.
4. Аналіз дампів і WinDbg
Якщо сервер завис, зніміть дамп процесу й відкрийте його в WinDbg або dotnet-dump. За стеками викликів видно, де потоки застрягли й хто утримує які блокування.
Приклад аналізу стеків:
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 000001d4b6f90e08 1 1 000001d4b5c941c0 000001d4b6f03458
(До дампів зазвичай вдаються «досвідчені джедаї» розгортання — не бійтеся, це потужний інструмент.)
5. Юніт-тестування з навантаженням (stress testing)
Запускайте багатопоточний код паралельно в сотнях/тисячах ітерацій — так їх легше виявити.
[Test]
public void Counter_IsThreadSafe()
{
var counter = 0;
var locker = new object();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
lock (locker)
{
counter++;
}
}
}));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(100 * 10000, counter);
}
6. Використання асертів і спецперевірок
Додавайте перевірки, що гарантують коректний стан під час налагодження. Наприклад, використовуйте Debug.Assert, коли той самий потік намагається повторно захопити ресурс.
3. Висновки та рекомендації
Візуальна схема: небезпечна зона і безпека
graph TD
A[Спільний ресурс] -- без синхронізації --> B(Стан гонки)
A -- блокування (lock/Mutex) --> C[Безпечний доступ: критична секція]
C -- "занадто багато блокувань" --> D(Втрата продуктивності)
A -- ReaderWriterLockSlim --> E{Багато читачів / Один записувач}
E -- "Читання" --> F[Багато потоків читають одночасно]
E -- "Запис" --> G[Лише один пише, решта чекають]
Синхронізаційні примітиви та їх призначення
| Примітив | Навіщо потрібен | Скільки потоків пропускає | Міжпроцесно | Продуктивність | Де використовувати |
|---|---|---|---|---|---|
|
Проста критична секція | 1 | Ні | Дуже висока | 99 % випадків |
|
Та сама критична секція, але між процесами | 1 | Так | Середня | файли, IPC |
|
Не більше N потоків | N | Так | Середня | Пули ресурсів |
|
Те саме, але швидше, у межах одного процесу | N | Ні | Висока | Пули в коді |
|
Багато читачів, один записувач | Багато/1 | Ні | Висока | кеші, налаштування |
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ