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

Ответ

Параллельный доступ нескольких 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) структуры данных и потокобезопасные коллекции, где это возможно.

Ответ 18+ 🔞

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

Смотри, в чём прикол. Представь, у тебя есть общая переменная, sharedCounter. И ты с десяти разных мест, как ошалелый, начинаешь в неё тыкать, команду sharedCounter++ выполнять. Ну, типа, увеличить на единичку, че сложного-то?

А сложно, блядь, вот что! Эта операция counter++ — она же не атомарная, как думают некоторые. Она разбивается на три этапа:

  1. Прочитать текущее значение из памяти.
  2. В своём локальном мирке прибавить к нему единицу.
  3. Записать НОВОЕ значение обратно в общую память.

И вот пока один поток на втором шаге, другой уже успевает прочитать СТАРОЕ значение. И они оба запишут, условно, не 998 и 999, а оба 998. Одно обновление нахуй теряется! Это и есть состояние гонки (race condition). В итоге после тысячи попыток у тебя будет не 1000, а, например, 987. Потому что 13 раз кто-то кого-то обогнал и насрал в общую кассу.

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}");
// Консоль тебе радостно сообщит: "Получено: 987". Обосрись со смеху.

И что делать-то, спрашиваешь? Синхронизировать доступ, вот что!

Есть несколько проверенных способов, как не дать потокам друг другу мозги выебать:

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

Итог, блядь, простой: если состояние общее и изменяемое — его надо защищать. Или, что ещё лучше, проектируй так, чтобы не было этого общего изменяемого состояния. Используй иммутабельные данные и потокобезопасные коллекции, и жить станет проще, а код — надёжнее.