Поиск по сайту:

Как безопасно и эффективно использовать многопоточность в .NET


Многопоточность может использоваться для значительного повышения производительности вашего приложения, но никакое ускорение не является бесплатным — управление параллельными потоками требует тщательного программирования, и без надлежащих мер предосторожности вы можете столкнуться с условиями гонки, взаимоблокировками и даже сбоями.

Что затрудняет многопоточность?

Если вы не укажете своей программе иное, весь ваш код будет выполняться в «основном потоке». С точки входа вашего приложения оно проходит и выполняет все ваши функции одну за другой. Это имеет предел производительности, поскольку, очевидно, вы можете сделать так много, только если вам нужно обрабатывать все по одному. Большинство современных процессоров имеют шесть или более ядер с 12 или более потоками, поэтому производительность остается на столе, если вы их не используете.

Однако это не так просто, как просто «включить многопоточность». Только определенные вещи (например, циклы) могут быть правильно многопоточными, и при этом необходимо учитывать множество соображений.

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

Это может быть очень, очень просто — например, вам нужно вести текущий подсчет чего-то между циклами. Самый очевидный способ сделать это — создать переменную и увеличить ее, но это не потокобезопасно.

Это состояние гонки возникает из-за того, что это не просто «добавление единицы к переменной» в абстрактном смысле; ЦП загружает значение number в регистр, добавляет единицу к этому значению, а затем сохраняет результат как новое значение переменной. Он не знает, что тем временем другой поток также пытался сделать то же самое и загрузил скоро должно быть неверное значение number. Два потока конфликтуют, и в конце цикла number может быть не равно 100.

В .NET есть функция, помогающая справиться с этим: ключевое слово lock. Это не препятствует прямому внесению изменений, но помогает управлять параллелизмом, позволяя одновременно получать блокировку только одному потоку. Если другой поток пытается ввести оператор блокировки во время обработки другого потока, он будет ждать до 300 мс, прежде чем продолжить.

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

Однако вы можете заметить, что появилась еще одна проблема: взаимоблокировки. Этот код — наихудший пример, но здесь это почти то же самое, что и обычный цикл for (на самом деле немного медленнее, поскольку дополнительные потоки и блокировки несут дополнительные накладные расходы). Каждый поток пытается получить блокировку, но только один поток может иметь блокировку, поэтому только один поток за раз может фактически запустить код внутри блокировки. В данном случае это весь код цикла, поэтому оператор блокировки лишает всех преимуществ многопоточности и просто замедляет работу.

Как правило, вы хотите блокировать по мере необходимости всякий раз, когда вам нужно делать записи. Однако вы должны помнить о параллелизме при выборе того, что блокировать, потому что чтение также не всегда является потокобезопасным. Если другой поток выполняет запись в объект, чтение из другого потока может дать неверное значение или привести к тому, что определенное условие вернет неверный результат.

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

Используйте Interlocked для атомарных операций

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

Interlocked – это класс, обертывающий некоторые операции с памятью, такие как сложение, замена и сравнение. Базовые методы реализованы на уровне ЦП и гарантированно являются атомарными и намного быстрее, чем стандартная инструкция lock. Вы захотите использовать их всякий раз, когда это возможно, хотя они не заменят полностью блокировку.

В приведенном выше примере замена блокировки вызовом 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.