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

      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;
          }
      }

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

Комментарии (19)


  1. MonkAlex
    30.12.2023 08:51
    +1

    Mutex как правильно указали - работает на уровне ОС. Поэтому сгодится например для обеспечения запуска 1 копии приложения (можно использовать global для ограничения между пользователями\сессиями).

    Пример AutoResetEvent явно показывает, что сейчас лучше работать с тасками - возвращать Task из вашего метода и await-ить его.

    В нынешнем дотнете есть ещё Lazy, который часть задач на себя берёт, которые раньше решались как раз чем-то типа lock.


  1. menelion_elensule
    30.12.2023 08:51
    +5

    И картинки вместо кода, ну как так-то? Не все, кто читает вашу статью, способны воспринимать визуальную информацию. А могло бы быть весьма полезно.


    1. AndiDieDm Автор
      30.12.2023 08:51

      Благодарю. В следующий раз постараюсь разобраться с редактором кода лучше. В этот раз текст из него выглядел крайне некрасиво, но подозреваю, что я что-то делал не так.


  1. MonkAlex
    30.12.2023 08:51
    +1

    Mutex как правильно указали - работает на уровне ОС. Поэтому сгодится например для обеспечения запуска 1 копии приложения (можно использовать global для ограничения между пользователями\сессиями).

    Пример AutoResetEvent явно показывает, что сейчас лучше работать с тасками - возвращать Task из вашего метода и await-ить его.

    В нынешнем дотнете есть ещё Lazy, который часть задач на себя берёт, которые раньше решались как раз чем-то типа lock.


    1. AndiDieDm Автор
      30.12.2023 08:51

      Про Lazy слышал, но кроме как в учебных примерах не пользовался, поэтому забыл. Благодарю за напоминание.


    1. Raleose
      30.12.2023 08:51

      Аналог AutoResetEvent в тасках - TaskCompletionSource


  1. maksim_bronnikov
    30.12.2023 08:51

    Сам семафор предоставляется ОС и используется для того, чтобы ограничить число одновременных пользователей ресурса.

    Какой вывод можно из этого заключить? Что SemaphoreSlim можно использовать для межпроцессорной синхронизации?


    1. AndiDieDm Автор
      30.12.2023 08:51

      Тут да, ошибся и не раскрыл разницу между Semaphore и SemaphoreSlim, прошу прощение. SemaphoreSlim не подойдет для межпроцессорной синхронизации, для этой задачи нужно использовать именно Semaphore.


    1. ryanl
      30.12.2023 08:51

      Такой, на который фантазия позволяет - ограниченный доступ к ресурсу, например тротлинг, degree of parallelism - самые распространенные случаи использования. Если бы я писал про Interlocked - то упомянул бы про общие черты с volatile, и про ключевые отличия в этом их общем разрезе, так сказать. Речь о запрете на reordering optimization.


  1. conqu1stador
    30.12.2023 08:51
    +2

    Не упомянуты ReaderWriterLockSlim/ReaderWriterLock, CountdownEvent, volatile/ MemoryBarrier, не раскрыта разница между AutoResetEvent и ManualResetEvent. Про lock тоже можно было бы рассказать поподробнее (что это синтаксический сахар над Monitor.Enter/ Monitor.Exit)


    1. AndiDieDm Автор
      30.12.2023 08:51
      -2

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


  1. falconandy
    30.12.2023 08:51

    Работает на уровне ОС

    Любой ОС, поддерживаемой .NET?

    Сам я не проверял, но вот есть довольно свежий вопрос https://stackoverflow.com/questions/75308514/mutex-behaviour-under-linux - но там никто не ответил.


    1. MonkAlex
      30.12.2023 08:51

      На скриншоте не убунта, так что вопрос сомнительный.

      С глобалом (а в вопросе он) на линуксах точно работало.


  1. Kerman
    30.12.2023 08:51
    +1

    Semaphore использует Monitor.WaitHandle.

    Lock использует Monitor.WaitHandle.

    AutoResetEvent использует Monitor.WaitHandle.

    И всё это использует объекты ядра операционки - mutex.

    SemaphoreSlim тоже использует мьютексы, когда не может обойтись атомарками (interlocked). Вообще, худые семафоры - это попытка сэкономить на использовании мьютексов за счёт атомарных операций. Очень удачная, надо сказать.

    Статья - безграмотная чушь.


    1. gyzl
      30.12.2023 08:51
      +1

      Ну неправда же. Lock использует Monitor.Enter. И дальше я не копал, но очень сомнительно, что дальше это работает на мьютексе - объекте межпроцессной синхронизации.


      1. sdramare
        30.12.2023 08:51

        Монитор устроен довольно сложно, но если опускать детали, то он сначала крутится в спинлоке на уровне юзерленда с проверкой того самого поля синхронизации в хэдере объекта, а потом, если прошло много времени, создает объект синхронизации уровня ядра типа Event и паркует тред. Это все описано у рихтера под названием "hybrid lock".


        1. gyzl
          30.12.2023 08:51

          Это да, но я знал что так сделано на Windows, там, кажется, так работает EnterCriticalSection. Как сейчас на NET Core и на других платформах - не знаю, буду благодарен, если кто просветит.


          1. sdramare
            30.12.2023 08:51

            https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/syncblk.cpp


  1. dmitry_dvm
    30.12.2023 08:51
    -1

    Это те вещи, которые я часто встречаю во время работы

    А что сфера разработки? В бэке за 7 лет только локи иногда встречал и 1 раз AutoResetEvent, и то там тасками можно было обойтись.


  1. AirLight
    30.12.2023 08:51

    Это все слишком сложно, я использую вместо этого паттерн Consumer-Producer паттер для определения количества исполнителей.