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

Ответ

Проблемы возникают, когда несколько потоков выполняют неатомарные операции (чтение-модификация-запись) над общим состоянием без синхронизации. Это приводит к состояниям гонки (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;
    }
}

Антипаттерны и лучшие практики:

  1. Избегайте вложенных блокировок (deadlock): Всегда приобретайте блокировки в строго определённом глобальном порядке.
  2. Минимизируйте время удержания блокировки: Выносите из критической секции все операции, которые не требуют эксклюзивного доступа (например, подготовку данных).
  3. Не используйте lock на публичных объектах или this: Создавайте приватный объект-syncRoot для блокировки, чтобы внешний код не мог вмешаться в вашу схему синхронизации.
    private readonly object _syncRoot = new object(); // Правильно
    // lock (this) { } // Опасно!
  4. Предпочитайте неизменяемость (immutability): Если данные не меняются после создания, их можно свободно передавать между потоками без синхронизации.
  5. Используйте высокоуровневые абстракции: Рассмотрите 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;
    }
}

А теперь, блядь, главное — как не наступить на грабли:

  1. Вложенные блокировки — прямой путь в deadlock. Это когда два потока ждут друг от друга ресурсы и оба встают колом. Всегда бери блокировки в одном и том же порядке, глобально.
  2. Долго не сиди в lock. Зашёл в критическую секцию — сделал только то, что без вариантов нужно сделать эксклюзивно, и сразу вышел. Всю подготовку данных, долгие вычисления — делай снаружи.
  3. Не лочись на публичном или на this. Выглядит просто, но это ловушка. Создай приватный объект-заглушку специально для блокировки.
    private readonly object _syncRoot = new object(); // Вот так, правильно.
    // lock (this) { } // А так — нет. Внешний код может тебя заблокировать и устроить дедлок.
  4. Неизменяемость — твой друг. Если объект после создания не меняется, его можно таскать по всем потокам без опаски. Никакой синхронизации не нужно, ебись оно конём.
  5. Не изобретай велосипед. Часто вместо того, чтобы вручную крутить потоки и lock, проще взять высокоуровневую абстракцию: Parallel.ForEach, Channels или TPL Dataflow. Они уже всё за тебя продумали.