Атомарные операции в C#
Атомарные методы удобны в многопоточной среде, так как они гарантируют детерминированность, то есть достижение одного и того же результата вне зависимости от того, сколько потоков одновременно пытаются исполнить метод.
Характеристики атомарных методов в C#
Существуют две главные характеристики атомарных методов в C#.
- Если один поток исполняется атомарным методом, другой поток не видит промежуточное состояние, когда метод либо не был начат, либо уже был закончен. Тем не менее не существует промежуточного состояния между началом и концом.
- Операция будет успешно завершена или полностью провалена без внесения каких-либо изменений. Это похоже на транзакции баз данных, где все операции успешны или не проведены вовсе при наличии хотя бы одной ошибки.
Как достигнуть атомарности в C#?
Существует несколько способов достижения атомарности в C#. Самым распространенным является использование оператора Lock
. Он позволяет блокировать исполнение части кода другими потоками при активации замка. При работе с коллекциями другим вариантом является использование параллельных коллекций, специально созданных для случаев многопоточности. Если же не использовать подходящие механизмы, в конце работы кода могут быть получены неожиданные результаты, поврежденные данные или неверные значения.
Безопасность потоков в C#
Важной концепцией в среде параллелизма является потоковая безопасность. Метод называется потокобезопасным, если его можно выполнить одновременно в нескольких потоках без возникновения ошибок.
Как достичь безопасности потоков в C#?
Необходимые для достижения безопасности потоков действия зависят от того, что происходит внутри метода. Если в метод добавить внешнюю переменную, она может принять неожиданное значение. Этого можно избежать с помощью механизмов синхронизации, таких как Interlocked
или Lock
.
При необходимости трансформации объектов можно использовать неизменяемые объекты, чтобы избежать их повреждения. В идеале стоит работать с чистыми функциями. Ими являются те функции, которые возвращают одни и те же значения для одних и тех же аргументов и не приводят к побочным эффектам.
Состояния гонки в C#
Состояние гонки в C# возникает, когда несколько потоков используют одну и ту же переменную и пытаются одновременно ее изменить. Проблема заключается в том, что в зависимости от порядка проведения потоками операций над переменной её значения будут отличаться. В таком случае даже инкрементация может быть проблематичной, потому что данная операция не атомарна.
Эта операция делится на три части – чтение, увеличение и запись. Учитывая тот факт, что имеется три операции, два потока могут выполнить их таким образом, что даже при повторном увеличении значения переменной только одно увеличение вступает в силу.
Пример состояния гонки в C#
В следующей таблице два потока пытаются последовательно инкрементировать переменную.
Изначально значение переменной равняется нулю. Затем Поток 1 инкрементирует это значение в памяти и передает это значение в переменную. Затем значение переменной становится равно единице.
После того как Поток 2 считает значение переменной, которое теперь равно единице, он инкрементирует значение в памяти. Затем он так же запишет значение в переменную. Теперь значение переменной равно двойке.
Такой результат ожидаем вследствие последовательного выполнения. Но что случится, если два потока попытаются обновить переменную одновременно?
Что случится, если два потока попытаются обновить переменную одновременно?
В результате может получиться, что итоговым значением переменной будет либо единица, либо двойка. Рассмотрим первый случай.
Теперь Поток 1 и Поток 2 вместе считывают значения и оба хранят в памяти значение ноль.
Поток 1 инкрементирует значение, как и Поток 2, и в памяти обоих теперь хранится значение один.
Затем Поток 1 записывает в переменную значение один, а Поток 2 проделывает это еще раз.
Это означает, что значение переменной зависит от порядка выполнения методов. Таким образом, даже если мы дважды увеличиваем значение в разных потоках из-за многопоточной среды, состояние гонки делает операцию недетерминированной. Иногда мы можем получить значение один, иногда значение два. Это зависит от случая.
Как решить эту проблему в C#?
Мы можем использовать механизмы синхронизации. Сначала используем механизм interlocked
. Затем посмотрим, как использовать операторы lock
.
Interlocked в C#
Класс Interlocked
в C# позволяет атомарно выполнять определенные операции, то есть дает возможность безопасно выполнять операции над одной и той же переменной из разных потоков.
Пример Interlocked в C#
Сначала посмотрим на пример без использования Interlocked
, а затем перепишем его и посмотрим, как Interlocked решает проблему безопасности потоков.
В примере была объявлена переменная, значение которой инкрементируется с использованием цикла Parallel.For
. Так как этот цикл использует многопоточность, несколько потоков пытаются обновить одну и ту же переменную ValueWithoutInterlocked
. Здесь цикл выполняется 100000 раз, так что и значение переменной будет равно 100000.
При запуске данного кода результат каждый раз будет разным. Взглянем на разницу между полученным и ожидаемым результатом:
Пример с классом Interlocked в C#
Класс Interlocked
в C# предоставляет статический метод Increment
. Он в качестве атомарной операции инкрементирует указанную переменную и хранит результат. Поэтому необходимо указать нужную переменную с помощью ключевого слова ref
, как показано в примере ниже.
Вывод:
Как видно в выводе, ожидаемый и полученный результаты совпадают. Это значит, что механизм синхронизации Interlocked
позволяет избежать состояния гонки, делая операцию атомарной. Класс Interlocked
имеет много статических методов, таких как Increment
, Add
, Exchange
и так далее.
Иногда мы хотим, чтобы только один поток имел доступ к критической секции. Для этого можно использовать оператор Lock
.
Lock в C#:
С помощью Lock
можно блокировать код, который будет выполняться только одним потоком за раз, то есть сделать часть кода последовательной, даже если несколько потоков попытаются выполнить её одновременно. Мы используем оператор Lock
, когда необходимо выполнить одну или несколько операций, не затронутых механизмом Interlocked
.
Стоит учитывать, что в идеале часть заблокированного кода должна выполняться относительно быстро. Это обусловлено тем, что потоки блокируются в ожидании снятия замка, а блокировка нескольких потоков на долгое время может повлиять на скорость работы приложения.
Пример Lock в C#:
Перепишем предыдущий пример с помощью Lock
. Лучше всего выделить под замок отдельный объект.
Вывод:
В этой статье вы узнали, как:
- достигнуть атомарности операций;
- обеспечить безопасность потоков;
- использовать
Interlocked
иLock
в C#.
Комментарии