|
"...Изучение и анализ примеров.
В и приведены описания и приложены исходные коды параллельных программ..." Непонятно что такое - "В и приведены описания" и где именно приведены и приложены исходные коды. |
Примеры программирования с использованием библиотеки PFX
Семинарское занятие № 9. Рекурсия и параллелизм (часть 3)
Одной из целей при проектировании библиотеки Parallel FX для .NET Framework было облегчить реализацию рекурсивных параллельных операций и обеспечить для них максимально возможную эффективность.
В частности, используя средства PLINQ, обход и обработка вершин дерева записывается в виде нескольких строк кода. Используя итератор класса Tree<T>, который был реализован в примере семинара 8, метод Process запишется следующим образом:
public static void Process<T> (Tree<T> tree, Action<T> action)
{
if (tree == null) return;
GetNodes(tree).AsParallel().ForAll(action);
}
Пример
9.1.
Внутренние действия, производимые библиотекой PFX при реализации данного PLINQ-фрагмента, очень похожи на действия из примера семинара, в котором создаются несколько потоков, которые выбирают вершины для обработки с помощью конструкции перечисления, используя механизм запирания ( lock ). Однако, в PLINQ такой подход реализован более эффективно путем увеличения количества вершин, извлекаемых из перечисления за один раз, что минимизирует использование конструкции lock (см. Задачу 7).
На самом деле, как и в ранее приведенных реализациях, в пример 9.1 осуществляется последовательный проход по дереву с запуском действий обработки ( actions ) в асинхронном режиме. Чтобы реализовать параллельный проход по дереву, когда различные поддеревья обрабатываются различными потоками, можно воспользоваться библиотекой TPL ( Task Parallel Library ):
public static void Process<T> (Tree<T> tree, Action<T> action)
{
if (tree == null) return;
Parallel.Invoke(
() => action(tree.Data),
() => Process(tree.Left, action),
() => Process(tree.Right, action));
}
Пример
9.2.
Оператор Parallel.Invoke потенциально запускает параллельное исполнение всех трех операторов из данного примера, заканчивая свою работу только по завершении их всех. Отличие от аналогичного примера, использующего класс ThreadPool, состоит в том, что здесь не может наступить состояние дедлока ввиду нехватки потоков для исполнения, поскольку реализация TPL по максимуму использует текущий (главный) поток для выполнения в нем обработки, которая, при наличии свободных потоков, исполнялась бы ими.
Если необходимо более гибкое управление параллельным исполнением отдельных фрагментов кода, то они могут быть оформлены в виде задач:
public static void Process<T> (Tree<T> tree, Action<T> action)
{
if (tree == null) return;
var t1 = Task.Create(
delegate { Process(tree.Left, action); });
var t2 = Task.Create(
delegate { Process(tree.Right, action); });
action(tree.Data);
Task.WaitAll(new Task[] { t1, t2 });
}
Пример
9.3.
Аналогичные методы могут быть применены к реализации алгоритма быстрой сортировки:
public static void Quicksort<T> (T[] arr, int left, int right)
where T : IComparable<T>
{
if (right > left)
{
int pivot = Partition(arr, left, right);
Parallel.Invoke(
() => Quicksort(arr, left, pivot - 1),
() => Quicksort(arr, pivot + 1, right));
}
}
Пример
9.4.
а также к реализации алгоритма сортировки слиянием:
public static void Mergesort<T> (
T[] arr, int left, int right) where T : IComparable<T>
{
if (right > left)
{
int mid = (right + left) / 2;
Parallel.Invoke(
() => Mergesort(arr, left, mid),
() => Mergesort(arr, mid + 1, right));
Merge(arr, left, mid + 1, right);
}
}
Пример
9.5.
Хотя параллельные версии этих алгоритмов мы получили очень просто, однако, этот код, по-прежнему, имеет проблемы. Реализация конструкции Parallel.Invoke построена на создании задач (см. пример 9.3) для отдельных операций и ожидании завершения работы этих задач. Если вычислительная сложность отдельных операций мала, то накладные расходы на создание задач и ожидание завершения их работы будут велики по сравнению с основными вычислительными операциями. Это может приводить к тому, что параллельная реализация будет работать медленнее, чем последовательная.
Для того, чтобы решить эту проблему, необходимо немного усложнить код, применив широко известный механизм на базе использования порога ( threshold ). Идея применения порога состоит в том, что мы вводим параллелизм в исполнение программы до тех пор, пока не будут загружены достаточной работой все ядра (процессоры), после чего происходит переключение на последовательное исполнение работ в рамках запущенных параллельных потоков, что позволяет избежать дополнительных накладных расходов. Например, в случае обхода дерева, используя понятие глубины вершины дерева в качестве порога, метод Process может выглядеть следующим образом:
private static void Process<T> (
Tree<T> tree, Action<T> action, int depth)
{
if (tree == null) return;
if (depth > 5)
{
action(tree.Data);
Process(tree.Left, action, depth + 1);
Process(tree.Right, action, depth + 1);
}
else
{
Parallel.Invoke(
() => action(tree.Data),
() => Process(tree.Left, action, depth + 1),
() => Process(tree.Right, action, depth + 1));
}
}
Пример
9.6.
В пример 9.6 присутствуют одновременно последовательная и параллельная реализации обхода дерева, а переключение с одной реализации на другую происходит в зависимости от глубины вершины, которая обрабатывается в текущий момент. Следует, однако, заметить, что сама по себе глубина не всегда может служить эффективным порогом - хорошей здесь иллюстрацией являются несбалансированные деревья. Тем не менее, использование порогов может существенно повысить эффективность реализации параллельных алгоритмов сортировки (см. Задачу 9).
Задача 10.
Реализуйте алгоритмы Quicksort и Mergesort с помощью конструкции Parallel.Invoke, используя механизм порогов.