Ответ
Проблемы возникают, когда несколько потоков выполняют неатомарные операции (чтение-модификация-запись) над общим состоянием без синхронизации. Это приводит к состояниям гонки (race conditions), повреждению данных и недетерминированному поведению.
Основные механизмы синхронизации в C#:
| Механизм | Назначение | Пример использования |
|---|---|---|
lock (Monitor) |
Базовая эксклюзивная блокировка для защиты критической секции. | Защита простых операций над общим полем. |
Mutex / Semaphore |
Межпроцессная синхронизация или ограничение числа одновременных входов. | Ограничение доступа к общему системному ресурсу. |
ReaderWriterLockSlim |
Оптимизация для сценариев «много читателей, редкие писатели». | Кэш в памяти, который часто читается. |
Concurrent коллекции |
Готовые потокобезопасные реализации (ConcurrentDictionary, ConcurrentQueue). |
Разделяемая очередь задач или кэш. |
Immutable коллекции |
Структуры, которые не изменяются после создания. Копирование при изменении. | Конфигурация, передаваемая между потоками. |
Interlocked |
Атомарные операции для простых типов (инкремент, сравнение с обменом). | Счётчики, флаги. |
Практический пример: потокобезопасный кэш с ConcurrentDictionary и Lazy:
using System.Collections.Concurrent;
public class ThreadSafeCache<TKey, TValue>
{
private readonly ConcurrentDictionary<TKey, Lazy<TValue>> _cache =
new ConcurrentDictionary<TKey, Lazy<TValue>>();
public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
{
// Lazy<T> гарантирует, что фабрика выполнится только один раз,
// даже если несколько потоков попытаются добавить значение одновременно.
var lazyValue = _cache.GetOrAdd(key,
k => new Lazy<TValue>(() => valueFactory(k)));
return lazyValue.Value;
}
}
Антипаттерны и лучшие практики:
- Избегайте вложенных блокировок (deadlock): Всегда приобретайте блокировки в строго определённом глобальном порядке.
- Минимизируйте время удержания блокировки: Выносите из критической секции все операции, которые не требуют эксклюзивного доступа (например, подготовку данных).
- Не используйте
lockна публичных объектах илиthis: Создавайте приватный объект-syncRootдля блокировки, чтобы внешний код не мог вмешаться в вашу схему синхронизации.private readonly object _syncRoot = new object(); // Правильно // lock (this) { } // Опасно! - Предпочитайте неизменяемость (immutability): Если данные не меняются после создания, их можно свободно передавать между потоками без синхронизации.
- Используйте высокоуровневые абстракции: Рассмотрите
Parallel.ForEach,Dataflow (TPL),Channelsили асинхронные очереди как альтернативы ручному управлению потоками и блокировкам.
Ответ 18+ 🔞
Ну, смотри, тут такая история: если у тебя несколько потоков лезут в одну и ту же переменную, да ещё и пытаются её менять без спросу — это ж гарантированный пиздец. Называется "состояние гонки". Данные могут превратиться в кашу, программа начинает вести себя как пьяный ёжик — непредсказуемо и с колючками.
Вот чем можно этот бардак приструнить:
| Штука | Зачем нужна | Где тыкать |
|---|---|---|
lock |
Базовая тема, чтобы один поток в критический момент был хозяином положения. | Когда нужно просто и надёжно прикрыть доступ к общему полю. |
Mutex / Semaphore |
Если твои потоки вообще из разных процессов, или нужно пускать не всех скопом, а по очереди. | Общий ресурс на уровне системы, типа файла или принтера. |
ReaderWriterLockSlim |
Умная блокировка, когда читателей дохуя, а пишет кто-то редко. | Кэш в памяти, который все постоянно читают, а обновляют раз в час. |
Concurrent коллекции |
Уже готовые, потрошёные и потокобезопасные штуки вроде словаря или очереди. | Разделяемая очередь задач или тот же кэш — бери и пользуйся. |
Immutable коллекции |
Коллекции-недотроги. Создал — и всё, не меняется. Хочешь новое значение — создаёшь новую коллекцию. | Конфигурация, которую потоки только читают. Никаких блокировок не нужно вообще. |
Interlocked |
Атомарные операции для примитивов. Быстро, без блокировок. | Счётчики, флаги — то, что нужно просто увеличить или сравнить. |
Вот тебе практичный пример: кэш, который не сломается, даже если в него полезет овердохуища потоков.
using System.Collections.Concurrent;
public class ThreadSafeCache<TKey, TValue>
{
private readonly ConcurrentDictionary<TKey, Lazy<TValue>> _cache =
new ConcurrentDictionary<TKey, Lazy<TValue>>();
public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
{
// Фишка в Lazy<T> — фабрика выполнится строго один раз, даже если десять потоков
// одновременно пришли за одним и тем же ключом. Остальные просто получат готовое значение.
var lazyValue = _cache.GetOrAdd(key,
k => new Lazy<TValue>(() => valueFactory(k)));
return lazyValue.Value;
}
}
А теперь, блядь, главное — как не наступить на грабли:
- Вложенные блокировки — прямой путь в deadlock. Это когда два потока ждут друг от друга ресурсы и оба встают колом. Всегда бери блокировки в одном и том же порядке, глобально.
- Долго не сиди в
lock. Зашёл в критическую секцию — сделал только то, что без вариантов нужно сделать эксклюзивно, и сразу вышел. Всю подготовку данных, долгие вычисления — делай снаружи. - Не лочись на публичном или на
this. Выглядит просто, но это ловушка. Создай приватный объект-заглушку специально для блокировки.private readonly object _syncRoot = new object(); // Вот так, правильно. // lock (this) { } // А так — нет. Внешний код может тебя заблокировать и устроить дедлок. - Неизменяемость — твой друг. Если объект после создания не меняется, его можно таскать по всем потокам без опаски. Никакой синхронизации не нужно, ебись оно конём.
- Не изобретай велосипед. Часто вместо того, чтобы вручную крутить потоки и
lock, проще взять высокоуровневую абстракцию:Parallel.ForEach,Channelsили TPL Dataflow. Они уже всё за тебя продумали.