Опубликован: 15.10.2009 | Доступ: свободный | Студентов: 885 / 247 | Оценка: 4.42 / 4.20 | Длительность: 08:22:00
Специальности: Программист
Лекция 13:

Высокоуровневый язык параллельного программирования MC#

< Лекция 12 || Лекция 13: 123

Каналы и обработчики канальных сообщений.

Каналы и обработчики канальных сообщений являются средствами для организации взаимодействия параллельных распределенных процессов между собой. Синтаксически, каналы и обработчики обычно объявляются в программе с помощью специальных конструкций - связок (chords).

В общем случае, синтаксические правила определения связок в языке MC# имеют вид:

chord-declaration ::= [handler-header] [& channel-header ]* body
handler-header ::= attributes modifiers handler handler-name
                   return-type ( formal-parameters )
channel-header ::= attributes modifiers channel channel-name
                  ( formal-parameters )

Связки определяются в виде членов класса. По правилам корректного определения, каналы и обработчики не могут иметь модификатора static, а потому они всегда привязаны к некоторому объекту класса, в рамках которого они объявлены.

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

Наоборот, если к моменту прихода значения по каналу, нет вызовов обработчика, то это значение просто сохраняется во внутренней очереди канала, где, в общем случае, накапливаются все сообщения, посылаемые по данному каналу. При вызове обработчика и при наличии значений во всех каналах соответствующей связки, для обработки в теле этой связки будут выбраны первые по порядку значения из очередей каналов.

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

Вторая ключевая особенность языка MC# состоит в том, что каналы и обработчики могут передаваться в качестве аргументов методам (в том числе, async- и movable- методам) отдельно от объектов, которым они принадлежат (в этом смысле, они похожи на указатели на функции в языке С, или, в терминах языка C#, на делегатов ( delegates ) ).

Третья ключевая особенность языка MC# состоит в том, что, в распределенном режиме, при копировании каналов и обработчиков на удаленную машину (под которой понимается узел кластера или некоторая машина в Grid-сети) автономно или в составе некоторого объекта, они становятся прокси-объектами, или посредниками для оригинальных каналов и обработчиков. Такая подмена скрыта от программиста - он может использовать переданные каналы и обработчики (а, в действительности, их прокси-объекты) на удаленной машине (т.е., внутри movable-методов) также, как и оригинальные: как обычно, все действия с прокси-объектами перенаправляются Runtime-системой на исходные каналы и обработчики. В этом отношении, каналы и обработчики отличаются от обычных объектов: манипуляции над последними на удаленной машине не переносятся на исходные объекты (см. первую ключевую особенность языка MC#).

Синхронизация в языке MC#

Аналогично языку Polyphonic C#, в одной связке можно определить несколько каналов. Такого вида связки являются главным средством синхронизации параллельных (в том числе, распределенных) потоков в языке MC#:

handler equals bool()& channel c1( int x ) 
                      & channel c2( int y ) {
   if  ( x == y )
      return ( true );
   else
      return ( false );
}

Таким образом, общее правило срабатывания связки состоит в следующем: тело связки исполняется только после того, как вызваны все методы из заголовка этой связки.

При использовании связок в языке MC# нужно руководствоваться следующими правилами их корректного определения:

  1. Формальные параметры каналов и обработчиков не могут содержать модификаторов ref или out.
  2. Если в связке объявлен обработчик с типом возвращаемого значения return-type, то в теле связки должны использоваться операторы return только с выражениями, имеющими тип return-type.
  3. Все формальные параметры каналов и обработчика в связке должны иметь различные идентификаторы.
  4. Каналы и обработчики в связке не могут быть объявлены как static.

Примеры программирования на языке MC#

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

Обход двоичного дерева

Если структура данных задачи организована в виде дерева, то его обработку легко распараллелить путем обработки каждого поддерева отдельном async- (movable-) методом.

Предположим, что мы имеем следующее определение (в действительности, сбалансированного) бинарного дерева в виде класса BinTree:

class BinTree {

  public BinTree left;
  public BinTree right;

  public int value;	

  public BinTree( int depth ) {
    value = 1;
    if ( depth <= 1 ) {
     left = null;
     right = null;
    }
    else {
      left = new BinTree( depth - 1 );
      right = new BinTree( depth - 1 );
    }
  }
}

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

public class SumBinTree {
  public static void Main( String[] args ) {

    int depth = System.Convert.ToInt32( args [0] );

    SumBinTree sbt = new SumBinTree();
    BinTree btree = new BinTree( depth );
   
    sbt.Sum( btree, sbt.c );

    Console.WriteLine("Sum = " + sbt.Get() );
  }

  // Определение канала и обработчика

  handler Get int ()& channel c( int x )
  {
   return ( x ); 
  }

  // Определение async-метода

  public async Sum( BinTree btree, channel (int) c ) {

    if ( btree.left == null )  // Дерево есть лист
      c ( btree.value );
    else {
      new SumBinTree().Sum( btree.left,  c1 );
      new SumBinTree().Sum( btree.right, c2 );
      c( Get2() );
    }
  }

  // Определение связки  из двух каналов и обработчика

  handler Get2 int()& channel с1( int x ) 
                     & channel с2( int y )
  {
    return ( x + y );
  }
}

Следует также отметить, что в случае распределенного варианта этой программы, при вызове movable- метода Sum, к объекту класса BinTree, являющемуся аргументом этого метода, будут применяться процедуры сериализации/десериализации при переносе вычислений на другой компьютер. (В действительности, с точки зрения Runtime-языка MC#, поддерживающей распределенное исполнение программ, канал также является обычным объектом, к которому будут применяться процедуры сериализации/десериализации).

Вычисление частичных сумм массива

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

Рассмотрим задачу вычисления частичных сумм массива f длины n .

А именно, по заданному массиву чисел f [ 0 : n-1 ] необходимо построить массив h [ 0 : n-1 ] , такой что

h[j]=\sum_i=0^jf[i] для каждого j: 0 le; j < n

Идея параллельного решения этой задачи состоит в разбиении массива f на p сегментов, где n кратно p, с дальнейшей одновременной обработкой этих сегментов данных длины m = n div p. Таким образом, обработка каждого сегмента будет производиться movable- методом.

(Отметим, что приведенное ниже решение пригодно и для случая, когда n не кратно p. Соответствующее обобщение может рассматриваться в качестве упражнения).

Разбиение исходного массива f на p сегментов производится таким образом, что в сегмент q, где ( 0 le; q < p ) попадают элементы f [ i ] , такие что i mod p = q.

Так, например, если n = 16 и p = 4, то

0-ой сегмент составят числа f [ 0 ], f [ 4 ], f [ 8 ], f [ 12 ];

1-ый сегмент составят числа f [ 1 ], f [ 5 ], f [ 9 ], f [ 13 ]

и т.д.

Параллельный алгоритм вычисления частичных сумм будет устроен так, что q -му процессу ( movable- методу), обрабатывающему q -ый сегмент данных, достаточно будет общаться лишь с его соседями слева и справа (соответственно, 0 -му процессу - лишь с соседом справа, а последнему, (p-1) -му процессу - лишь с соседом слева) и главной программой для возврата результатов. Процесс с номером q будет вычислять все элементы h [ j ] результирующего массива, такие что j mod p = q, где 0 le; j < n.

Фрагмент главной программы, разбивающей исходный массив на сегменты и вызывающий movable- метод handleSegment, показан ниже. Здесь первым аргументом этого метода является номер сегмента, а последним - имя канала для возврата результатов.

. . .
int[] segment = new int [ m ];
BDChannel[] channels = new BDChannel [ p - 1 ];

for ( i = 0; i < p; i++ ) {
for ( j = 0; j < m; j++ )
segment [ j ] = f [ j * p + i ];

switch ( i ) {
case 0: handleSegment( i, segment, null, channels [0], result );
break;
case p-1: handleSegment(i, segment, channels [p-2], null,result);
break;
default: handleSegment( i, segment, channels [i-1], channels [i], 
result );
}
}

Объекты класса BDChannel объявляются следующим образом :

class   BDChannel   {
 handler  Receive object()
             & channel Send ( object obj )  {
   return  ( obj );
 }
}

Схема взаимодействия процессов (movable-методов) между собой и главной программой показана ниже:


После разбиения, исходный массив f приобретает вид двумерной матрицы, распределенной по p процессам:

процесс 0: a_{0,0} a_{0,1} \dots a_{0,m-1}
процесс 1: a_{1,0} a_{1,1} \dots a_{1,m-1}
\dots \dots \dots \dots \dots
процесс q: a_{q,0} a_{q,1} \dots a_{q,m-1}
\dots \dots \dots \dots \dots
процесс p-1: a_{p-1,0} a_{p-1,1} \dots a_{p-1,m-1}

Другими словами, эта матрица получена из массива f разрезанием его на p сегментов и транспонированием каждого сегмента.

Ключевая идея алгоритма отдельного процесса q состоит в заполнении локальных для него массивов h0 и h1 (оба, имеющие размерность m ) в соответствии с формулами:

h0[i]=\sum_{j=0}^{q-1}\sum_{k=0}^ia_{j,k} 0 le; i < m
h1[i]=\sum_{j=q+1}^{p-1}\sum_{k=0}^{i-1}a_{j,k} 0le;i<m

Неформально, это означает, что для процесса с номером q i -ый элемент массива h0 есть сумма всех элементов приведенной выше матрицы, которые расположены выше и слева элемента a_{q,i} (включая и элементы столбца i ).

Аналогично, i -ый элемент массива h1 есть сумма всех элементов матрицы, которые расположены ниже и слева элемента a_{q,i} (но, не включая элементов из столбца i ).

Ниже показана иллюстрация этого принципа для n = 16,  p = 4  и  q = 1,  i = 2.


После того, как вычислены массивы h0 и h1 (посредством взаимодействия с соседними процессами), процесс с номером q может вычислить элемент h[ i * p + q ] результирующего массива как

h0[i]+\sum_{j=0}^ia_{q,j}+h1[i] для всех i: 0 le; i < m

Получаемые результирующие m значений процесс q сохраняет в локальном массиве h для передачи их главной программе. Тогда общая схема movable -метода handleSegment выглядит следующим образом:

movable handleSegment(  int number, int[] segment,
     BDChannel left, BDChannel right, сhannel (int[]) result )  {
<Вычисление массива h0>
<Вычисление массива h1>
s = 0;
for  ( k = 1; k < m; k++ )  {
h [ k ] = h0 [ k ] + s + segment [ k ] + h1 [ k ];
s = s + segment [ k ];
}
h [ 0 ] = number;	// Запись номера процесса-отправителя
result( h );
}

Фрагмент программы, вычисляющий массив h0 , приведен ниже.

r= 0;
for ( k = 0; k < m; k++ )  {
if ( left == null )
t = 0;
else
t = (int)left.Receive();
if ( right != null )
right.Send( t + segment [ k ] );
h0 [ k ] = r + t;
r = r + t;
}
< Лекция 12 || Лекция 13: 123
Максим Полищук
Максим Полищук
"...Изучение и анализ примеров.
В и приведены описания и приложены исходные коды параллельных программ..."
Непонятно что такое - "В и приведены описания" и где именно приведены и приложены исходные коды.