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

«Как протестировать многопоточный код?» — вопрос из категории Тестирование, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Тестирование многопоточного кода сложно, так как нужно выявлять состояния гонки (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.