Опубликован: 05.03.2013 | Уровень: для всех | Доступ: платный
Лекция 10:

Типовые модели параллельных приложений

< Лекция 9 || Лекция 10: 123

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

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;
 

Таким образом, для двуядерной системы разрешается один параллельный вызов двух методов.

< Лекция 9 || Лекция 10: 123
Светлана Папшева
Светлана Папшева
Россия
Валерий Субботин
Валерий Субботин
Украина