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

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

Ответ

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