Урок 2. Параллельный цикл for C#
Во второй части учебника по параллельному программированию в .NET рассматривается цикл for. Он позволяет выполнять определенное количество итераций цикла параллельно с декомпозицией данных, автоматически обрабатываемой библиотекой параллельных задач. Также на этом уроке поговорим о подводных камнях параллельного программирования.
Параллельный цикл
Библиотека Task Parallel Library (TPL) включает в себя две команды цикла, являющиеся параллельными версиями структур for и foreach в C# . Каждая из них предоставляет код, необходимый для шаблона параллельного цикла, гарантируя, что весь процесс будет завершен со всеми итерациями, выполненными перед переходом к оператору, следующему за циклом. Отдельные итерации разбиваются на группы, которые могут быть разделены между доступными процессорами, повышая производительность на машинах с несколькими ядрами.
for
В этой статье мы рассмотрим параллельный цикл for. Он обеспечивает некоторые функциональные возможности базового цикла for, позволяя создавать цикл с фиксированным числом итераций. Если доступно несколько ядер, итерации можно разложить на группы, которые выполняются параллельно. Чтобы продемонстрировать это, создайте новое консольное приложение, добавьте следующую директиву using в созданный класс:
using System.Threading.Tasks;Для начала мы можем создать последовательный цикл. В приведенном ниже коде цикл повторяется десять раз, причем управляющая переменная цикла увеличивается с нуля до девяти. В каждой итерации вызывается метод GetTotal. Он выполняет вычисление,необходимое для создания достаточно длинной паузы, чтобы увидеть улучшение производительности параллельной версии.
При запуске программы она выводит номер итерации из переменной управления циклом и результат вычисления. Примечание: вы можете настроить длину цикла в методе GetTotal, чтобы добиться полезной паузы между итерациями.
static void Main() { for (int i = 0; i < 10; i++) { long total = GetTotal(); Console.WriteLine("{0} - {1}", i, total); } } static long GetTotal() { long total = 0; for (int i = 1; i < 1000000000; i++) // Отрегулируйте этот цикл в соответствии { // со скоростью вашего компьютера total += i; } return total; } /* Вывод 0 - 499999999500000000 1 - 499999999500000000 2 - 499999999500000000 3 - 499999995000000000 4 - 499999995000000000 5 - 499999995000000000 6 - 499999995000000000 7 - 499999995000000000 8 - 499999995000000000 9 - 499999995000000000 */Чтобы преобразовать вышеупомянутый цикл в параллельную версию, мы можем использовать метод Parallel.For . Синтаксис отличается тем , что он предоставляется статическим методом, а не ключевым словом C#. Интересующий нас вариант метода имеет три параметра. Первые два аргумента определяют нижнюю и верхнюю границы цикла, причем верхняя граница является исключительной. Третий параметр принимает делегат действия, обычно выраженный в виде лямбда-выражения, которое содержит код для выполнения во время каждой итерации.
Параллельный синтаксис для предыдущего цикла показан ниже. Когда вы запускаете приведенный выше код на компьютере с несколькими ядрами, вы должны увидеть значительное улучшение производительности. В одноядерной, однопроцессорной системе производительность будет незначительно ниже, чем в эквивалентном последовательном цикле.
Parallel.For(0, 10, i => { long total = GetTotal(); Console.WriteLine("{0} - {1}", i, total); }); /* Вывод 5 - 499999995000000000 1 - 499999995000000000 6 - 499999995000000000 0 - 499999995000000000 2 - 499999995000000000 7 - 499999995000000000 4 - 499999995000000000 3 - 499999995000000000 8 - 499999995000000000 9 - 499999995000000000 */Важно отметить, что выходные данные для параллельной версии отличаются от выходных данных ее последовательного аналога. Результаты, приведенные в комментариях выше, были достигнуты с использованием двухъядерного процессора. В этом случае итерация '5' завершилась первой, а то, что было бы первой итерацией в последовательной версии, фактически выполнялось четвертой. Это изменение порядка цикла почти всегда происходит при параллельном выполнении и может вызвать проблемы, если они непредвиденны.
Подводные камни
Хотя заменить последовательный цикл параллельной версией несложно, вы должны быть осторожны при этом. Существуют различные ловушки, с которыми можно столкнуться. Некоторые из них вызывают сразу же очевидные ошибки в вашем коде. Некоторые вызывают тонкие ошибки, которые могут возникать лишь изредка и которые трудно найти. Другие просто снижают производительность параллельных циклов.
Некоторые из подводных камней описаны в оставшейся части этой статьи. Далее в учебнике мы увидим некоторые способы, с помощью которых эти проблемы могут быть устранены.
Общее состояние
Параллельные циклы идеальны, когда отдельные итерации независимы. Когда итерации разделяют изменчивое состояние, синхронизация необходима, чтобы гарантировать, что ошибки не будут введены параллельными процессами, использующими несогласованные значения. Это обычно требует введения механизмов блокировки, которые замедляют производительность программного обеспечения или изменения алгоритмов для удаления общего состояния.
Следующий параллельный цикл прост, но показывает проблему общего изменчивого состояния. Здесь у нас есть переменная "count", которую мы хотим увеличить в тысячу раз. При последовательном цикле это произойдет, и конечное значение счета будет равно 1000. В параллельной версии существует небольшая пауза между считыванием переменной count и сохранением обновленной версии обратно в счетчик. Это позволяет параллельной итерации считывать несогласованный счетчик и вызывать неправильные результаты.
int count = 0; Parallel.For(0, 1000, i => { int temp = count; Thread.Sleep(1); count = temp + 1; }); Console.WriteLine(count); // not 1000Зависимые итерации
При использовании последовательных циклов можно предположить, что все предыдущие итерации будут завершены до текущего выполнения. При параллельных циклах, как видно из первого примера, порядок обычно меняется. Это означает, что вы не должны иметь код внутри параллельного цикла, который зависит от результата другой итерации.
В качестве примера рассмотрим следующий код. Это попытка создать последовательность Фибоначчи, где каждое значение в ряду является суммой предыдущих двух чисел. Однако, когда цикл выполняется параллельно, более ранние результаты не всегда доступны, поэтому более поздние значения в результирующем массиве неверны.
int[] fibonnacci = new int[10]; fibonnacci[0] = 0; fibonnacci[1] = 1; Parallel.For(2, 10, i => { fibonnacci[i] = fibonnacci[i - 1] + fibonnacci[i - 2]; Thread.Sleep(1); }); for (int i = 0; i < 10; i++) { Console.Write("{0} ", fibonnacci[i]); } // Outputs "0 1 1 2 3 5 0 0 0 0 "Предположение о параллелизме
Необычной проблемой, но тем не менее важной, является неверное предположение, что цикл всегда будет выполняться параллельно. На одноядерных процессорах параллельные циклы обычно выполняются последовательно. Даже в многопроцессорных системах цикл может выполняться последовательно. Если ваш код требует, чтобы более поздняя итерация завершилась, прежде чем можно будет продолжить более раннюю, цикл будет заблокирован.
Вызовы не потокобезопасных методов
Распространенной ошибкой в параллельном цикле является вызов методов, которые не являются потокобезопасными. Если вы вызываете свои собственные методы из цикла, убедитесь, что они потокобезопасны. При использовании стандартных вызовов из .NET framework следует проверить документацию MSDN, чтобы убедиться, что вызываемые члены являются потокобезопасными. Если вы этого не сделаете, вполне возможно, что вы введете ошибки синхронизации.
Вызовы потокобезопасных методов
Если методы, которые вы вызываете из цикла, потокобезопасны, вы не должны создавать проблемы синхронизации при их использовании. Однако, если методы используют блокировку для достижения потокобезопасности, вы можете ухудшить производительность вашего программного обеспечения, поскольку несколько ядер блокируются при выполнении параллельного цикла.
Примечание: примером потокобезопасного метода, используемого в примерах этой статьи, является Console.метод WriteLine. Этот метод обычно не следует использовать в форме параллельного цикла, но он полезен при понимании выполнения такого цикла.
Чрезмерный параллелизм
В большинстве случаев параллелизм увеличивает производительность ваших циклов. Однако можно злоупотреблять параллелизмом. Например, рассмотрим следующий код. Здесь внешний цикл имеет сто итераций, как и внутренний цикл. Поскольку оба они были построены с использованием параллельного метода.Ибо существует возможность для комбинированных циклов быть разложенными на десять тысяч отдельных операций.
int[,] grid = new int[100, 100]; Parallel.For(0, 100, i => { Parallel.For(0, 100 ,j => { grid[i,j] = i+j; }); });На современном оборудовании вряд ли найдется достаточно процессорных ядер, чтобы воспользоваться преимуществами такого уровня параллелизма, особенно для такой простой повторной операции. Вместо этого дополнительные накладные расходы на декомпозицию снизят производительность кода. В таких случаях более целесообразно использовать один параллельный цикл и один последовательный цикл, как показано ниже:
int[,] grid = new int[100, 100]; Parallel.For(0, 100, i => { for (int j = 0; j < 100; j++) { grid[i, j] = i + j; } });В приведенном выше коде параллелен только внешний цикл. Это приводит к меньшим накладным расходам, чем если бы внешний цикл был последовательным, а внутренний-параллельным. Как правило, когда существует возможность чрезмерного распараллеливания, вы должны разбить задачу на несколько более крупных задач, а не на сотни или тысячи более мелких.
Привязка к потоку
Последняя проблема, которую следует рассмотреть, - это привязка к потоку . При разработке программного обеспечения для Windows некоторые задачи должны выполняться в определенном потоке. Например, в программном обеспечении Windows Forms и Windows Presentation Foundation (WPF) обновления элементов управления пользовательского интерфейса должны происходить в потоке, создавшем элемент управления. Если вы попытаетесь внести изменения из другого потока, будет выброшено исключение.
Чтобы избежать таких проблем, вы не должны использовать параллельные циклы в данных ситуациях. Если вы программируете приложение WPF и хотите использовать параллельный цикл, убедитесь, что он выполняется в потоке, отличном от потока пользовательского интерфейса. Результаты операции могут быть синхронизированы позже и применены к элементам управления пользовательского интерфейса с помощью потока пользовательского интерфейса.
Автор этого материала - я - Пахолков Юрий. Я оказываю услуги по написанию программ на языках Java, C++, C# (а также консультирую по ним) и созданию сайтов. Работаю с сайтами на CMS OpenCart, WordPress, ModX и самописными. Кроме этого, работаю напрямую с JavaScript, PHP, CSS, HTML - то есть могу доработать ваш сайт или помочь с веб-программированием. Пишите сюда.
Отправляя сообщение я подтверждаю, что ознакомлен и согласен с политикой конфиденциальности данного сайта.