Pull to refresh

Синхронизация операций в .NET на примерах

Level of difficultyMedium
Reading time3 min
Views12K

Всем привет. Сегодня я расскажу об инструментах, которые существуют в .NET для параллельной работы с какими-то внешними ресурсами и приведу примеры, где и как их можно применить.

При параллельной работе с каким-то ресурсом, нам нужно синхронизировать доступ к нему, чтобы не попасть в состояние гонки или банально не перегрузить его. Для этого в .NET существуют следующие вещи:

  1. lock-object. Это самый простой способ синхронизации. Мы заводим объект, который будем использовать для блокировки параллельного выполнения какого-то участка кода.

    1. Применять стоит, когда нам нужно, чтобы какой-то участок кода в один момент времени выполнялся только одним потоком. Тут может быть любая работа с файлами, БД и другими ресурсами.

    2. Важно понимать, что lock работает на уровне приложения, а не ОС, поэтому другое приложение может спокойно занять наш ресурс.

    3. Еще следует помнить, что код в lock секции должен выполняться в рамках одного потока, поэтому мы не можем использовать внутри асинхронные вызовы.

      class LockExample 
      {
          private readonly object _lockObj = new();
          public void Foo() 
          {
              // Код, который может выполняться несколькими потоками
              lock (_lockObj) 
              {
                  // Код, выполняемый одним потоком
              }
              // Код, который может выполняться несколькими потоками
          }
      }
  2. Mutex. Используется для ограничения доступа к ресурсу на уровне ОС.

    1. Его может освободить только тот поток, который его занял.

    2. Подойдет для ограничения доступа к файлам.

      class MutexExample 
      {
          public void Foo() 
          {
              Mutex mtx = new();
              // Код, который не требует работы со внешним ресурсом
              if (mtx.WaitOne()) // Можно указать таймаут        
              {
                  try
                  {       
                      // Работа с каким-то ресурсом
                  }
                  finally
                  {
                      mtx.ReleaseMutex();
                  }
              }
              // Код, который может выполняться несколькими потоками
          }
      }
  3. SemaphoreSlim. Облегченная версия семафора. Сам семафор предоставляется ОС и используется для того, чтобы ограничить число одновременных пользователей ресурса.

    1. Если использовать не слим версию, то можем использовать для межпроцессорной синхронизации, так как работает на уровне ОС.

    2. Можем указать, сколько одновременно потоков могут работать с ресурсом. Полезно, если мы не хотим перегрузить его, например, при обращении к сетевой карте при REST-запросах.

    3. Внутри кода, ограниченного семафором, может быть асинхронность, что полезно для работы с файлами, к которым мы хотим ограничить доступ. На работе некоторые настройки сервисов мы храним в .json-файлах, для ограничения доступа к ним из нескольких потоков, мы используем слим версию и асинхронное ожидание.

    4. Slim версию можно ожидать асинхронно, что тоже удобно.

      class SemSlimExample
      {
          private readonly SemaphoreSlime _sync = new(1, 1);
          public async Task FooAsync()
          {
              await _sync.WaitAsync();
              try
              {
                  // Код, который должен выполняться не более чем 1 потоком
              }
              finally
              {
                  _sync.Release();
              }
          }
      }
  4. AutoResetEvent. Как и классы выше служит для синхронизации доступа к ресурсу.

    1. Отличие в том, что позволяет управлять одним потоком из другого.

    2. AutoResetEvent - автоматически возвращается в начальное состояние после сигнала.

      class AREExample
      {
          private AutoResetEvent _evt = new(false);
          private List<string> _data = new();
          public void Foo()
          {
              _evt = new(false);
              var load = Task.Run(ReceiveDataFromServer);
              // Независимая от результата работа
              _evt.WaitOne();
              // Работа с _data
          }
          private void ReceiveDataFromServer()
          {
              var rawData = Requester.GetData("url");
              Parallel.ForEach((raw) => data.Add(HandleRaw(raw)));
              _evt.Set();
          }
      }
    3. Из примера выше видно, что для подобных ситуаций сейчас проще использовать Task и async/await.

    4. Еще существует ManualResetEvent, который требуется возвращать в исходное состояние самостоятельно.

    5. Для более сложных сценариев существует EventWaitHandle, но с ним мне не приходилось работать.

  5. Interlocked. Служит для произведения атомарных операций.

    1. Подходит, если есть какая-то переменная, которую мы хотим атомарно изменять.

    2. Еще с его помощью можно поставить флаг на какую-то часть кода, которую должен выполнять какой-то из потоков, но нам не важно какой. Тогда при первом входе в метод поток будет поднимать флаг через Interlocked, а другие потоки будут выходить из метода, когда будут видеть, что флаг уже поднят.

      class InterFlagExample
      {
          private int _flag = 0;
          public void Foo()
          {
              if (Interlocked.CompareExchange(ref _flag, value: 1, comparand: 0) != 0)
                return;
              // Работа для одного потока
              _flag = 0;
          }
      }

Это те вещи, которые я часто встречаю во время работы, и которые могут серьезно облегчить жизнь, если мы собираемся работать с каким-то ресурсом параллельно. Надеюсь кому-то эта статья поможет узнать что-то новое или разобраться с каким-то из этих способов синхронизации.

Tags:
Hubs:
Total votes 14: ↑10 and ↓4+7
Comments26

Articles