Параллельное программирование для многоядерных процессоров

Конструкция Parallel.Invoke

Разбить на страницы
Показывать лекцию целиком

В Лекции 2 были рассмотрены параллельные реализации циклов For и ForEach. Еще один способ распараллеливания, поддерживаемый классом Parallel - это метод Parallel.Invoke.

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

Подобные ситуации часто возникают в рекурсивных алгоритмах и алгоритмах типа "разделяй и властвуй". Рассмотрим, например, обход бинарного дерева:

class Tree<T>
{
public T Data;
public Tree<T> Left, Right;
…
}

На C# обход дерева в последовательной реализации может выглядеть следующим образом:

static void WalkTree<T>(Tree<T> tree, Action <T> func)
{
  if (tree == null) return;
  WalkTree(tree.Left, func);
  WalkTree(tree.Right, func);
  func(tree.Data);
}

Заметим, что в последовательной реализации, имеется группа операторов, выполняющих обход обоих ветвей дерева и работающая с текущим узлом. Если наша цель - выполнить это действие для каждого узла в дереве и если порядок, в котором эти узлы будут обслужены неважен, то мы можем распараллелить исполнение группы таких операторов, используя Parallel.Invoke. Параллельная реализация выглядит следующим образом:

static void WalkTree<T>(Tree<T> tree, Action <T> func)
{
  if (tree = null) return;
Parallel.Invoke(
    () => WalkTree(tree.Left, func);
    () => WalkTree(tree.Right, func);
    () => func(tree.Data));
}

Только что показанный способ распараллеливания применим и к другим алгоритмам типа "разделяй и властвуй". Рассмотрим последовательную реализацию алгоритма быстрой сортировки:

static void SeqQuickSort<T>(T[] domain, int left, int right)
where T : IComparable<T>
{
  if (right - left + 1 <= INSERTION_TRESHOLD)
{
  InsertionSort(domain, left, right);
}
else
{
  int pivot = Partition(domain, left, right);
  SeqQuickSort(domain, left, pivot - 1);
SeqQuickSort(domain, pivot + 1, right);
}
}

Также как в предыдущем примере, распараллеливание может быть выполнено посредством метода Parallel.Invoke:

static void ParQuickSort<T> (T[] domain, int left, int right)
where T : IComparable<T>
{
  if (right - left + 1 <= SEQUENTIAL_TRESHOLD)
{
  SeqQuickSort (domain, left, right);
}
else
{
  int pivot = Partition(domain, left, right);
Parallel.Invoke(
    () => SeqQuickSort(domain, left, pivot - 1);
() => SeqQuickSort(domain, pivot + 1, right));
}
}

Заметим, что в последовательной реализации SeqQuickSort, если размер сортируемого сегмента массива достаточно мал, то алгоритм вырождается в алгоритм сортировки вставкой ( InsertionSort ). Для очень больших массивов алгоритм быстрой сортировки значительно эффективнее, чем простые алгоритмы сортировки (сортировка вставкой, метод пузырька, сортировка выборкой) . Будем использовать эту идею и при параллельной реализации. Массив очень большого размера, поступающий на вход, разделяется на сегменты, которые обрабатываются параллельно. Однако для массива небольшого размера дополнительные издержки на обслуживание потоков могут привести к потере производительности. Итак, если для массивов небольшого размера SeqQuickSort вырождается в InsertionSort, то в параллельном варианте ParQuickSort выраждается в SeqQuickSort. Аналогичным способом можно организовать только что рассмотренный обход бинарного дерева, чтобы уменьшить потери производительности:

static void WalkTree<T> (Tree<T> tree, Action<T> func, int depth)
{
  if (tree = null) return;
else if (depth > SEQUENTIAL_TRESHOLD)
{
    WalkTree(tree.Left, func, depth + 1);
    WalkTree(tree.Right, func, depth + 1);
    func(tree.Data);
}
else
{
Parallel.Invoke(
      () => WalkTree(tree.Left, func, depth + 1);
      () => WalkTree(tree.Right, func, depth + 1);
      () => func(tree.Data));
}
}

Семинарское занятие № 4. Рекурсия и параллелизм (часть 1)

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

Рассмотрим простую структуру данных - бинарное дерево ( Tree ):

class Tree<T>
{
    public Tree<T> Left, Right; // потомки
    public T Data; // данные для этого узла 
}

Предположим, что нам необходимо обработать каждую вершину дерева с помощью действия Action<T>, не заботясь о порядке в котором обрабатываются вершины. В последовательном, рекурсивном варианте это сделать очень легко:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;

    // Обработать текущую вершину, затем левое поддерево,
    // а потом правое
 
    action(tree.Data);
    Process(tree.Left, action);
    Process(tree.Right, action);
}

Задача 1.

Реализуйте класс Tree, представляющий дерево с произвольной степенью ветвления (т.е., не только со степенью 2). Переделайте соответственно процедуру Process.

Нерекурсивный вариант с явным использованием стека (объекта класса Stack ) может выглядеть следующим образом:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;
    var toExplore = new Stack<Tree<T> > ();

    // Начало обработки с корневой вершины
    toExplore.Push(tree);
    while (toExplore.Count > 0)
    {
        // Извлечь очередную вершину, обработать ее, поместить в 
        // стек ее потомков

        var current = toExplore.Pop();
        action(current.Data);
        if (current.Left != null) 
            toExplore.Push(current.Left);
        if (current.Right != null) 
            toExplore.Push(current.Right);
    }
}

Задача 2.

Переписать приведенный выше метод Process с использованием класса Queue вместо Stack.

Для перехода к параллельному варианту обработки вершин дерева, предположим, что само действие является независимым, вычислительно сложным (т.е., параллельная обработка вершин дерева имеет смысл) и безопасным для использования в многопоточной среде.

С использованием средств .NET Framework, имеются различные возможности для реализации такого параллельного варианта. Ниже приведена реализация, следующая оригинальному рекурсивному алгоритму и использующая класс ThreadPool:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;

    // Событие mre используется, чтобы реализовать возврат из
    // данного метода только после того, как будет закончена
      // обработка вершин-потомков

    using (var mre = new ManualResetEvent(false))
    {
        // Обработать левого потомка асинхронно

        ThreadPool.QueueUserWorkItem(delegate
        {
            Process(tree.Left, action);
            mre.Set();
        });

        // Обработать текущую вершину и правого потомка синхронно

        action(tree.Data);
        Process(tree.Right, action);

        // Ожидание окончания обработки левого потомка

        mre.WaitOne();
    }
}

Задача 3.

Показать, как аналогичный параллельный вариант можно реализовать, используя класс Thread.

Недостаток предыдущего варианта заключался в том, что обработка правого потомка задерживалась до тех пор, пока не будут обработаны данные текущей вершины. Чтобы повысить степень параллелизма метода Process, можно его модифицировать следующим образом:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;

    // Необходимо воспользоваться конструкцией события
    // для ожидания завершения обработки потомков

    using (var mre = new ManualResetEvent(false))
    {
        int count = 2;

        // Обработать левого потомка асинхронно

        ThreadPool.QueueUserWorkItem(delegate
        {
            Process(tree.Left, action);
            if (Interlocked.Decrement(ref count) == 0) 
                mre.Set();
        });

        // Обработать правого потомка асинхронно

        ThreadPool.QueueUserWorkItem(delegate
        {
            Process(tree.Right, action);
            if (Interlocked.Decrement(ref count) == 0) 
                mre.Set();
        });

        // Обработать текущую вершину синхронно

        action(tree.Data);

        // Ожидание завершения обработки потомков

        mre.WaitOne();
    }
}

Задача 4.

Переписать приведенный выше метод Process в предположении, что класс Tree определен так, как в Задаче 1. Подготовьте несколько объектов класса Tree с количеством вершин, соответственно, 100, 200, 500, 1000, и протестируйте метод Process на данных деревьях.

Страницы:

В Лекции 2 были рассмотрены параллельные реализации циклов For и ForEach. Еще один способ распараллеливания, поддерживаемый классом Parallel - это метод Parallel.Invoke.

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

Подобные ситуации часто возникают в рекурсивных алгоритмах и алгоритмах типа "разделяй и властвуй". Рассмотрим, например, обход бинарного дерева:

class Tree<T>
{
public T Data;
public Tree<T> Left, Right;
…
}

На C# обход дерева в последовательной реализации может выглядеть следующим образом:

static void WalkTree<T>(Tree<T> tree, Action <T> func)
{
  if (tree == null) return;
  WalkTree(tree.Left, func);
  WalkTree(tree.Right, func);
  func(tree.Data);
}

Заметим, что в последовательной реализации, имеется группа операторов, выполняющих обход обоих ветвей дерева и работающая с текущим узлом. Если наша цель - выполнить это действие для каждого узла в дереве и если порядок, в котором эти узлы будут обслужены неважен, то мы можем распараллелить исполнение группы таких операторов, используя Parallel.Invoke. Параллельная реализация выглядит следующим образом:

static void WalkTree<T>(Tree<T> tree, Action <T> func)
{
  if (tree = null) return;
Parallel.Invoke(
    () => WalkTree(tree.Left, func);
    () => WalkTree(tree.Right, func);
    () => func(tree.Data));
}

Только что показанный способ распараллеливания применим и к другим алгоритмам типа "разделяй и властвуй". Рассмотрим последовательную реализацию алгоритма быстрой сортировки:

static void SeqQuickSort<T>(T[] domain, int left, int right)
where T : IComparable<T>
{
  if (right - left + 1 <= INSERTION_TRESHOLD)
{
  InsertionSort(domain, left, right);
}
else
{
  int pivot = Partition(domain, left, right);
  SeqQuickSort(domain, left, pivot - 1);
SeqQuickSort(domain, pivot + 1, right);
}
}

Также как в предыдущем примере, распараллеливание может быть выполнено посредством метода Parallel.Invoke:

static void ParQuickSort<T> (T[] domain, int left, int right)
where T : IComparable<T>
{
  if (right - left + 1 <= SEQUENTIAL_TRESHOLD)
{
  SeqQuickSort (domain, left, right);
}
else
{
  int pivot = Partition(domain, left, right);
Parallel.Invoke(
    () => SeqQuickSort(domain, left, pivot - 1);
() => SeqQuickSort(domain, pivot + 1, right));
}
}

Заметим, что в последовательной реализации SeqQuickSort, если размер сортируемого сегмента массива достаточно мал, то алгоритм вырождается в алгоритм сортировки вставкой ( InsertionSort ). Для очень больших массивов алгоритм быстрой сортировки значительно эффективнее, чем простые алгоритмы сортировки (сортировка вставкой, метод пузырька, сортировка выборкой) . Будем использовать эту идею и при параллельной реализации. Массив очень большого размера, поступающий на вход, разделяется на сегменты, которые обрабатываются параллельно. Однако для массива небольшого размера дополнительные издержки на обслуживание потоков могут привести к потере производительности. Итак, если для массивов небольшого размера SeqQuickSort вырождается в InsertionSort, то в параллельном варианте ParQuickSort выраждается в SeqQuickSort. Аналогичным способом можно организовать только что рассмотренный обход бинарного дерева, чтобы уменьшить потери производительности:

static void WalkTree<T> (Tree<T> tree, Action<T> func, int depth)
{
  if (tree = null) return;
else if (depth > SEQUENTIAL_TRESHOLD)
{
    WalkTree(tree.Left, func, depth + 1);
    WalkTree(tree.Right, func, depth + 1);
    func(tree.Data);
}
else
{
Parallel.Invoke(
      () => WalkTree(tree.Left, func, depth + 1);
      () => WalkTree(tree.Right, func, depth + 1);
      () => func(tree.Data));
}
}

Семинарское занятие № 4. Рекурсия и параллелизм (часть 1)

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

Рассмотрим простую структуру данных - бинарное дерево ( Tree ):

class Tree<T>
{
    public Tree<T> Left, Right; // потомки
    public T Data; // данные для этого узла 
}

Предположим, что нам необходимо обработать каждую вершину дерева с помощью действия Action<T>, не заботясь о порядке в котором обрабатываются вершины. В последовательном, рекурсивном варианте это сделать очень легко:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;

    // Обработать текущую вершину, затем левое поддерево,
    // а потом правое
 
    action(tree.Data);
    Process(tree.Left, action);
    Process(tree.Right, action);
}

Задача 1.

Реализуйте класс Tree, представляющий дерево с произвольной степенью ветвления (т.е., не только со степенью 2). Переделайте соответственно процедуру Process.

Нерекурсивный вариант с явным использованием стека (объекта класса Stack ) может выглядеть следующим образом:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;
    var toExplore = new Stack<Tree<T> > ();

    // Начало обработки с корневой вершины
    toExplore.Push(tree);
    while (toExplore.Count > 0)
    {
        // Извлечь очередную вершину, обработать ее, поместить в 
        // стек ее потомков

        var current = toExplore.Pop();
        action(current.Data);
        if (current.Left != null) 
            toExplore.Push(current.Left);
        if (current.Right != null) 
            toExplore.Push(current.Right);
    }
}

Задача 2.

Переписать приведенный выше метод Process с использованием класса Queue вместо Stack.

Для перехода к параллельному варианту обработки вершин дерева, предположим, что само действие является независимым, вычислительно сложным (т.е., параллельная обработка вершин дерева имеет смысл) и безопасным для использования в многопоточной среде.

С использованием средств .NET Framework, имеются различные возможности для реализации такого параллельного варианта. Ниже приведена реализация, следующая оригинальному рекурсивному алгоритму и использующая класс ThreadPool:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;

    // Событие mre используется, чтобы реализовать возврат из
    // данного метода только после того, как будет закончена
      // обработка вершин-потомков

    using (var mre = new ManualResetEvent(false))
    {
        // Обработать левого потомка асинхронно

        ThreadPool.QueueUserWorkItem(delegate
        {
            Process(tree.Left, action);
            mre.Set();
        });

        // Обработать текущую вершину и правого потомка синхронно

        action(tree.Data);
        Process(tree.Right, action);

        // Ожидание окончания обработки левого потомка

        mre.WaitOne();
    }
}

Задача 3.

Показать, как аналогичный параллельный вариант можно реализовать, используя класс Thread.

Недостаток предыдущего варианта заключался в том, что обработка правого потомка задерживалась до тех пор, пока не будут обработаны данные текущей вершины. Чтобы повысить степень параллелизма метода Process, можно его модифицировать следующим образом:

public static void Process<T> (Tree<T> tree, Action<T> action)
{
    if (tree == null) return;

    // Необходимо воспользоваться конструкцией события
    // для ожидания завершения обработки потомков

    using (var mre = new ManualResetEvent(false))
    {
        int count = 2;

        // Обработать левого потомка асинхронно

        ThreadPool.QueueUserWorkItem(delegate
        {
            Process(tree.Left, action);
            if (Interlocked.Decrement(ref count) == 0) 
                mre.Set();
        });

        // Обработать правого потомка асинхронно

        ThreadPool.QueueUserWorkItem(delegate
        {
            Process(tree.Right, action);
            if (Interlocked.Decrement(ref count) == 0) 
                mre.Set();
        });

        // Обработать текущую вершину синхронно

        action(tree.Data);

        // Ожидание завершения обработки потомков

        mre.WaitOne();
    }
}

Задача 4.

Переписать приведенный выше метод Process в предположении, что класс Tree определен так, как в Задаче 1. Подготовьте несколько объектов класса Tree с количеством вершин, соответственно, 100, 200, 500, 1000, и протестируйте метод Process на данных деревьях.

Вернуться к учебному плану