Обзор Parallel LINQ

31

В своей основе Parallel LINQ, он же PLINQ — это версия LINQ to Objects, в которой объекты исходного перечисления обрабатываются параллельно. В этом определении заключено очень многое, так что давайте подробно разберем его, чтобы понять, что к чему.

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

Если посмотреть на исходный запрос в примере ниже можно заметить, что в нем имя каждого названия машины обрабатывается по очереди:

string[] cars = { "Nissan", "Aston Martin", "Chevrolet", "Alfa Romeo", "Chrysler", "Dodge", "BMW",
                              "Ferrari", "Audi", "Bentley", "Ford", "Lexus", "Mercedes", "Toyota", "Volvo", "Subaru", "Жигули :)"};

string auto = cars.Where(p => p.StartsWith("S")).First();
Console.WriteLine(auto);

На рисунке проиллюстрировано, как это работает:

Последовательная обработка LINQ

Механизм LINQ начинает с того, что проверяет, не начинается ли "Nissan" с "S". Затем переходит к "Aston Martin" и осуществляет проверку снова, после чего проверяет "Chevrolet", "Alfa Romeo", "Chrysler" и т.д. LINQ проходит последовательно по всем именам в массиве. Разумеется, это называется последовательным выполнением. Проблема последовательного выполнения состоит в том, что в каждый момент времени используется только одно ядро или один центральный процессор (далее будем говорить только о ядрах, но подразумеваться и то, и другое). На четырехядерных машинах три из четырех ядер не делают ничего при выполнении LINQ подобным образом.

Parallel LINQ изменяет правила игры, разбивая исходные данные на части и обрабатывая их параллельно, как показано ниже:

string[] cars = { "Nissan", "Aston Martin", "Chevrolet", "Alfa Romeo", "Chrysler", "Dodge", "BMW",
                              "Ferrari", "Audi", "Bentley", "Ford", "Lexus", "Mercedes", "Toyota", "Volvo", "Subaru", "Жигули :)"};

            string auto = cars.AsParallel()
                .Where(p => p.StartsWith("S")).First();
            Console.WriteLine(auto);
Выполнение Parallel LINQ

Названия "Nissan", "Aston Martin", "Chevrolet" и "Alfa Romeo" обрабатываются одновременно — каждый в отдельном ядре машины. После того, как каждое ядро завершит обработку имени, оно переходит к следующему, независимо от других ядер. Parallel LINQ заботится о разбиении данных, определяя, сколько элементов может быть обработано одновременно (хотя обычно принимается решение, что одного элемента данных на ядро достаточно), и координируя работу ядер таким образом, что результат получается так, как от любого другого запроса LINQ. Если скомпилировать и запустить код, получится следующий результат:

Простой пример Parallel LINQ

Так зачем вообще связываться с Parallel LINQ? Ответ прост — производительность. Взгляните на пример ниже. В нем определены два запроса LINQ, которые делают одно и то же. Один запрос последовательный, а другой использует Parallel LINQ. Оба запроса select выбирают четные числа между 0 и Int32.MaxValue и подсчитывают количество совпадений. Для генерации последовательности целочисленных значений применяются методы Enumerable.Range и ParallelEnumerable.Range. Диапазоны будут рассматриваться в одной из следующих статей, а пока просто имейте в виду, что оба метода создают IEnumerable<int>, содержащую все нужные целочисленные значения. Хоть этот пример не особенно полезен, но он позволяет понять идею:

using System.Diagnostics;
...

// Создать последовательный диапазон чисел
            IEnumerable<int> nums1 = Enumerable.Range(0, Int32.MaxValue);

            // Запустить секундомер
            Stopwatch sw = Stopwatch.StartNew();

            // Выполнить запрос LINQ
            int sum1 = (from n in nums1
                        where n % 2 == 0
                        select n).Count();

            Console.WriteLine("Результат последовательного выполнения: " + sum1 +
                "\nВремя: " + sw.ElapsedMilliseconds + " мс\n");

            // Создаем параллельный диапазон чисел
            IEnumerable<int> nums2 = ParallelEnumerable.Range(0, Int32.MaxValue);

            // Перезапускаем секундомер
            sw.Restart();

            // Выполняем параллельный запрос LINQ
            int sum2 = (from n in nums2.AsParallel()
                        where n % 2 == 0
                        select n).Count();

            Console.WriteLine("Результат параллельного выполнения: " + sum2 +
                "\nВремя: " + sw.ElapsedMilliseconds + " мс");

В результате выполнения этого кода получается вывод, который показан ниже. Для последовательного запроса LINQ на обработку всех целочисленных значений и выдачи результата понадобилось около 72 секунд. Запрос Parallel LINQ сделал то же самое за 40 секунд. Это впечатляет, учитывая, что код запросов выглядит очень похоже и, как видно из вывода, оба варианта дают одинаковый результат (и это при том, что использовалась машина с 2х ядерным процессором):

Сравнение производительности последовательного и параллельного выполнения

Для измерения времени выполнения каждого запроса используется класс Stopwatch из пространства имен System.Diagnostics. После компиляции и запуска кода были получены образы экрана диспетчера задач Windows при выполнении каждого запроса. Экран диспетчера задач Windows во время последовательного выполнения показан ниже:

Использование центрального процессора во время последовательного выполнения запроса

Как видите, использование центрального процессора составило 50%. Это двухядерная машина, а потому можно было ожидать 50% загрузки при работе одного ядра — именно так последовательные запросы и выполняются. Экран диспетчера задач Windows во время выполнения запроса Parallel LINQ приведен ниже:

Использование центрального процессора во время параллельного выполнения запроса

Как видите происходит 100% загрузка процессора. За счет применения Parallel LINQ можно с минимальными усилиями получить значительный выигрыш в производительности.

Parallel LINQ предназначен для объектов

Как уже говорилось, Parallel LINQ — это параллельная реализация API-интерфейса LINQ to Objects. Поэтому он выполняет запросы LINQ to Objects в параллельном режиме. Он не реализует параллельных средств для других видов LINQ.

Это не значит, что с помощью Parallel LINQ не удастся обработать результаты других разновидностей запросов LINQ (например, выбрать объекты Order из базы данных Northwind с использованием LINQ to Entities или LINQ to SQL, а затем применить Parallel LINQ для дальнейшей обработки результатов), но Parallel LINQ не работает ни на чем, кроме объектов.

Далеко не все запросы LINQ to Objects являются хорошими кандидатами для запросов Parallel LINQ. Есть еще накладные расходы, связанные с разбиением данных на фрагменты, установкой и управлением классами, выполняющими параллельные задачи. Если запрос не слишком долго выполняется последовательно, возможно, не имеет смысла выполнять его параллельно, т.к. накладные расходы могут свести на нет весь выигрыш в производительности.

Для использования Parallel LINQ никаких специальных шагов предпринимать не понадобится. Все ключевые классы содержатся в пространстве имен System.Linq, где также находятся и все обычные классы LINQ to Objects. Наиболее важные методы и ключевые операции PLINQ будут описаны далее.

Пройди тесты
Лучший чат для C# программистов