JavaRush /Курси /C# SELF /Найкращі практики та інструменти для діагностики

Найкращі практики та інструменти для діагностики

C# SELF
Рівень 57 , Лекція 4
Відкрита

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[Лише один пише, решта чекають]

Синхронізаційні примітиви та їх призначення

Примітив Навіщо потрібен Скільки потоків пропускає Міжпроцесно Продуктивність Де використовувати
lock (Monitor)
Проста критична секція 1 Ні Дуже висока 99 % випадків
Mutex
Та сама критична секція, але між процесами 1 Так Середня файли, IPC
Semaphore
Не більше N потоків N Так Середня Пули ресурсів
SemaphoreSlim
Те саме, але швидше, у межах одного процесу N Ні Висока Пули в коді
ReaderWriterLockSlim
Багато читачів, один записувач Багато/1 Ні Висока кеші, налаштування
1
Опитування
Взаємні блокування, рівень 57, лекція 4
Недоступний
Взаємні блокування
Типові проблеми багатопоточності
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ