Как протестировать многопоточный код?

Ответ

Тестирование многопоточного кода сложно, так как нужно выявлять состояния гонки (race conditions), взаимные блокировки (deadlocks) и проблемы с видимостью памяти. Вот практические подходы для C#/.NET.

1. Стресс-тестирование с параллельным выполнением Запускаем тестируемый метод множество раз из разных потоков, чтобы выявить недетерминированные ошибки.

[Test]
public void Counter_ShouldBeThreadSafe_UnderConcurrentAccess()
{
    const int iterations = 100000;
    int counter = 0;
    var options = new ParallelOptions { MaxDegreeOfParallelism = 10 };

    // Многократно инкрементируем счетчик из параллельных потоков
    Parallel.For(0, iterations, options, i =>
    {
        // Если Increment() не потокобезопасен, итоговое значение будет меньше iterations
        Increment(ref counter);
    });

    // Утверждение: если код потокобезопасен, счетчик должен быть равен iterations
    Assert.That(counter, Is.EqualTo(iterations));
}

// НЕПОТОКОБЕЗОПАСНАЯ реализация для демонстрации
private static void Increment(ref int value) => value = value + 1;

2. Использование примитивов синхронизации в тестах ManualResetEventSlim, CountdownEvent, Barrier помогают координировать потоки в предсказуемой последовательности для воспроизведения специфических сценариев (например, deadlock).

[Test]
public void ResourceAccess_ShouldNotDeadlock()
{
    var lockA = new object();
    var lockB = new object();
    bool deadlockDetected = false;
    var testCompleted = new ManualResetEventSlim();

    Task task1 = Task.Run(() =>
    {
        lock (lockA)
        {
            Thread.Sleep(10); // Имитация работы
            if (Monitor.TryEnter(lockB, 1000)) // Пытаемся захватить с таймаутом
            {
                Monitor.Exit(lockB);
            }
            else { deadlockDetected = true; }
            lock (lockA) { }
        }
    });

    // ... аналогичный код для task2, захватывающего lockB, затем lockA
    Task.WaitAll(task1, task2);
    Assert.IsFalse(deadlockDetected, "Обнаружен взаимный блокировка (deadlock)");
}

3. Специализированные библиотеки и инструменты

  • Microsoft.VisualStudio.Threading.Analyzers: Статический анализ кода на предмет проблем с deadlock.
  • Concurrency Visualizer (в Performance Profiler): Визуализация работы потоков для анализа.
  • Тестовые фреймворки: Некоторые (например, xUnit) позволяют запускать один тест параллельно много раз для выявления "хлопающих" (flaky) тестов.

Главный совет: Проектируйте код так, чтобы минимизировать общее изменяемое состояние. Используйте неизменяемые (immutable) структуры данных и потокобезопасные коллекции (ConcurrentBag, ConcurrentDictionary) из пространства имен System.Collections.Concurrent.

Ответ 18+ 🔞

А, ну это же классика, блядь! Тестирование многопоточного кода — это как пытаться поймать за хвост трёх кошек в тёмной комнате, пока они друг другу ебут мозг. Одна ошибка вылезет, а вторая уже в другом месте сидит, хитрая жопа.

Смотри, вот как это обычно делается, без лишней академической хуйни.

1. Запустить всё в ад и посмотреть, что сдохнет (Стресс-тестирование) Суть проста, как три копейки: наваливаем на метод дохуя потоков одновременно и смотрим, не развалится ли он. Если логика кривая — где-то да проебётся.

[Test]
public void Counter_ShouldBeThreadSafe_UnderConcurrentAccess()
{
    const int iterations = 100000;
    int counter = 0;
    var options = new ParallelOptions { MaxDegreeOfParallelism = 10 };

    // Просто ебём метод со всех сторон кучей потоков
    Parallel.For(0, iterations, options, i =>
    {
        // Если Increment() написан через жопу, итог будет меньше, чем iterations
        Increment(ref counter);
    });

    // Вот тут и выяснится: либо герой, либо говно. Должно быть ровно iterations.
    Assert.That(counter, Is.EqualTo(iterations));
}

// Нарочно кривая реализация, чтобы показать, как НЕ НАДО
private static void Increment(ref int value) => value = value + 1;

Видишь? Без синхронизации counter после такого издевательства будет похож на решето — значение каждый раз разное и меньше ожидаемого. Волнение ебать!

2. Создать ад намеренно, чтобы проверить на deadlock Бывает, нужно воспроизвести конкретный пиздец, когда два потока повисли, смотря друг другу в глаза и держа замки. Для этого юзаем ManualResetEventSlim или Barrier, чтобы синхронизировать их старт как надо.

[Test]
public void ResourceAccess_ShouldNotDeadlock()
{
    var lockA = new object();
    var lockB = new object();
    bool deadlockDetected = false;
    var testCompleted = new ManualResetEventSlim();

    Task task1 = Task.Run(() =>
    {
        lock (lockA)
        {
            Thread.Sleep(10); // Чуть-чуть поработали
            if (Monitor.TryEnter(lockB, 1000)) // Пробуем взять второй замок, но не вечно
            {
                Monitor.Exit(lockB);
            }
            else { deadlockDetected = true; } // Не получилось — значит, подозрительно
            lock (lockA) { }
        }
    });

    // ... тут task2, который берёт сначала lockB, потом lockA
    Task.WaitAll(task1, task2);
    Assert.IsFalse(deadlockDetected, "Обнаружен взаимный блокировка (deadlock)");
}

Смысл в том, чтобы заставить потоки взяться за замки в такой последовательности, при которой они могут повиснуть. А Monitor.TryEnter с таймаутом — это как страховочная верёвка, чтобы тест сам не завис нахуй навечно.

3. Взять в помощь специнструменты Тут уже без магии, просто факты:

  • Microsoft.VisualStudio.Threading.Analyzers: Эта штука может на статическом анализе намекнуть, что ты, возможно, проектируешь deadlock. Бесплатный намёк, игнорировать — грех.
  • Concurrency Visualizer (в Performance Profiler): Позволяет буквально увидеть, как потоки дерутся за ресурсы. Картинка бывает красноречивее тысячи логов.
  • Возможности фреймворков: Тот же xUnit может прогнать один и тот же тест сто раз подряд, чтобы выловить плавающую багу, которая вылезает раз в полгода. Удивление пиздец, когда она таки находится.

И главный совет, который сэкономит тебе нервов: Старайся проектировать так, чтобы потокам нечего было делить. Меньше общего изменяемого состояния — меньше головной боли. Используй неизменяемые данные и бери готовые потокобезопасные коллекции из System.Collections.ConcurrentConcurrentBag, ConcurrentDictionary и прочие. Они уже прошиты по самое небалуйся и избавят тебя от тонны ручной синхронизации. А то самому всё на lock обвешивать — это верный путь в дебри, откуда потом не выбраться.