Какие проблемы возникают при одновременном доступе нескольких Task к общему изменяемому состоянию?

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

Ответ

Параллельный доступ нескольких Task к общему изменяемому состоянию без синхронизации приводит к состоянию гонки (race condition). Это вызывает недетерминированное поведение, повреждение данных и трудноуловимые ошибки.

Основные проблемы:

  1. Потеря обновлений: Два потока читают одно значение, инкрементируют его и записывают обратно. Одно из обновлений теряется.
  2. Некорректное состояние: Один поток читает данные, пока другой поток находится в процессе их изменения, получая "половинчатое" или неконсистентное состояние объекта.

Наглядный пример:

int sharedCounter = 0;
List<Task> tasks = new();

for (int i = 0; i < 1000; i++)
{
    tasks.Add(Task.Run(() => sharedCounter++)); // ОПАСНО: операция не атомарна!
}
await Task.WhenAll(tasks);
Console.WriteLine($"Ожидалось: 1000, Получено: {sharedCounter}");
// Результат будет меньше 1000, например, 987

Способы синхронизации:

Способ Применение Пример для счетчика
Interlocked Атомарные операции с примитивами. Самый быстрый. Interlocked.Increment(ref sharedCounter);
lock (Monitor) Блокировка для защиты произвольного блока кода. Универсально. lock (_lockObject) { sharedCounter++; }
Concurrent коллекции Потокобезопасные структуры данных. Использовать ConcurrentBag<T>, ConcurrentDictionary<K,V>
SemaphoreSlim / Mutex Ограничение доступа к ресурсу для N потоков или межпроцессная синхронизация. Для ограничения пула соединений.

Вывод: Общее изменяемое состояние требует явной синхронизации. Предпочитайте неизменяемые (immutable) структуры данных и потокобезопасные коллекции, где это возможно.