Irina Korneva 03 августа 2023

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

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

Атомарные операции в C#

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

Характеристики атомарных методов в C#

Существуют две главные характеристики атомарных методов в C#.

  1. Если один поток исполняется атомарным методом, другой поток не видит промежуточное состояние, когда метод либо не был начат, либо уже был закончен. Тем не менее не существует промежуточного состояния между началом и концом.
  2. Операция будет успешно завершена или полностью провалена без внесения каких-либо изменений. Это похоже на транзакции баз данных, где все операции успешны или не проведены вовсе при наличии хотя бы одной ошибки.
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека шарписта»

Как достигнуть атомарности в C#?

Существует несколько способов достижения атомарности в C#. Самым распространенным является использование оператора Lock. Он позволяет блокировать исполнение части кода другими потоками при активации замка. При работе с коллекциями другим вариантом является использование параллельных коллекций, специально созданных для случаев многопоточности. Если же не использовать подходящие механизмы, в конце работы кода могут быть получены неожиданные результаты, поврежденные данные или неверные значения.

Безопасность потоков в C#

Важной концепцией в среде параллелизма является потоковая безопасность. Метод называется потокобезопасным, если его можно выполнить одновременно в нескольких потоках без возникновения ошибок.

Как достичь безопасности потоков в C#?

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

При необходимости трансформации объектов можно использовать неизменяемые объекты, чтобы избежать их повреждения. В идеале стоит работать с чистыми функциями. Ими являются те функции, которые возвращают одни и те же значения для одних и тех же аргументов и не приводят к побочным эффектам.

Состояния гонки в C#

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

Эта операция делится на три части – чтение, увеличение и запись. Учитывая тот факт, что имеется три операции, два потока могут выполнить их таким образом, что даже при повторном увеличении значения переменной только одно увеличение вступает в силу.

Пример состояния гонки в C#

В следующей таблице два потока пытаются последовательно инкрементировать переменную.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Изначально значение переменной равняется нулю. Затем Поток 1 инкрементирует это значение в памяти и передает это значение в переменную. Затем значение переменной становится равно единице.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

После того как Поток 2 считает значение переменной, которое теперь равно единице, он инкрементирует значение в памяти. Затем он так же запишет значение в переменную. Теперь значение переменной равно двойке.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Такой результат ожидаем вследствие последовательного выполнения. Но что случится, если два потока попытаются обновить переменную одновременно?

Что случится, если два потока попытаются обновить переменную одновременно?

В результате может получиться, что итоговым значением переменной будет либо единица, либо двойка. Рассмотрим первый случай.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Теперь Поток 1 и Поток 2 вместе считывают значения и оба хранят в памяти значение ноль.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Поток 1 инкрементирует значение, как и Поток 2, и в памяти обоих теперь хранится значение один.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Затем Поток 1 записывает в переменную значение один, а Поток 2 проделывает это еще раз.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

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

Как решить эту проблему в C#?

Мы можем использовать механизмы синхронизации. Сначала используем механизм interlocked. Затем посмотрим, как использовать операторы lock.

Interlocked в C#

Класс Interlocked в C# позволяет атомарно выполнять определенные операции, то есть дает возможность безопасно выполнять операции над одной и той же переменной из разных потоков.

Пример Interlocked в C#

Сначала посмотрим на пример без использования Interlocked, а затем перепишем его и посмотрим, как Interlocked решает проблему безопасности потоков.

В примере была объявлена переменная, значение которой инкрементируется с использованием цикла Parallel.For. Так как этот цикл использует многопоточность, несколько потоков пытаются обновить одну и ту же переменную ValueWithoutInterlocked. Здесь цикл выполняется 100000 раз, так что и значение переменной будет равно 100000.

        using System;
using System.Threading.Tasks;
namespace ParallelProgrammingDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            var ValueWithoutInterlocked = 0;
            Parallel.For(0, 100000, _ =>
            {
                //Incrementing the value
                ValueWithoutInterlocked++;
            });
            Console.WriteLine("Expected Result: 100000");
            Console.WriteLine($"Actual Result: {ValueWithoutInterlocked}");
            Console.ReadKey();
        }
    }
}
    

При запуске данного кода результат каждый раз будет разным. Взглянем на разницу между полученным и ожидаемым результатом:

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Пример с классом Interlocked в C#

Класс Interlocked в C# предоставляет статический метод Increment. Он в качестве атомарной операции инкрементирует указанную переменную и хранит результат. Поэтому необходимо указать нужную переменную с помощью ключевого слова ref, как показано в примере ниже.

        using System;
using System.Threading;
using System.Threading.Tasks;
namespace ParallelProgrammingDemo
{
    class Program
    {
        static void Main(string[] args)
        {
            var ValueInterlocked = 0;
            Parallel.For(0, 100000, _ =>
            {
                //Incrementing the value
               Interlocked.Increment(ref ValueInterlocked);
            });
            Console.WriteLine("Expected Result: 100000");
            Console.WriteLine($"Actual Result: {ValueInterlocked}");
            Console.ReadKey();
        }
    }
}
    
Вывод:
⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

Как видно в выводе, ожидаемый и полученный результаты совпадают. Это значит, что механизм синхронизации Interlocked позволяет избежать состояния гонки, делая операцию атомарной. Класс Interlocked имеет много статических методов, таких как Increment, Add, Exchange и так далее.

⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#

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

Lock в C#:

С помощью Lock можно блокировать код, который будет выполняться только одним потоком за раз, то есть сделать часть кода последовательной, даже если несколько потоков попытаются выполнить её одновременно. Мы используем оператор Lock, когда необходимо выполнить одну или несколько операций, не затронутых механизмом Interlocked.

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

Пример Lock в C#:

Перепишем предыдущий пример с помощью Lock. Лучше всего выделить под замок отдельный объект.

        using System;
using System.Threading.Tasks;
namespace ParallelProgrammingDemo
{
    class Program
    {
        static object lockObject = new object();
        static void Main(string[] args)
        {
            var ValueWithLock = 0;
            Parallel.For(0, 100000, _ =>
            {
                lock(lockObject)
                {
                    //Incrementing the value
                    ValueWithLock++;
                }
            });
            Console.WriteLine("Expected Result: 100000");
            Console.WriteLine($"Actual Result: {ValueWithLock}");
            Console.ReadKey();
        }
    }
}
    
Вывод:
⚛️ Атомарные операции, безопасность потоков и состояние гонки в C#
***

В этой статье вы узнали, как:

  • достигнуть атомарности операций;
  • обеспечить безопасность потоков;
  • использовать Interlocked и Lock в C#.

Материалы по теме

Источники

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ