Всем привет. Сегодня я расскажу об инструментах, которые существуют в .NET для параллельной работы с какими-то внешними ресурсами и приведу примеры, где и как их можно применить.
При параллельной работе с каким-то ресурсом, нам нужно синхронизировать доступ к нему, чтобы не попасть в состояние гонки или банально не перегрузить его. Для этого в .NET существуют следующие вещи:
-
lock-object. Это самый простой способ синхронизации. Мы заводим объект, который будем использовать для блокировки параллельного выполнения какого-то участка кода.
Применять стоит, когда нам нужно, чтобы какой-то участок кода в один момент времени выполнялся только одним потоком. Тут может быть любая работа с файлами, БД и другими ресурсами.
Важно понимать, что
lock
работает на уровне приложения, а не ОС, поэтому другое приложение может спокойно занять наш ресурс.-
Еще следует помнить, что код в
lock
секции должен выполняться в рамках одного потока, поэтому мы не можем использовать внутри асинхронные вызовы.class LockExample { private readonly object _lockObj = new(); public void Foo() { // Код, который может выполняться несколькими потоками lock (_lockObj) { // Код, выполняемый одним потоком } // Код, который может выполняться несколькими потоками } }
-
Mutex. Используется для ограничения доступа к ресурсу на уровне ОС.
Его может освободить только тот поток, который его занял.
-
Подойдет для ограничения доступа к файлам.
class MutexExample { public void Foo() { Mutex mtx = new(); // Код, который не требует работы со внешним ресурсом if (mtx.WaitOne()) // Можно указать таймаут { try { // Работа с каким-то ресурсом } finally { mtx.ReleaseMutex(); } } // Код, который может выполняться несколькими потоками } }
-
SemaphoreSlim. Облегченная версия семафора. Сам семафор предоставляется ОС и используется для того, чтобы ограничить число одновременных пользователей ресурса.
Если использовать не слим версию, то можем использовать для межпроцессорной синхронизации, так как работает на уровне ОС.
Можем указать, сколько одновременно потоков могут работать с ресурсом. Полезно, если мы не хотим перегрузить его, например, при обращении к сетевой карте при REST-запросах.
-
Слим версия может быть асинхронной, что полезно для работы с файлами, к которым мы хотим ограничить доступ. На работе некоторые настройки сервисов мы храним в
.json
-файлах, для ограничения доступа к ним из нескольких потоков, мы используем слим версию и асинхронное ожидание.class SemSlimExample { private readonly SemaphoreSlime _sync = new(1, 1); public async Task FooAsync() { await _sync.WaitAsync(); try { // Код, который должен выполняться не более чем 1 потоком } finally { _sync.Release(); } } }
-
AutoResetEvent. Как и классы выше служит для синхронизации доступа к ресурсу.
Отличие в том, что позволяет управлять одним потоком из другого.
-
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(); } }
Из примера выше видно, что для подобных ситуаций сейчас проще использовать Task и async/await.
Еще существует
ManualResetEvent
, который требуется возвращать в исходное состояние самостоятельно.Для более сложных сценариев существует
EventWaitHandle
, но с ним мне не приходилось работать.
-
Interlocked. Служит для произведения атомарных операций.
Подходит, если есть какая-то переменная, которую мы хотим атомарно изменять.
-
Еще с его помощью можно поставить флаг на какую-то часть кода, которую должен выполнять какой-то из потоков, но нам не важно какой. Тогда при первом входе в метод поток будет поднимать флаг через Interlocked, а другие потоки будут выходить из метода, когда будут видеть, что флаг уже поднят.
class InterFlagExample { private int _flag = 0; public void Foo() { if (Interlocked.CompareExchange(ref _flag, value: 1, comparand: 0) != 0) return; // Работа для одного потока _flag = 0; } }
Это те вещи, которые я часто встречаю во время работы, и которые могут серьезно облегчить жизнь, если мы собираемся работать с каким-то ресурсом параллельно. Надеюсь кому-то эта статья поможет узнать что-то новое или разобраться с каким-то из этих способов синхронизации.
Комментарии (19)
menelion_elensule
30.12.2023 08:51+5И картинки вместо кода, ну как так-то? Не все, кто читает вашу статью, способны воспринимать визуальную информацию. А могло бы быть весьма полезно.
AndiDieDm Автор
30.12.2023 08:51Благодарю. В следующий раз постараюсь разобраться с редактором кода лучше. В этот раз текст из него выглядел крайне некрасиво, но подозреваю, что я что-то делал не так.
MonkAlex
30.12.2023 08:51+1Mutex как правильно указали - работает на уровне ОС. Поэтому сгодится например для обеспечения запуска 1 копии приложения (можно использовать global для ограничения между пользователями\сессиями).
Пример AutoResetEvent явно показывает, что сейчас лучше работать с тасками - возвращать Task из вашего метода и await-ить его.
В нынешнем дотнете есть ещё Lazy, который часть задач на себя берёт, которые раньше решались как раз чем-то типа lock.
AndiDieDm Автор
30.12.2023 08:51Про
Lazy
слышал, но кроме как в учебных примерах не пользовался, поэтому забыл. Благодарю за напоминание.
maksim_bronnikov
30.12.2023 08:51Сам семафор предоставляется ОС и используется для того, чтобы ограничить число одновременных пользователей ресурса.
Какой вывод можно из этого заключить? Что SemaphoreSlim можно использовать для межпроцессорной синхронизации?
AndiDieDm Автор
30.12.2023 08:51Тут да, ошибся и не раскрыл разницу между
Semaphore
иSemaphoreSlim
, прошу прощение.SemaphoreSlim
не подойдет для межпроцессорной синхронизации, для этой задачи нужно использовать именноSemaphore
.
ryanl
30.12.2023 08:51Такой, на который фантазия позволяет - ограниченный доступ к ресурсу, например тротлинг, degree of parallelism - самые распространенные случаи использования. Если бы я писал про Interlocked - то упомянул бы про общие черты с volatile, и про ключевые отличия в этом их общем разрезе, так сказать. Речь о запрете на reordering optimization.
conqu1stador
30.12.2023 08:51+2Не упомянуты
ReaderWriterLockSlim
/ReaderWriterLock
,CountdownEvent
,volatile
/MemoryBarrier
, не раскрыта разница междуAutoResetEvent
иManualResetEvent
. Проlock
тоже можно было бы рассказать поподробнее (что это синтаксический сахар надMonitor.Enter
/Monitor.Exit
)AndiDieDm Автор
30.12.2023 08:51-2Не приходилось сталкиваться на практике с этими классами, поэтому не написал, спасибо, изучу и их. По поводу
lock
не хотел углубляться в реализацию.
falconandy
30.12.2023 08:51Работает на уровне ОС
Любой ОС, поддерживаемой .NET?
Сам я не проверял, но вот есть довольно свежий вопрос https://stackoverflow.com/questions/75308514/mutex-behaviour-under-linux - но там никто не ответил.MonkAlex
30.12.2023 08:51На скриншоте не убунта, так что вопрос сомнительный.
С глобалом (а в вопросе он) на линуксах точно работало.
Kerman
30.12.2023 08:51+1Semaphore использует Monitor.WaitHandle.
Lock использует Monitor.WaitHandle.
AutoResetEvent использует Monitor.WaitHandle.
И всё это использует объекты ядра операционки - mutex.
SemaphoreSlim тоже использует мьютексы, когда не может обойтись атомарками (interlocked). Вообще, худые семафоры - это попытка сэкономить на использовании мьютексов за счёт атомарных операций. Очень удачная, надо сказать.
Статья - безграмотная чушь.
gyzl
30.12.2023 08:51+1Ну неправда же. Lock использует Monitor.Enter. И дальше я не копал, но очень сомнительно, что дальше это работает на мьютексе - объекте межпроцессной синхронизации.
sdramare
30.12.2023 08:51Монитор устроен довольно сложно, но если опускать детали, то он сначала крутится в спинлоке на уровне юзерленда с проверкой того самого поля синхронизации в хэдере объекта, а потом, если прошло много времени, создает объект синхронизации уровня ядра типа Event и паркует тред. Это все описано у рихтера под названием "hybrid lock".
gyzl
30.12.2023 08:51Это да, но я знал что так сделано на Windows, там, кажется, так работает EnterCriticalSection. Как сейчас на NET Core и на других платформах - не знаю, буду благодарен, если кто просветит.
dmitry_dvm
30.12.2023 08:51-1Это те вещи, которые я часто встречаю во время работы
А что сфера разработки? В бэке за 7 лет только локи иногда встречал и 1 раз AutoResetEvent, и то там тасками можно было обойтись.
AirLight
30.12.2023 08:51Это все слишком сложно, я использую вместо этого паттерн Consumer-Producer паттер для определения количества исполнителей.
MonkAlex
Mutex как правильно указали - работает на уровне ОС. Поэтому сгодится например для обеспечения запуска 1 копии приложения (можно использовать global для ограничения между пользователями\сессиями).
Пример AutoResetEvent явно показывает, что сейчас лучше работать с тасками - возвращать Task из вашего метода и await-ить его.
В нынешнем дотнете есть ещё Lazy, который часть задач на себя берёт, которые раньше решались как раз чем-то типа lock.