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

Ответ

Безопасная блокировка (синхронизация) доступа к общему ресурсу из нескольких потоков — ключевая задача. В C# для этого есть несколько механизмов, выбор зависит от контекста.

Основные подходы:

  1. Ключевое слово lock (Monitor.Enter/Exit)

    • Самый распространенный и простой способ для синхронизации доступа к блоку кода внутри одного процесса (AppDomain).
    • Принцип: Использует объект-маркер (object _lock = new object();). Только один поток может владеть этим маркером в данный момент.
    • Важно: Используйте приватный, readonly объект, специально созданный для блокировки. Никогда не используйте this, typeof(...) или публичные объекты — это может привести к взаимоблокировкам (deadlock).

      public class Account
      {
      private readonly object _balanceLock = new object();
      private decimal _balance;
      
      public void Deposit(decimal amount)
      {
          // Только один поток может выполнять этот блок кода для данного экземпляра Account
          lock (_balanceLock)
          {
              _balance += amount;
          }
      }
      }
  2. Класс Mutex (мьютекс)

    • Принцип: Аналогичен lock, но может быть именованным и использоваться для синхронизации между разными процессами (межпроцессная синхронизация).
    • Минусы: Более тяжеловесный, чем lock.
      private static Mutex _mutex = new Mutex(false, "GlobalMyAppMutex");
      public void Process()
      {
      _mutex.WaitOne(); // Захват мьютекса
      try
      {
          // Критическая секция
      }
      finally
      {
          _mutex.ReleaseMutex(); // Освобождение мьютекса ВСЕГДА в finally
      }
      }
  3. Класс Semaphore и SemaphoreSlim

    • Принцип: Позволяет ограничить количество потоков, которые могут одновременно войти в критическую секцию, до заданного числа (больше 1). SemaphoreSlim — легковесная версия для внутрипроцессной синхронизации.
    • Идеально для: Ограничения доступа к пулу ресурсов (например, не более 10 одновременных подключений к внешнему API).
      
      private static SemaphoreSlim _pool = new SemaphoreSlim(initialCount: 3, maxCount: 3);

    public async Task AccessResourceAsync() { await _pool.WaitAsync(); // Ждем, если уже 3 потока внутри try { // Работа с ресурсом (максимум 3 потока одновременно) await Task.Delay(1000); } finally { _pool.Release(); // Освобождаем слот } }

  4. Класс ReaderWriterLockSlim

    • Принцип: Оптимизирован для сценариев, где чтение происходит часто, а запись редко. Позволяет множеству потоков читать данные одновременно, но дает эксклюзивный доступ на запись.
    • Идеально для: Кэшей или конфигураций, которые часто читаются, но изредка обновляются.
      
      private readonly ReaderWriterLockSlim _cacheLock = new ReaderWriterLockSlim();
      private Dictionary<string, Data> _cache = new();

    public Data GetData(string key) { _cacheLock.EnterReadLock(); // Множественные потоки могут войти сюда try { return _cache[key]; } finally { _cacheLock.ExitReadLock(); } }

    public void UpdateData(string key, Data value) { _cacheLock.EnterWriteLock(); // Эксклюзивная блокировка. Ждет, пока все читатели выйдут. try { _cache[key] = value; } finally { _cacheLock.ExitWriteLock(); } }

Общее правило: Всегда заключайте работу с блокировкой в блок try...finally, чтобы гарантировать ее освобождение даже в случае исключения. Для асинхронных методов предпочитайте SemaphoreSlim.WaitAsync() или другие асинхронные примитивы, чтобы не блокировать потоки пула.

Ответ 18+ 🔞

Давай разберем эту тему про то, как потокам не давать друг другу в табло, когда они лезут к одному и тому же ресурсу. В C# для этого есть целый арсенал, и выбор зависит от того, какую именно драку ты хочешь устроить.

Основные способы:

  1. Ключевое слово lock (по сути, обёртка над Monitor)

    • Самый простой и частый способ, когда все действо происходит в рамках одного приложения.
    • Суть: Берёшь какой-нибудь объект как маркер (object _lock = new object();). Кто первый схватил этот маркер — тот и работает. Остальные ждут, как лохи.
    • Важнейшее правило: Используй приватный, ридонли объект, созданный специально для блокировки. Ни в коем случае не бери this, typeof(...) или что-то публичное — это верный путь к взаимной блокировке, где все потоки просто повиснут и сдохнут.

      public class Account
      {
      private readonly object _balanceLock = new object(); // Вот он, наш маркер для драки
      private decimal _balance;
      
      public void Deposit(decimal amount)
      {
          // Только один поток в один момент времени может тут орудовать
          lock (_balanceLock)
          {
              _balance += amount; // Спокойно работаем, пока остальные стоят в очереди
          }
      }
      }
  2. Класс Mutex

    • Похож на lock, но круче. Его можно именовать и использовать для синхронизации между разными процессами (например, чтобы два экземпляра твоей программы не накосячили с одним файлом).
    • Минус: Он тяжелее, чем обычный lock, так что внутри одного процесса без нужды не юзай.
      private static Mutex _mutex = new Mutex(false, "GlobalMyAppMutex");
      public void Process()
      {
      _mutex.WaitOne(); // Захватываем мьютекс. Если он уже занят — висим и ждём.
      try
      {
          // Делаем что-то важное в полной изоляции
      }
      finally
      {
          _mutex.ReleaseMutex(); // ОСВОБОЖДАТЬ НАДО В ЛЮБОМ СЛУЧАЕ, даже если вылетело исключение!
      }
      }
  3. Semaphore и SemaphoreSlim

    • Тут философия другая. lock и Mutex пускают только одного. А семафор может пустить сразу несколько потоков, но не больше заданного лимита. SemaphoreSlim — его облегчённый брат для работы внутри одного процесса.
    • Идеально, когда у тебя, например, лимит на 5 одновременных запросов к какому-нибудь API, и больше нельзя.
      
      private static SemaphoreSlim _pool = new SemaphoreSlim(initialCount: 3, maxCount: 3); // Пускаем максимум троих

    public async Task AccessResourceAsync() { await _pool.WaitAsync(); // Ждём, если все три слота уже заняты try { // Работаем с ресурсом. Нас тут может быть до трёх штук одновременно. await Task.Delay(1000); } finally { _pool.Release(); // Освобождаем слот, чтобы кто-то другой мог зайти } }

  4. ReaderWriterLockSlim

    • Это для умных сценариев, когда чтение происходит часто, а запись — редко. Зачем всем читающим ждать друг друга? Пусть читают хоть все сразу! Но когда приходит писатель — он получает эксклюзивный доступ, и все читатели его терпеливо ждут.
    • Золото для кэшей, настроек и прочего, что часто читается, но обновляется раз в пятилетку.
      
      private readonly ReaderWriterLockSlim _cacheLock = new ReaderWriterLockSlim();
      private Dictionary<string, Data> _cache = new();

    public Data GetData(string key) { _cacheLock.EnterReadLock(); // Много потоков могут зайти сюда одновременно try { return _cache[key]; } finally { _cacheLock.ExitReadLock(); } }

    public void UpdateData(string key, Data value) { _cacheLock.EnterWriteLock(); // А вот тут эксклюзив! Все читатели подождут снаружи. try { _cache[key] = value; } finally { _cacheLock.ExitWriteLock(); } }

Главный принцип, который забывают идиоты: Всегда освобождай блокировку в finally! Иначе при исключении твой ресурс намертво повиснет, и остальные потоки будут ждать его до скончания времён. Для асинхронного кода используй асинхронные версии (типа SemaphoreSlim.WaitAsync()), чтобы не блокировать потоки пула попусту.