Ответ
Параллельный доступ нескольких Task к общему изменяемому состоянию без синхронизации приводит к состоянию гонки (race condition). Это вызывает недетерминированное поведение, повреждение данных и трудноуловимые ошибки.
Основные проблемы:
- Потеря обновлений: Два потока читают одно значение, инкрементируют его и записывают обратно. Одно из обновлений теряется.
- Некорректное состояние: Один поток читает данные, пока другой поток находится в процессе их изменения, получая "половинчатое" или неконсистентное состояние объекта.
Наглядный пример:
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++ — она же не атомарная, как думают некоторые. Она разбивается на три этапа:
- Прочитать текущее значение из памяти.
- В своём локальном мирке прибавить к нему единицу.
- Записать НОВОЕ значение обратно в общую память.
И вот пока один поток на втором шаге, другой уже успевает прочитать СТАРОЕ значение. И они оба запишут, условно, не 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 потоков одновременно к какому-то ресурсу (типа БД) лезло. | Для ограничения пула соединений — идеально. |
Итог, блядь, простой: если состояние общее и изменяемое — его надо защищать. Или, что ещё лучше, проектируй так, чтобы не было этого общего изменяемого состояния. Используй иммутабельные данные и потокобезопасные коллекции, и жить станет проще, а код — надёжнее.