«Атом» происходит от греческого atomos — "неразрезаемый" и использовался в смысле "неделимая наименьшая единица", пока физики не обнаружили, что на самом деле существуют объекты меньших размеров, чем атомы. В параллельном программировании (многопоточная среда) атомарные методы удобны, так как они гарантируют детерминированность, то есть достижение одного и того же результата вне зависимости от того, сколько потоков одновременно пытаются выполнить инструкцию.
Существуют две главные характеристики атомарных методов:
Важной концепцией в среде параллелизма является потоковая безопасность. Метод называется потокобезопасным, если его можно выполнить одновременно в нескольких потоках без возникновения ошибок.
Необходимые для достижения безопасности потоков действия зависят от того, что происходит внутри метода. Если в метод добавить внешнюю переменную, она может принять неожиданное значение. Этого можно избежать с помощью механизмов синхронизации, таких как статический класс Interlocked или оператор lock.
При необходимости трансформации объектов можно использовать неизменяемые объекты, чтобы избежать их повреждения. В идеале стоит работать с чистыми функциями. Ими являются те функции, которые возвращают одни и те же значения для одних и тех же аргументов и не приводят к побочным эффектам.
Состояние гонки возникает, когда несколько потоков используют одну и ту же переменную и пытаются одновременно её изменить. Проблема заключается в том, что в зависимости от порядка проведения потоками операций над переменной её значения будут отличаться. В таком случае даже инкрементация может быть проблематичной, потому что данная операция не атомарна.
Инкремент делится на три части: чтение, увеличение, запись. Учитывая тот факт, что имеется три операции, два потока могут выполнить их таким образом, что даже при повторном увеличении значения переменной только одно увеличение вступает в силу.
Что случится, если два потока попытаются обновить переменную одновременно? В следующей таблице можно наглядно понять, что произойдёт. Значение переменной зависит от порядка выполнения методов. Таким образом, даже если мы дважды увеличиваем значение в разных потоках, состояние гонки делает операцию недетерминированной.
for (int i = 0; i < 4; i++)
{
int value = 0;
Parallel.For(0, 100_000, _ => value++);
Console.WriteLine($"Actual Result: {value}");
}
В примере выше объявлена переменная, значение которой увеличивается с использованием цикла Parallel.For. Так как этот цикл использует многопоточность, несколько потоков пытаются обновить одну и ту же переменную value. Здесь цикл выполняется 100к раз, а значит значение переменной мы ожидаем в 100000.
Однако по запуску данного кода результат каждый раз (все 4 раза в конструкции for) будет разным:
Actual Result: 100000
Actual Result: 83615
Actual Result: 73592
Actual Result: 89645
Мы можем использовать механизмы синхронизации с заменой исходного примера. Например класс Interlocked, который предоставляет методы Increment, Add, Exchange и т.д:
int value = 0;
Parallel.For(0, 100_000, _ => Interlocked.Increment(ref value));
Console.WriteLine($"Actual Result: {value}");
Либо оператор lock, в блоке которого код будет выполняться только одним потоком за раз:
object sync = new();
int value = 0;
Parallel.For(0, 100_000, _ =>
{
lock (sync)
{
value++;
}
});
Console.WriteLine($"Actual Result: {value}");
Теперь при вызове кода мы всегда будем получать ожидаемые результаты.
#Полезно #Многопоточность