Типовые модели параллельных приложений
При инициализации объекта указывается число участников барьерной синхронизации и делегат, вызываемый в конце каждого этапа.
Barrier bar = new Barrier(3, (bar) => { Console.WriteLine("Phase: {0}", bar.CurrentPhaseNumber); }); Action worker = () => { Work1(); bar.SignalAndWait(); Work2(); bar.SignalAndWait(); Work3(); }; var w1 = worker; var w2 = worker; var w3 = worker; Parallel.Invoke(w1, w2, w3);
Метод SignalAndWait сигнализирует о завершении работы данным участником и блокирует поток до завершения работы всех участников. Объект Barrier позволяет изменять число участников в процессе работы.
Рекурсивные алгоритмы относятся к моделям делегирования. В качестве примера рассмотрим алгоритм быстрой сортировки. Алгоритм рекурсивно разбивает диапазон чисел на два диапазона в соответствии с выбранным ведущим элементом – левый диапазон содержит только числа, меньшие или равные ведущему элементу; правый диапазон содержит числа, большие ведущего элемента. Распараллеливание алгоритма сводится к одновременному выполнению обработки левого и правого диапазона.
static void ParallelSort(T[] data, int startIndex, int endIndex, IComparer<T> comparer, int minBlockSize=10000) { if (startIndex < endIndex) { // мало элементов – выполняем последовательную сортировку if (endIndex - startIndex < minBlockSize) { // Последовательная сортировка Array.Sort(data, startIndex, endIndex - startIndex + 1, comparer); } else { // Определяем ведущий элемент int pivotIndex = partitionBlock(data, startIndex, endIndex, comparer); // обрабатываем левую и правую часть Action leftTask = () => { ParallelSort(data, startIndex, pivotIndex - 1, comparer, depth + 1, maxDepth, minBlockSize); }); Action rightTask = () => { ParallelSort(data, pivotIndex + 1, endIndex, comparer, depth + 1, maxDepth, minBlockSize); }); // wait for the tasks to complete Parallel.Invoke(leftTask, rightTask); } } } // Осуществляем перераспределение элементов static int partitionBlock(T[] data, int startIndex, int endIndex, IComparer<T> comparer) { // Ведущий элемент T pivot = data[startIndex]; // Перемещаем ведущий элемент в конец массива swapValues(data, startIndex, endIndex); // индекс ведущего элемента int storeIndex = startIndex; // цикл по всем элементам массива for (int i = startIndex; i < endIndex; i++) { // ищем элементы меньшие или равные ведущему if (comparer.Compare(data[i], pivot) <= 0) { // перемещаем элемент и увеличиваем индекс swapValues(data, i, storeIndex); storeIndex++; } } swapValues(data, storeIndex, endIndex); return storeIndex; } // Обмен элементов static void swapValues(T[] data, int firstIndex, int secondIndex) { T holder = data[firstIndex]; data[firstIndex] = data[secondIndex]; data[secondIndex] = holder; } static void Main(string[] args) { // generate some random source data Random rnd = new Random(); int[] sourceData = new int[5000000]; for (int i = 0; i < sourceData.Length; i++) { sourceData[i] = rnd.Next(1, 100); } QuickSort(sourceData, new IntComparer()); }
Основная проблема рекурсивных алгоритмов заключается в снижении эффективности при большой глубине рекурсии. Для ограничения рекурсивного разбиения множества данных применяется пороговая величина MinBlock. Если число элементов в блоке незначительно, то выполняется нерекурсивная сортировка (пузырьковая или сортировка со вставками). Распараллеливание быстрой сортировки приводит к еще одному источнику накладных расходов – рекурсивное порождение задач, конкурирующих за рабочие потоки пула. При использовании пользовательских потоков (работа с объектами Thread) конкуренция будет фатальной – рекурсивное порождение потоков приводит к значительным накладным расходам. Для контроля степени параллелизма применяют несколько подходов. Самый простой способ заключается в контроле глубины рекурсии – если глубина рекурсии превышает некий порог, то выполняется последовательная быстрая сортировка.
static void ParallelSort(T[] data, int startIndex, int endIndex, IComparer<T> comparer, int minBlockSize=10000, int depth = 0, int MaxDepth) { // Последовательная сортировка if (endIndex - startIndex < minBlockSize) InsertionSort(data, startIndex, endIndex, comparer); else { // Определяем ведущий элемент int pivotIndex = partitionBlock(data, startIndex, endIndex, comparer); // обрабатчик левой части Action leftTask = () => { ParallelSort(data, startIndex, pivotIndex - 1, comparer, depth + 1, maxDepth, minBlockSize); }); // обработчик правой части Action rightTask = () => { ParallelSort(data, pivotIndex + 1, endIndex, comparer, depth + 1, maxDepth, minBlockSize); }); if(depth >= MaxDepth) { leftTask(); rightTask(); } else { Parallel.Invoke(leftTask, rightTask); } } }
Глубина рекурсии является простым критерием, но не оптимальным. При плохом выборе ведущего элемента, блоки будут неравномерными, и глубина рекурсии для обработки каждого блока будет различной. Если правая часть содержит мало элементов, то обработка правой части будет завершена достаточно быстро. Поэтому распараллеливание обработки левой части может осуществляться и при большей глубине, чем задано параметром MaxDepth. Реализовать "адаптивный" параллелизм можно с помощью разделяемого счетчика фактических выполняющихся параллельных вызовов. При параллельном запуске быстрой сортировки - счетчик увеличивается, при завершении параллельных вызовов – счетчик уменьшается. Изменения счетчика необходимо выполнять атомарно с помощью методов Interlocked.Increment, Interlocked.Decrement.
static int parallelCalls; static void ParallelSort(T[] data, int startIndex, int endIndex, IComparer<T> comparer, int minBlockSize=10000) { // Последовательная сортировка if (endIndex - startIndex < minBlockSize) InsertionSort(data, startIndex, endIndex, comparer); else { // Определяем ведущий элемент int pivotIndex = partitionBlock(data, startIndex, endIndex, comparer); // обработчик левой части Action leftTask = () => { ParallelSort(data, startIndex, pivotIndex - 1, comparer, depth + 1, maxDepth, minBlockSize); }); // обработчик правой части Action rightTask = () => { ParallelSort(data, pivotIndex + 1, endIndex, comparer, depth + 1, maxDepth, minBlockSize); }); if (parallelCalls > MaxParallelCalls) { leftTask(); rightTask(); } else { Interlocked.Increment(ref parallelCalls); Parallel.Invoke(leftTask, rightTask); Interlocked.Decrement(ref parallelCalls); } } }
Максимальное число параллельных вызовов MaxParallelCalls можно выбирать в зависимости от числа ядер вычислительной системы:
MaxParallelCalls = System.Environment.ProcessorCount / 2;
Таким образом, для двуядерной системы разрешается один параллельный вызов двух методов.