Атомарные операции, безопасность потоков и состояние гонки в 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.
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();
}
}
}
При запуске данного кода результат каждый раз будет разным. Взглянем на разницу между полученным и ожидаемым результатом:
Пример с классом 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();
}
}
}
Вывод:
Как видно в выводе, ожидаемый и полученный результаты совпадают. Это значит, что механизм синхронизации Interlocked позволяет избежать состояния гонки, делая операцию атомарной. Класс Interlocked имеет много статических методов, таких как Increment, Add, Exchange и так далее.
Иногда мы хотим, чтобы только один поток имел доступ к критической секции. Для этого можно использовать оператор 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();
}
}
}
Вывод:
В этой статье вы узнали, как:
- достигнуть атомарности операций;
- обеспечить безопасность потоков;
- использовать Interlocked и Lock в C#.
Материалы по теме
Как безопасно и эффективно использовать многопоточность в .NET
Многопоточность может использоваться для значительного повышения производительности вашего приложения, но никакое ускорение не является бесплатным — управление параллельными потоками требует тщательного программирования, и без надлежащих мер предосторожности вы можете столкнуться с условиями гонки, взаимоблокировками и даже сбоями.
Что затрудняет многопоточность?
Если вы не укажете своей программе иное, весь ваш код будет выполняться в «основном потоке». С точки входа вашего приложения оно проходит и выполняет все ваши функции одну за другой.
Это имеет предел производительности, поскольку, очевидно, вы можете сделать так много, только если вам нужно обрабатывать все по одному.
Большинство современных процессоров имеют шесть или более ядер с 12 или более потоками, поэтому производительность остается на столе, если вы их не используете.
https://www.youtube.com/watch?v=v_vbvrWKznY\u0026pp=ygVz0JDRgtC-0LzQsNGA0L3Ri9C1INC-0L_QtdGA0LDRhtC40LgsINCx0LXQt9C-0L_QsNGB0L3QvtGB0YLRjCDQv9C-0YLQvtC60L7QsiDQuCDRgdC-0YHRgtC-0Y_QvdC40LUg0LPQvtC90LrQuCDQsiBDIw%3D%3D
Однако это не так просто, как просто «включить многопоточность». Только определенные вещи (например, циклы) могут быть правильно многопоточными, и при этом необходимо учитывать множество соображений.
Первая и самая важная проблема – условия гонки. Это часто происходит во время операций записи, когда один поток изменяет ресурс, совместно используемый несколькими потоками. Это приводит к поведению, при котором вывод программы зависит от того, какой поток заканчивает или изменяет что-то первым, что может привести к случайному и неожиданному поведению.
Это может быть очень, очень просто — например, вам нужно вести текущий подсчет чего-то между циклами. Самый очевидный способ сделать это — создать переменную и увеличить ее, но это не потокобезопасно.
Это состояние гонки возникает из-за того, что это не просто «добавление единицы к переменной» в абстрактном смысле; ЦП загружает значение number в регистр, добавляет единицу к этому значению, а затем сохраняет результат как новое значение переменной. Он не знает, что тем временем другой поток также пытался сделать то же самое и загрузил скоро должно быть неверное значение number. Два потока конфликтуют, и в конце цикла number может быть не равно 100.
В .NET есть функция, помогающая справиться с этим: ключевое слово lock. Это не препятствует прямому внесению изменений, но помогает управлять параллелизмом, позволяя одновременно получать блокировку только одному потоку. Если другой поток пытается ввести оператор блокировки во время обработки другого потока, он будет ждать до 300 мс, прежде чем продолжить.
Вы можете блокировать только ссылочные типы, поэтому общий шаблон заключается в предварительном создании объекта блокировки и использовании его вместо блокировки типа значения.
Однако вы можете заметить, что появилась еще одна проблема: взаимоблокировки. Этот код — наихудший пример, но здесь это почти то же самое, что и обычный цикл for (на самом деле немного медленнее, поскольку дополнительные потоки и блокировки несут дополнительные накладные расходы).
Каждый поток пытается получить блокировку, но только один поток может иметь блокировку, поэтому только один поток за раз может фактически запустить код внутри блокировки. В данном случае это весь код цикла, поэтому оператор блокировки лишает всех преимуществ многопоточности и просто замедляет работу.
Как правило, вы хотите блокировать по мере необходимости всякий раз, когда вам нужно делать записи.
Однако вы должны помнить о параллелизме при выборе того, что блокировать, потому что чтение также не всегда является потокобезопасным.
Если другой поток выполняет запись в объект, чтение из другого потока может дать неверное значение или привести к тому, что определенное условие вернет неверный результат.
К счастью, есть несколько приемов, позволяющих сделать это правильно, с помощью которых вы можете сбалансировать скорость многопоточности при использовании блокировок, чтобы избежать условий гонки.
Используйте Interlocked для атомарных операций
Для основных операций использование оператора lock может быть излишним. Хотя это очень полезно для блокировки перед сложными изменениями, это слишком много для такой простой вещи, как добавление или замена значения.
Interlocked – это класс, обертывающий некоторые операции с памятью, такие как сложение, замена и сравнение. Базовые методы реализованы на уровне ЦП и гарантированно являются атомарными и намного быстрее, чем стандартная инструкция lock. Вы захотите использовать их всякий раз, когда это возможно, хотя они не заменят полностью блокировку.
https://www.youtube.com/watch?v=v_vbvrWKznY\u0026pp=YAHIAQE%3D
В приведенном выше примере замена блокировки вызовом Interlocked.Add() значительно ускорит операцию. Хотя этот простой пример не быстрее, чем просто не использовать Interlocked, он полезен как часть более крупной операции и по-прежнему является ускорением.
Также есть Increment и Decrement для операций ++ и — , которые сэкономят вам целых два нажатия клавиш. Они буквально скрывают Add(ref count, 1) под капотом, поэтому их использование не дает особого ускорения.
Вы также можете использовать Exchange, общий метод, который устанавливает переменную, равную переданному ей значению. Тем не менее, вы должны быть осторожны с этим — если вы устанавливаете его на значение, которое вы вычислили, используя исходное значение, это не является потокобезопасным, поскольку старое значение могло быть изменено до запуска Interlocked.Exchange.
CompareExchange проверит два значения на равенство и заменит значение, если они равны.
Используйте потокобезопасные коллекции
Коллекции по умолчанию в System.Collections.Generic можно использовать с многопоточностью, но они не являются полностью потокобезопасными. Microsoft предоставляет потокобезопасные реализации некоторых коллекций в System.Collections.Concurrent.
К ним относятся ConcurrentBag, неупорядоченная универсальная коллекция, и ConcurrentDictionary, поточно-ориентированный словарь. Существуют также параллельные очереди и стеки, а также OrderablePartitioner, который может разделять упорядоченные источники данных, такие как списки, на отдельные разделы для каждого потока.
Посмотрите, как распараллелить циклы
Часто проще всего использовать многопоточность в больших и дорогих циклах. Если вы можете выполнять несколько опций параллельно, вы можете получить огромное ускорение в общем времени выполнения.
Лучший способ справиться с этим — использовать System.Threading.Tasks.Parallel. Этот класс предоставляет замену циклам for и foreach, которые выполняют тело цикла в отдельных потоках. Он прост в использовании, хотя требует немного другого синтаксиса:
Очевидно, что загвоздка здесь в том, что вам нужно убедиться, что DoSomething() безопасен для потоков и не взаимодействует с какими-либо общими переменными. Однако это не всегда так просто, как просто заменить цикл параллельным циклом, и во многих случаях для внесения изменений необходимо заблокировать общие объекты.
Чтобы устранить некоторые проблемы с взаимоблокировками, Parallel.For и Parallel.ForEach предоставляют дополнительные функции для работы с состоянием.
По сути, не каждая итерация будет выполняться в отдельном потоке — если у вас есть 1000 элементов, это не создаст 1000 потоков; он создаст столько потоков, сколько сможет обработать ваш ЦП, и будет выполнять несколько итераций для каждого потока. Это означает, что если вы вычисляете итог, вам не нужно блокировать каждую итерацию.
Вы можете просто обойти переменную промежуточного итога, а в самом конце заблокировать объект и внести изменения один раз. Это значительно снижает нагрузку на очень большие списки.
Давайте посмотрим на пример. Следующий код берет большой список объектов и должен сериализовать каждый из них отдельно в JSON, в результате чего получается List всех объектов. Сериализация JSON — очень медленный процесс, поэтому разделение каждого элемента на несколько потоков — это большое ускорение.
Есть куча аргументов, и здесь есть что распаковать:
- Первый аргумент принимает IEnumerable, определяющий данные, по которым выполняется цикл. Это цикл ForEach, но та же концепция работает и с базовыми циклами For.
- Первое действие инициализирует локальную переменную промежуточного итога. Эта переменная будет совместно использоваться для каждой итерации цикла, но только внутри одного и того же потока. Другие потоки будут иметь свои собственные промежуточные итоги. Здесь мы инициализируем его пустым списком. Если вы вычисляли числовое значение, вы могли бы вернуть 0 здесь.
- Второе действие — тело основного цикла. Первый аргумент — это текущий элемент (или индекс в цикле For), второй — объект ParallelLoopState, который можно использовать для вызова .Break(), а последний — переменная промежуточного итога.
- В этом цикле вы можете работать с элементом и изменять промежуточный итог. Возвращаемое вами значение заменит промежуточный итог для следующего цикла. В этом случае мы сериализуем элемент в строку, а затем добавляем строку к промежуточному итогу, который является списком.
Многопоточность единства
И последнее замечание: если вы используете игровой движок Unity, вам нужно быть осторожным с многопоточностью. Вы не можете вызывать какие-либо API Unity, иначе игра вылетит. Его можно использовать экономно, выполняя операции API в основном потоке и переключаясь туда и обратно всякий раз, когда вам нужно что-то распараллелить.
В основном это относится к операциям, которые взаимодействуют со сценой или физическим движком. Математика Vector3 не затрагивается, и вы можете без проблем использовать ее из отдельного потока. Вы также можете свободно изменять поля и свойства своих собственных объектов при условии, что они не вызывают никаких внутренних операций Unity.
Как безопасно и эффективно использовать многопоточность в .NET — — 2023
Видео: c# (Csharp) и .NET: многопоточность и потокобезопасные объекты.
Добро пожаловать в этот учебник. В этом руководстве представлен обзор того, как безопасно и эффективно использовать многопоточность в .NET, а также убедиться, что ваши приложения работают с максимальной отдачей. Мы затронем такие темы, как безопасное управление потоками, синхронизация, предотвращение взаимоблокировок и другие необходимые аспекты для эффективного программирования. Итак, давайте углубимся в многопоточность в .NET и посмотрим, как сделать ваши приложения максимально эффективными.
Использование многопоточности может значительно повысить производительность приложения, но это не бесплатно. Для успешного управления несколькими потоками необходимо тщательное программирование, и если оно не будет выполнено должным образом, могут возникнуть условия гонки, взаимоблокировки и сбои.
Что затрудняет многопоточность?
ЦП теперь могут использовать многопоточность для повышения эффективности и производительности. Однако существуют потенциальные проблемы, которые могут возникнуть при работе с потоками и общими ресурсами, такие как условия гонки и взаимоблокировки.
По умолчанию весь код в вашей программе будет выполняться в «основном потоке». Это означает, что с точки входа вашего приложения все функции будут выполняться последовательно.
Однако, поскольку большинство процессоров теперь имеют несколько ядер и потоков, существует потенциал для повышения эффективности и производительности, если вы воспользуетесь этим.
К сожалению, этот подход может быть ограничен, поскольку он обрабатывает все одно за другим.
https://www.youtube.com/watch?v=z6M5YCWm4Go\u0026pp=ygVz0JDRgtC-0LzQsNGA0L3Ri9C1INC-0L_QtdGA0LDRhtC40LgsINCx0LXQt9C-0L_QsNGB0L3QvtGB0YLRjCDQv9C-0YLQvtC60L7QsiDQuCDRgdC-0YHRgtC-0Y_QvdC40LUg0LPQvtC90LrQuCDQsiBDIw%3D%3D
Многопоточность вступает в игру не просто одним щелчком переключателя; только определенные компоненты, такие как циклы, могут быть выполнены таким образом, и есть несколько моментов, которые необходимо принять во внимание, прежде чем продолжить.
Условия гонки — одна из самых важных проблем, которую следует учитывать при работе с потоками и общими ресурсами. Они возникают, когда несколько потоков пытаются одновременно писать в один и тот же ресурс. Это приводит к непредсказуемым и непреднамеренным результатам, так как вывод программы будет зависеть от того, какой поток завершит или изменяет ресурс первым.
Создание переменной и ее увеличение может быть очень простым способом вести подсчет чего-либо между циклами; однако это не является потокобезопасным.
В этой ситуации может возникнуть состояние гонки, потому что ЦП не знает о других потоках, пытающихся сделать то же самое одновременно — загрузить значение числа в регистр, добавить единицу к этому значению, а затем сохранить его как новое значение переменной. Если два потока попытаются сделать это одновременно, их конфликтующие действия могут привести к ситуации, когда число не равно 100 в конце цикла.
С помощью ключевого слова блокировки .NET можно управлять одновременными запросами, чтобы гарантировать, что только один поток может получить блокировку за раз. Любой другой поток, который попытается ввести оператор блокировки во время обработки блокировки, будет вынужден ждать до 300 мс, прежде чем продолжить.
Частый шаблон при работе со ссылочными типами заключается в создании объекта блокировки перед использованием, который затем можно использовать для «блокировки» типа значения.
Несмотря на то, что этот код может иметь некоторые преимущества в производительности, он может привести к взаимоблокировкам. По сути, этот код аналогичен использованию обычного цикла for, за исключением дополнительных потоков и блокировок, которые могут вызвать дополнительные накладные расходы.
Каждый поток пытается получить блокировку, но только один может иметь ее одновременно, поэтому только один поток сможет выполнить код внутри блокировки. Поскольку весь код цикла заключен в блокировку, это не дает многопоточности давать какие-либо преимущества, что делает ее намного медленнее.
Всякий раз, когда вам нужно внести изменения, рекомендуется заблокировать; однако примите во внимание параллелизм при принятии решения о том, какие блокировки использовать.
Даже при чтении только из объекта существует вероятность возникновения ситуации, небезопасной для потоков.
Если другой поток выполняет запись в него, это может привести к возвращению неверного значения или вызвать непредвиденный результат из-за условия.
Если вы хотите поддерживать скорость, не допуская при этом условий гонки, есть несколько приемов, которые вы можете использовать. Использование блокировок может помочь сохранить скорость многопоточности, избегая при этом нежелательных условий гонки.
Использование блокировок может помочь сохранить скорость многопоточности, избегая при этом нежелательных условий гонки, в то же время необходимо соблюдать осторожность, чтобы избежать ситуаций, небезопасных для потоков, даже при чтении из объекта.
Хотя многопоточность может повысить производительность, при ее реализации необходимо учитывать потенциальные риски условий гонки и взаимоблокировок. Необходимо также учитывать понимание использования блокировок, чтобы избежать этих проблем, чтобы поддерживать скорость при сохранении безопасности данных программы.
Используйте Interlocked для атомарных операций
Используйте Interlocked для атомарных операций
Управление параллелизмом часто необходимо при манипулировании данными в многопоточных приложениях. Операции блокировки могут быть неэффективными, но класс Interlocked предоставляет набор атомарных операций с памятью, которые при правильном использовании могут выполняться быстрее, чем стандартный оператор блокировки.
https://www.youtube.com/watch?v=z6M5YCWm4Go\u0026pp=YAHIAQE%3D
Оператор блокировки может быть неэффективен для базовых операций, таких как добавление или замена значений, из-за дополнительных накладных расходов, которые требуются для операций, не требующих значительной сложности. Тем не менее, это может быть очень полезно для блокировки перед более сложными изменениями.
Класс Interlocked предоставляет набор операций атомарной памяти, таких как сложение, замена и сравнение, которые выполняются на уровне ЦП. Эти операции выполняются быстрее, чем стандартный оператор блокировки, и их следует использовать, когда это возможно. Однако они не могут полностью заменить блокировку.
Заменив блокировку вызовом Interlocked.Add(), описанная выше операция будет намного быстрее. Хотя это само по себе не обязательно быстрее, чем неиспользование Interlocked, его полезность в более крупных операциях все же компенсирует это и обеспечивает повышение скорости.
Существуют операции увеличения и уменьшения, обозначаемые символами ++ и — соответственно, которые можно использовать для экономии двух нажатий клавиш. По сути, эти операции выполняют ту же функцию, что и Add(ref count, 1), без дополнительного увеличения скорости.
Метод Interlocked.Exchange можно использовать для установки переменной, равной переданному ей значению, однако при этом следует соблюдать осторожность, поскольку он не является потокобезопасным, если исходное значение было изменено до того, как Interlocked.Exchange был бегать.
Функция CompareExchange сравнивает два значения и, если они равны, значение будет заменено.
Взаимосвязанные операции, такие как Add(), Increment и Decrement и Exchange(), могут ускорить операции по сравнению с блокировкой, и их следует учитывать, когда несколько потоков изменяют одни и те же данные. Однако следует соблюдать осторожность при использовании метода Exchange, поскольку он не является потокобезопасным, если исходное значение изменяется до запуска Interlocked.Exchange.
Класс Interlocked предоставляет множество операций с атомарной памятью, которые могут повысить скорость по сравнению с блокировкой, и их следует учитывать при работе с многопоточностью. Важно понимать различия между операциями, чтобы правильно их использовать.
Используйте потокобезопасные коллекции
Используйте потокобезопасные коллекции
При программировании с несколькими потоками необходимо учитывать реализацию необходимого интерфейса коллекции. В среде .NET Microsoft предоставляет классы System.Collections.Generic, а также классы System.Collections.Concurrent для потокобезопасных коллекций.
Коллекции в System.Collections.Generic не являются полностью потокобезопасными, но могут использоваться с многопоточностью. Для потокобезопасных реализаций некоторых коллекций Microsoft предлагает System.Collections.Concurrent.
Среди них — ConcurrentBag, универсальная неупорядоченная коллекция, и ConcurrentDictionary, поточно-ориентированный словарь. Кроме того, существуют параллельные очереди и стеки, а также OrderablePartitioner, который может разделять упорядоченные источники данных, такие как списки, на различные разделы, подходящие для каждого отдельного потока.
Классы System.Collections.Generic не предназначены для потокобезопасности, однако их можно использовать в многопоточных приложениях. Для приложений, требующих потокобезопасных коллекций, Microsoft предоставляет пространства имен System.Collections.
Concurrent с различными классами коллекций Concurrent, такими как ConcurrentBag и ConcurrentDictionary. Также в пространство имен System.Collections.
Concurrent включены параллельные очереди и стеки, наряду с OrderablePartitioner для сегментации списков на более мелкие разделы, подходящие для обработки отдельными потоками.
Хотя классы System.Collections.Generic не предназначены для обеспечения многопоточности, их все же можно использовать в многопоточных приложениях. Для приложений, которым требуются потокобезопасные коллекции, Microsoft предлагает пространство имен System.Collections.
Concurrent с рядом классов коллекций Concurrent, таких как ConcurrentBag и ConcurrentDictionary. Кроме того, пространство имен System.Collections.
Concurrent предлагает дополнительные типы коллекций и OrderablePartitioner для разделения упорядочиваемых источников данных для упрощения обработки потоков.
Посмотрите, как распараллелить циклы
Использование System.Threading.Tasks.Parallel — один из наиболее эффективных способов обеспечить параллельное выполнение нескольких операций в больших и дорогостоящих циклах. Этот класс предоставляет замену циклам for и foreach, чтобы сделать их более эффективными и простыми.
https://www.youtube.com/watch?v=7M6rpBjh89A\u0026pp=ygVz0JDRgtC-0LzQsNGA0L3Ri9C1INC-0L_QtdGA0LDRhtC40LgsINCx0LXQt9C-0L_QsNGB0L3QvtGB0YLRjCDQv9C-0YLQvtC60L7QsiDQuCDRgdC-0YHRgtC-0Y_QvdC40LUg0LPQvtC90LrQuCDQsiBDIw%3D%3D
Большие и дорогие циклы часто являются лучшими местами для многопоточности, поскольку они обеспечивают параллельное выполнение нескольких операций, что приводит к значительному увеличению времени выполнения.
Использование System.Threading.Tasks.Parallel — наиболее эффективный способ управления этим сценарием. Этот класс предоставляет замену циклам for и foreach, которые выполняют операции цикла в отдельных потоках, что делает его простым в использовании и более эффективным. Однако используемый синтаксис немного отличается:
Чтобы гарантировать, что DoSomething() является потокобезопасным, вы должны позаботиться о том, чтобы заблокировать все общие объекты перед внесением изменений. Простой замены цикла параллельным циклом может быть недостаточно, поэтому вы должны принять дополнительные меры предосторожности, чтобы гарантировать отсутствие помех для общих переменных.
Parallel.For и Parallel.ForEach предлагают решения некоторых проблем взаимоблокировки.
Выделяя несколько итераций каждому потоку, а не создавая новые потоки для каждой итерации, эти методы уменьшают необходимость блокировки объекта при каждой отдельной итерации.
Таким образом, можно передавать промежуточную переменную, такую как промежуточный итог, и объект нужно заблокировать только один раз в конце при внесении изменений. Это значительно снижает накладные расходы, необходимые при работе с большими списками.
Мы можем взглянуть на пример, чтобы лучше представить, насколько медленным является процесс сериализации JSON. Большой список объектов можно с трудом сериализовать, запустив код и обработав каждый объект отдельно, чтобы получить список. всех объектов. Поскольку сериализация JSON выполняется медленно, имеет смысл использовать несколько потоков для ускорения процесса.
Здесь нужно рассмотреть и проанализировать множество моментов; многое предстоит распаковать.
- Первый аргумент в этом цикле ForEach — этоIEnumerableкоторый предлагает данные, через которые будет проходить цикл. Это цикл ForEach, но та же идея работает и для базовых циклов For.
- Первое действие инициализирует локальную переменную промежуточного итога. Эта переменная сохраняется на каждой итерации цикла, делая ее доступной только для потока, в котором она была инициализирована. Все остальные потоки будут содержать отдельные промежуточные итоги. В этом примере мы устанавливаем его в пустой список. Если вы подсчитываете числовую сумму, вы можете назначить 0 в качестве отправной точки.
- Второе действие — тело основного цикла. Первый аргумент — это текущий элемент ((или индекс в цикле For)), второй — объект ParallelLoopState, который можно использовать для вызова .Break(), а последний — переменная промежуточного итога.
- В этом цикле вы можете работать с элементом и изменять промежуточный итог. Возвращаемое вами значение будет использоваться в качестве следующего промежуточного итога в цикле. В этом случае мы сериализуем элемент в строку и добавляем его к промежуточному итогу, который является списком.
- После того, как все задачи выполнены, последней операцией является вычисление промежуточного «результата», и это позволяет нам заблокировать и изменить любой необходимый ресурс. Это действие произойдет только один раз в конце и все равно будет выполняться в отдельном потоке, поэтому для изменения ресурса необходимо использовать метод блокировки или блокировки. В этом примере мы будем использовать AddRange(), чтобы добавить список промежуточных итогов в окончательный список.
Несмотря на преимущество повышенной эффективности, простой замены цикла параллельным циклом может быть недостаточно для обеспечения безопасности потоков. Программист должен принимать дополнительные меры предосторожности при работе с общими переменными, чтобы гарантировать отсутствие помех.
Однако использование Parallel.For и Parallel.ForEach может помочь найти решение проблем взаимоблокировки путем выделения нескольких итераций каждому потоку вместо создания нового потока для каждой итерации. Это значительно снижает накладные расходы, необходимые при работе с большими списками.
System.Threading.Tasks.Parallel — это эффективный и действенный способ управления многопоточностью.
Несмотря на преимущества этого класса, при работе с общими переменными необходимо соблюдать особую осторожность и меры предосторожности, чтобы обеспечить потокобезопасность. К счастью, методы Parallel.For и Parallel.
ForEach предоставляют решения для некоторых проблем взаимоблокировки, которые уменьшают количество накладных расходов, необходимых при работе с большими списками.
Многопоточность единства
Многопоточность — мощный и полезный инструмент в арсенале любого разработчика программного обеспечения, особенно разработчиков Unity. Однако, как и любой другой мощный инструмент, его следует использовать с осторожностью и сдержанностью.
В качестве последней меры предосторожности всем разработчикам Unity следует проявлять осторожность при использовании многопоточности. Вызов любого вида Unity API приведет к краху игры. Альтернативным решением является выполнение операций API в основном потоке игры и осторожное использование многопоточности для распараллеливания различных задач по мере необходимости.
Вы можете без проблем выполнять математические операции Vector3 в отдельном потоке. Однако когда речь идет об операциях, зависящих от сцены или физического движка, лучше всего держать их в одном потоке. Если вы решите изменить поля или свойства ваших собственных объектов, убедитесь, что внизу нет связанной операции Unity.
Вызов любого вида Unity API приведет к краху игры. Следовательно, операции, которые зависят от сцены или физического движка, лучше ограничить основным потоком.
Если необходимы модификации полей или свойств ваших собственных объектов, следует убедиться, что ниже нет связанной операции Unity.
Альтернативным решением является выполнение операций API в основном потоке игры и осторожное использование многопоточности для распараллеливания различных задач по мере необходимости. Кроме того, математику Vector3 можно без проблем выполнять в отдельном потоке.
В заключение, важно помнить о возможностях многопоточности и проявлять осторожность при ее использовании. Риски можно снизить, ограничив использование многопоточности конкретными задачами и операциями, а также убедившись, что операции, зависящие от сцены или физического движка, хранятся в основном потоке.
В заключение, многопоточность может значительно повысить производительность приложения, но требует тщательного планирования и реализации, чтобы избежать каких-либо проблем. Используя правильные методы, можно использовать преимущества многопоточности без рисков.
ЧИТАТЬ СЛЕДУЮЩИЙ
Рекомендации по работе с потоками — .NET
Многопоточность требует тщательного программирования. Большинство задач можно упростить, поместив запросы на выполнение в очередь по потокам пулов потоков. В этом разделе рассматриваются более сложные ситуации, такие как координация работы нескольких потоков или обработка потоков, вызывающих блокировку.
Примечание
Начиная с версии .NET Framework 4, библиотека параллельных задач и PLINQ предоставляют интерфейсы API, которые несколько снижают сложность и риски многопоточного программирования. Дополнительные сведения см. в статье Параллельное программирование в .NET.
Взаимоблокировки и состояние гонки
Многопоточность позволяет решить проблемы с пропускной способностью и скоростью реагирования, но при этом возникают новые проблемы: взаимоблокировки и конфликты.
Взаимоблокировки
Взаимоблокировка происходит, когда каждый из двух потоков пытается заблокировать ресурс, уже заблокированный другим потоком. Ни один из потоков не может продолжить работу.
Многие методы классов управляемых потоков предоставляют значения времени ожидания для обнаружения взаимоблокировок. Например, следующий код пытается получить блокировку для объекта с именем lockObject. Если блокировка не будет получена в течение 300 миллисекунд, Monitor.TryEnter возвратит false.
If Monitor.TryEnter(lockObject, 300) Then
Try
' Place code protected by the Monitor here.
Finally
Monitor.Exit(lockObject)
End Try
Else
' Code to execute if the attempt times out.
End If
if (Monitor.TryEnter(lockObject, 300)) {
try {
// Place code protected by the Monitor here.
}
finally {
Monitor.Exit(lockObject);
}
}
else {
// Code to execute if the attempt times out.
}
Состояние гонки
Конфликт — это ошибка, которая возникает, когда результат программы зависит от того, какой из двух или более потоков первым достигнет определенного блока кода. Выполнение программы часто дает различные результаты, и предсказать результат выполнения конкретного запуска невозможно.
Простой пример состояния гонки — увеличение поля.
Предположим, что класс содержит закрытое поле static (Shared в Visual Basic), которое увеличивается всякий раз при создании класса с помощью кода, например objCt++; (в C#) или objCt += 1 (в Visual Basic). Для этой операции необходимо загрузить значение из objCt в регистр, увеличить или уменьшить это значение и сохранить его в objCt.
В многопоточных приложениях поток, загружающий и увеличивающий значение, может быть вытеснен другим потоком, который выполняет все три эти действия; если первый поток возобновляет выполнение и сохраняет его значение, он переопределяет objCt, не принимая во внимание тот факт, что в промежутке значение изменилось.
Конкретно этого состояния гонки можно легко избежать, применяя методы класса Interlocked, например Interlocked.Increment. Сведения о других технологиях синхронизации данных между несколькими потоками см. в разделе Синхронизация данных для многопоточности.
Конфликты могут также возникать при синхронизации действий различных потоков. При написании каждой строки кода необходимо учитывать, что может произойти, если поток будет вытеснен другим потоком до ее выполнения (или до одной из индивидуальных машинных команд, составляющих эту строку).
Статические члены и статические конструкторы
Класс не инициализируется, пока не завершится выполнение его конструктора (конструктор static в C# Shared Sub New в Visual Basic). Чтобы предотвратить выполнение кода в еще не инициализированном типе, CLR блокирует все вызовы из других потоков для членов класса static (члены Shared в Visual Basic) до тех пор, пока выполнение конструктора класса не будет завершено.
Например, если конструктор класса запускает новый поток, а процедура потока вызывает член static класса, новый поток блокируется до завершения конструктора класса.
Это относится к любому типу, который может иметь конструктор static.
Число процессоров
Наличие нескольких процессоров или только одного процессора в системе может повлиять на многопоточную архитектуру. Дополнительные сведения см. в разделе Количество процессоров.
Используйте свойство Environment.ProcessorCount, чтобы определить количество процессоров, доступных во время выполнения.
Основные рекомендации
При использовании нескольких потоков соблюдайте следующие рекомендации:
- Не используйте Thread.Abort для завершения других потоков. Вызов Abort в другом потоке сродни вызову исключения в этом потоке, не зная, какая точка достигла потока в его обработке.
- Не используйте Thread.Suspend и Thread.Resume для синхронизации действий между потоками. Используйте вместо этого Mutex, ManualResetEvent, AutoResetEvent и Monitor.
- Не контролируйте выполнение рабочих потоков из основной программы (например, с помощью событий). Вместо этого составьте программу так, чтобы рабочие потоки ожидали доступности задания, выполняли его и оповещали другие части программы о его завершении. Если рабочие потоки не блокируются, можно использовать потоки из пула потоков. Monitor.PulseAll можно использовать в ситуациях, когда рабочие потоки блокируются.
- Не используйте типы как объекты блокировки. Это означает, что следует избегать кода lock(typeof(X)) в C# или SyncLock(GetType(X)) в Visual Basic, а также использования Monitor.Enter с объектами Type. Для каждого конкретного типа существует только один экземпляр System.Type в каждом домене приложения. Если блокируемый тип является открытым, его может заблокировать чужой код, вызвав тем самым взаимоблокировку. Дополнительные вопросы см. Рекомендации по обеспечению надежности.
- Будьте внимательны при блокировке экземпляров, например lock(this) в C# или SyncLock(Me) в Visual Basic. Если другой код в приложении, который является внешним для типа, заблокирует объект, может возникнуть взаимоблокировка.
- Следите за тем, чтобы каждый поток, который входит в монитор, обязательно вышел из этого монитора, даже если за время, пока поток находится в мониторе, возникает исключение. Оператор C# lock и оператор Visual Basic SyncLock делают это автоматически, обеспечивая вызов метода Monitor.Exit с помощью блока finally. Если вы не можете проконтролировать вызов метода Exit, включите в свое приложение мьютекс. Мьютекс автоматически освобождается, как только прекращается выполнение владеющего им потока.
- Для задач, которые требуют различных ресурсов, используйте несколько потоков и старайтесь не назначать несколько потоков одному ресурсу. Например, любая задача с использованием ввода-вывода выигрывает от наличия собственного потока, поскольку во время операций ввода-вывода этот поток блокируется и, таким образом, разрешает выполнение других потоков. Входные данные пользователя — еще один ресурс, которому пойдет на пользу выделенный поток. На однопроцессорном компьютере задача, требующая активных вычислений, сосуществует с входными данными пользователя и задачами, которые предусматривают операции ввода-вывода, однако несколько ресурсоемких задач могут конкурировать друг с другом.
- Вместо оператора lock (SyncLock в Visual Basic) для простого изменения состояния лучше использовать методы класса Interlocked. Оператор lock — хороший универсальный инструмент, но класс Interlocked обеспечивает высокую производительность для обновлений, которые должны быть атомарными. Если конкуренции нет, он выполняет внутри единственный префикс lock. При проверке кода ищите код, похожий на показанный в следующих примерах. В первом примере увеличивается переменная состояния:
SyncLock lockObject
myField += 1
End SyncLocklock(lockObject)
{
myField++;
}Вы можете повысить производительность, применяя метод Increment вместо оператора lock, как показано ниже.
System.Threading.Interlocked.Increment(myField)System.Threading.Interlocked.Increment(myField);
Примечание
Используйте метод Add для атомарных приращений более 1.Во втором примере переменная ссылочного типа обновляется только в том случае, если она является пустой ссылкой (Nothing в Visual Basic).
If x Is Nothing Then
SyncLock lockObject
If x Is Nothing Then
x = y
End If
End SyncLock
End Ifif (x == null)
{
lock (lockObject)
{
x ??= y;
}
}Чтобы повысить производительность, применяйте вместо этого метод CompareExchange, как показано ниже.
System.Threading.Interlocked.CompareExchange(x, y, Nothing)System.Threading.Interlocked.CompareExchange(ref x, y, null);
Рекомендации для библиотек классов
При разработке библиотек классов для многопоточности необходимо учитывать следующие рекомендации.
- Старайтесь не создавать потребность в синхронизации. Особенно это относится к коду, который используется наиболее часто. Например, алгоритм можно скорректировать таким образом, чтобы он допускал конфликты, а не устранял их. Ненужная синхронизация снижает производительность и может привести к взаимоблокировке и конфликтам.
- Сделайте статические данные (Shared в Visual Basic) по умолчанию потокобезопасными.
- Данные экземпляров не должны быть потокобезопасными по умолчанию. Добавление блокировок для создания потокобезопасного кода снижает производительность, увеличивает конфликт блокировки и создает условия для возникновения взаимоблокировок. В обычных моделях приложений пользовательский код одновременно выполняется только одним потоком, что уменьшает необходимость потокобезопасности. По этой причине библиотеки классов .NET не являются потокобезопасными по умолчанию.
- Не предоставляйте статические методы, изменяющие статическое состояние. В обычных сценариях сервера статическое состояние используется запросами совместно, а значит, код одновременно могут выполнять сразу несколько потоков. Это открывает возможность для появления потоковых ошибок. Попробуйте применить конструктивный шаблон, инкапсулирующий данные в экземпляры, которые не являются общими для запросов. Кроме того, если статические данные синхронизируются, вызовы между статическими методами, изменяющие состояние, могут приводить к взаимоблокировкам или избыточной синхронизации, что, в свою очередь, снижает производительность.
См. также
- Работа с потоками
- Потоки и работа с потоками
Атомарные операции в C#
Атомарные операции в C#
Атомарные операции — это операции, которые выполняются «атомарно», то есть одной не делимой операцией, без возможности вмешательства другой операции. В C# вы можете использовать атомарные операции для синхронизации доступа к разделяемым ресурсам.
Когда несколько потоков используют общие ресурсы, это может привести к соревнованию или состоянию гонки (race condition). Состояние гонки возникает, когда результат операции зависит от порядка выполнения операций в разных потоках. Это может привести к неожиданным или непредсказуемым результатам, особенно в многопоточной среде.
Чтобы предотвратить состояние гонки, в C# есть несколько атомарных операций для выполнения простых операций целочисленных типов данных в одной не делимой операции. Эти операции гарантированно выполняются целиком, без возможности прерывания или вмешательства другой операции.
— Interlocked.Increment(): увеличить значение переменной на 1
— Interlocked.Decrement(): уменьшить значение переменной на 1
— Interlocked.
Exchange(): заменить значение переменной на новое значение и вернуть старое значение
— Interlocked.
CompareExchange(): сравнить значение переменной с ожидаемым значением, и если оно равно, заменить его на новое значение и вернуть true, в противном случае вернуть false
— Interlocked.Add(): добавить число к значению переменной и вернуть новое значение
Представим, что несколько потоков должны выполнять инкрементацию (увеличение на 1) одной и той же переменной. Если они просто использовали бы операцию инкрементации (++), возникло бы состояние гонки, потому что несколько потоков попытаются одновременно изменить значение переменной, что приведет к неопределенному результату.
Вместо этого мы можем использовать Interlocked.Increment() для выполнения инкрементации в атомарной операции. Это гарантирует, что каждый поток получит правильный результат, и что никакая увеличивающая операция не будет потеряна или перезаписана другим потоком.
Пример:
using System.Threading;
class Program {
static int count = 0;
static void Main() {
for (int i = 0; i < 10; i++) {
Thread t = new Thread(IncrementCount);
t.Start();
}
Console.ReadLine();
}
static void IncrementCount() {
Interlocked.Increment(ref count);
Console.WriteLine("Count: {0}", count);
}
}
Этот пример создает 10 потоков, каждый из которых вызывает IncrementCount, который увеличивает значение переменной count на 1 и выводит ее текущее значение в консоль. Заметьте, что мы используем ref для передачи ссылки на переменную (чтобы изменения были применены к переменной, а не только к ее копии).
Примеры использования атомарных операций:
— Счетчик (counter): используйте Interlocked.Increment() для увеличения значения на 1 при каждом вызове
— Сумматор (accumulator): используйте Interlocked.Add() для добавления значения к переменной
Атомарные операции могут быть одним из наиболее эффективных способов решения проблем с состоянием гонки в многопоточной среде. Их использование может улучшить производительность ваших приложений и повысить их надежность.
Однако не забывайте про другие средства синхронизации, такие как блокировки (locks), мониторы (monitors), семафоры (semaphores) и другие, которые могут быть полезны в других сценариях и ситуациях.
Использование атомарных операций — это только часть картины многопоточности и параллелизма, но понимание их принципов очень важно для управления и синхронизации многопоточных процессов.