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

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

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

Язык параллельного программирования MC# (http://www.mcsharp.net) предназначен для написания программ, работающих на всем спектре параллельных архитектур - от многоядерных процессоров до Grid-сетей. Единственное требование к таким системам со стороны MC# - на них должна быть установлена среда исполнения CLR (Common Language Runtime) с соответствующим набором библиотек. На машинах с операционной системой Windows реализацией такой среды является Microsoft .NET Framework, а на машинах с операционной системой Linux - система Mono (http://www.mono-project.com), которая является свободной реализацией платформы .NET для Unix-подобных систем.

Язык MC# является адаптацией и развитием базовых идей языка Polyphonic C# на случай параллельных и распределенных вычислений. Язык Polyphonic C# был разработан в 2002г. в Microsoft Research Laboratory (г. Кембридж, Великобритания) Н. Бентоном (N. Benton), Л. Карделли (L. Cardelli) и Ц. Фурнье (C. Fournet). Целью его создания было добавление высокоуровневых средств асинхронного параллельного программирования в язык C# для использования в серверных и клиент-серверных приложениях на базе Microsoft .NET Framework.

Ключевая особенность языка Polyphonic C# заключается в добавлении к обычным, синхронным методам, так называемых "асинхронных" методов, которые предназначены играть в (многопоточных) программах две основные роли:

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

    При этом исполнение Polyphonic C#-программ, по замыслу авторов этого языка, по-прежнему, предполагалось либо на одной машине, либо на нескольких машинах, с зафиксированными на них асинхронными методами, взаимодействующими между собой с использованием средств удаленного вызова методов (RMI - Remote Method Invocation), предоставляемых библиотекой System.Runtime.Remoting платформы .NET.

    В случае языка MC#, программист может предусмотреть исполнение автономных асинхронных методов либо локально, либо удаленно. В последнем случае, метод может быть спланирован для исполнения на другой машине, выбираемой двумя способами: либо согласно явному указанию программиста (что не является типичным случаем), либо автоматически (обычно, на наименее загруженном узле кластера или машине Grid-сети). Взаимодействие асинхронных методов, в рамках языка MC#, реализуется посредством передачи сообщений с использованием каналов и обработчиков канальных сообщений. Эти каналы и обработчики определяются в MC#-программах с помощью связок в стиле языка Polyphonic C#.

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

    Модель программирования языка MC#: async- и movable-методы, каналы, обработчики связки

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

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

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

    Async - и movable -методы являются единственным средством создания параллельных процессов (потоков) в языке MC#.

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

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

    Для синхронизации параллельных процессов в MC# используются связки (chords), определяемые в стиле языка Polyphonic C#.

    Async- и movable-методы

    Общий синтаксис определения async - и movable -методов в языке MC# следующий:

    модификаторы { async | movable } имя_метода ( аргументы )
    {
    		< тело метода >
    }

    Ключевые слова async и movable располагаются на месте типа возвращаемого значения, поэтому синтаксическое правило его задания при объявлении метода в языке MC# имеет вид:

    return-type ::= type | void | async | movable

    Задание ключевого слова async означает, что при вызове данного метода он будет запущен в виде отдельного потока локально, т.е., на данной машине (возможно, на отдельном ядре процессора), но без перемещения на другую машину. Ключевое слово movable означает, что данный метод при его вызове может быть спланирован для исполнения на другой машине.

    Отличия async- и movable-методов от обычных методов состоят в следующем:

  • вызов async- и movable-методов заканчивается, по существу, мгновенно (для последних из названных методов, время затрачивается только на передачу необходимых для вызова этого метода данных на удаленную машину),
  • эти методы никогда не возвращают результаты (о взаимодействии movable-методов между собой и с другими частями программы, см. Раздел 2.2 "Каналы и обработчики").
  • Соответственно, согласно правилам корректного определения async- и movable-методов:

  • они не могут объявляться статическими,
  • в их теле не может использоваться оператор return.
  • Вызов movable-метода имеет две синтаксические формы:

  • имя_объекта.имя_метода ( аргументы )

    (место исполнения метода выбирается Runtime-системой автоматически),

  • имя_машины@имя_объекта.имя_метода ( аргументы )

    ( имя_машины задает явным образом место исполнения данного метода).

  • При разработке распределенной программы на языке MC# (т.е., при использовании в ней movable-методов и исполнении ее на кластере или в Grid-сети), необходимо учитывать следующие особенности системы исполнения (Runtime-системы) MC#-программ.

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

    Поэтому, первой ключевой особенностью языка MC# (а точнее, его семантики) является то, что, в общем случае, во время вызова movable-метода, все необходимые данные, а именно:

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

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

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

    Каналы и обработчики канальных сообщений являются средствами для организации взаимодействия параллельных распределенных процессов между собой. Синтаксически, каналы и обработчики обычно объявляются в программе с помощью специальных конструкций - связок (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# нужно руководствоваться следующими правилами их корректного определения:

  • Формальные параметры каналов и обработчиков не могут содержать модификаторов ref или out.
  • Если в связке объявлен обработчик с типом возвращаемого значения return-type, то в теле связки должны использоваться операторы return только с выражениями, имеющими тип return-type.
  • Все формальные параметры каналов и обработчика в связке должны иметь различные идентификаторы.
  • Каналы и обработчики в связке не могут быть объявлены как 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;
    }

    Задача расстановки ферзей на шахматной доске (N-Queens)

    Хорошо известной задачей в учебниках по элементарному программированию, структурам данных и алгоритмам, является задача о расстановке на шахматной доске восьми ферзей таким образом, чтобы ни один из них не находился под боем какого-либо другого из ферзей. То, что эта задача имеет решение, было продемонстрировано Карлом Фридрихом Гауссом и Францем Науком в 1850 году. На самом деле, имеется 92 различных способа расставить указанным образом ферзей на обычной шахматной доске.

    Вычисление количества решений и всех их перечисление для данной задачи является одной из базовых проблем компьютерного программирования. Задача о 8 ферзях естественным образом обобщается до задачи об $$N $$ > ферзях, когда задается число $$N $$ - размер шахматной доски, и требуется найти все способы расстановки $$N $$ ферзей на этой доске, чтобы они попарно не атаковали друг друга.

    Для решения этой задачи предлагались различные методы - некоторые из них можно найти в известной книге Н. Вирта "Алгоритмы + Структуры Данных = Программы". Далее будут рассмотрены теоретические основы и реализация эффективного алгоритма решения задачи $$N $$ ферзей (N-Queens), эффективность которого достигается

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

    Далее, при объяснении методов решения, будет использоваться пример с 8 ферзями (т.е., доской 8 x 8).

    Прямая проверка. Предположим, что ферзь находится на $$i $$ -ой горизонтали (строке) и на $$j $$ -ой вертикали (столбце), т.е., на клетке ( $$i,j $$ ). Тогда, клетки, которые находятся под боем данного ферзя, включают в себя: все клетки $$i $$ -ой строки и все клетки $$j $$ -го столбца, а также все клетки ( $$k,l $$ ), где $$k -l = i - j $$, или $$k + l = i + j $$. Последние две группы клеток располагаются на двух диагоналях, которые находятся под боем ферзя, расположенного в клетке ( $$i,j $$ ).

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

    Эта процедура может быть представлена алгоритмически в виде последовательности проверок, при которых вначале делается попытка выставить ферзя в первой строке, затем второго ферзя - во второй строке, и т.д. Если для очередного ферзя в $$i $$ -ой строке безопасная позиция отсутствует, то необходима процедура бектрекинга.

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

    В усовершенствованном методе используются три массива column, left и >right для хранения информации о текущей конфигурации ферзей, которые состоят из 8, 15 и 15 элементов соответственно (так как $$15 = 8 + ( 8 - 1 ) $$ при $$N = 8 $$ ). Пусть column[i] равно 1, если имеется ферзь в $$i $$ -ом столбце доски, и $$0 $$ - в противном случае. Если ферзь находится в позиции ( $$i,j $$ ), то $$left [ i + j ] $$ и $$right [ 7 - j + i ] $$ будут равны 1, в противном случае - 0 (как обычно, мы предполагаем, что нумерация элементов массива начинается с 0).

    Имея такие массивы, проверка очередной клетки на возможность размещения в ней очередного ферзя, становится прямой и эффективной. Для проверки позиции ( $$i^',j^' $$ ), нам необходимо только проверить элементы column[i^'], left[i^'+j^'] и right[7-j^'+i^']. Если все три элемента массивов равны 0, то позиция ( $$i^',j^' $$ ) является безопасной для нового ферзя. Для поиска безопасной расстановки или всех возможных таких расстановок, как обычно, используются процедуры последовательного перебора и бектрекинга.

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

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

    Метод решения на основе битовых векторов

    Предположим, что $$b_1 $$ является битовым вектором для первого ряда доски, и $$j $$ -й бит в $$b_1 $$ содержит 1, представляющую размещенного в этой позиции ферзя. Тогда, ( $$j-1 $$ )-ая позиция во втором ряду атакуется (контролируется) данным ферзем, и соответствующая отметка в битовом векторе может быть получена сдвигом вектора $$b_1 $$ на один бит влево. Аналогичным образом, ( $$j+1 $$ )-ая позиция во втором ряду, которая также контролируется данным ферзем, определяется сдвигом вправо вектора $$b_1 $$ на один бит. Тогда, все контролируемые позиции во втором ряду представляются следующим битовым вектором (мы используем < < и > > как обозначения операций сдвига влево и вправо, символ | обозначает побитовое ИЛИ, 1 используется для обозначения занятой или контролируемой позиции, а 0 - для свободных позиций):

    $$gt;b_1 | ( b_1 lt; < 1 ) | (b_1 gt; gt; 1)$$

    Также легко найти позиции, контролируемые первым ферзем в третьей строке: достаточно сдвинуть вектор $$b_1$$ влево или вправо еще на один бит. Т.е.:

    $$b_1 | ( b_1 < < 2) | (b_1 > > 2)$$

    В общем случае, позиции, контролируемые первым ферзем в $$k$$ -ой строке, могут быть определены путем сдвига вектора $$b_1$$ на $$k - 1$$ битов влево и вправо и выполнением побитовой операции ИЛИ над этими тремя векторами. Ферзь в первой строке контролирует в каждой строке ниже самое большее три позиции. Отметим также, что при сдвиге биты, содержащие 1, могут выходить за пределы векторов, размер которых совпадает с размером доски. Пусть $$B_i[k] $$ обозначает вектор, представляющий контролируемые позиции в строке $$k$$ ферзем, находящимся в строке $$i ( k > i ) $$. Тогда, очевидно, имеем:

    $$B_i[k] = b_i | ( b_i < < ( k - i ) ) | (b_i > > ( k - i ) )$$

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

    Однако, существует естественный способ объединить все эти управляющие векторы в три рабочих вектора, которые будут называться left, down и right, соответственно.

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

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

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

    $$B = left | down | right$$

    На каждом шаге, новый ферзь добавляется в некоторую позицию, для которой бит в векторе $$B$$ равен 0, и все три рабочих вектора модифицируются в соответствующем бите. После этого, происходит переход к следующему шагу, при котором векторы left и right сдвигаются на 1 бит влево и вправо, соответственно. Тем самым, для каждой строки поддерживается инвариант

    контролируемые позиции = left | down | right,

    который гарантирует корректность процедуры.

    Описанный процесс поиска с бектрекингом легко реализовать в виде (последовательной) рекурсивной процедуры на языке C#:

    using System;
    public class NQueens   {
     public static int  N, MASK, COUNT;
     public static void Backtrack(int y, int left, int down, int right)
     {
         int  bitmap, bit;
         if (y == N) {
             COUNT++;
         } else {
             bitmap = MASK  ~(left | down | right);
             while (bitmap != 0) {
                 bit = -bitmap  bitmap;
                 bitmap ^= bit;
                 Backtrack(y+1, (left | bit) < <1, down | bit, (right | bit) > >1);
             }
         }
     }
     public static void Main(String[] args )
     {
         N = 10;   /*  <- N  */
         COUNT = 0;   /* result */
         MASK = (1 < < N) - 1;
         Backtrack(0, 0, 0, 0);
         Console.WriteLine ("N=" + N + " -> " + COUNT);
         return;
     }
    }

    Параллельный алгоритм решения задачи N-Queens

    Базовая идея параллельного алгоритма проста: для каждой возможной позиции ферзя в 1-ой строке ( а всего таких позиций $$N$$ для доски размером $$N x N$$ ) поиск расстановок всех остальных ферзей может проводиться независимо, а потому параллельно на нескольких процессорах. Однако, этот прямой вариант пригоден только когда $$N = P$$, где $$P$$ есть число доступных процессоров.

    Однако, прямой вариант легко обобщить путем независимого поиска решений для всех допустимых конфигураций ферзей на первых $$M ( M le; N ) $$ строках. Очевидно, что число таких конфигураций не превышает $$N^M$$, и все они могут быть сгенерированы с помощью процедуры Backtrack, приведенной выше.

    Все эти конфигурации оформляются в виде объектов класса Task, которые в качестве своих полей содержат векторы left, down и right, представляющие очередную расстановку ферзей на первых $$M$$ строках, для которой будет искаться полная расстановка.

    Таким образом, отдельные процессоры ( Worker' ы), будут брать из некоторой очереди (представляемой каналом sendTask и обработчиком getTask ) очередную задачу, решать ее и пересылать ответ главной программе при запросе очередного задания. Worker заканчивает свою работу при считывании из очереди концевого маркера (объекта класса Task со значением -1 для каждого из векторов left, down,right ).

    Полный текст программы NQueens на языке MC# представлен ниже.

    using System;
    public class Task   {
     public int left, down, right;
     public Task ( int l, int d, int r ) {
      left  = l;
      down  = d;
      right = r;
     }
    }
    //**************************************************//
    public class NQueens   {
     public static long totalCount = 0;
     public static void Main ( String[] args ) {
      int   N = System.Convert.ToInt32 ( args [ 0 ] );   //  Board size
      int   M = System.Convert.ToInt32 ( args [ 1 ] );   //  Number of fixed queens
      int   P = System.Convert.ToInt32 ( args [ 2 ] );   //  Number of workers
      NQueens nqueens = new NQueens();
      nqueens.launchWorkers ( N, M, P, nqueens.getTask, nqueens.sendStop, nqueens );
      nqueens.generateTasks ( N, M, P, nqueens.sendTask );
      for ( int i = 0; i < P; i++ )
       nqueens.getStop ? ();
      Console.Write     ("Task challenge : " + N + "   " );
      Console.WriteLine ("Solutions = " + totalCount );
     }
     //***************************************************************************//
     public handler getTask Task(int count )  channel sendTask ( Task task ) {
      totalCount += count;
      return ( task );
     }
     //***************************************************************************//
     public handler getStop void() channel sendStop () {
      return;
     }
     //***************************************************************************//
     public async launchWorkers ( int N, int M, int P, handler Task(int) getTask,
                                  channel () sendStop, NQueens nqueens            ){
      for ( int i = 0; i < P; i++ )
       nqueens.Worker ( i, N, M, getTask, sendStop );
     }
     //***************************************************************************//
     public void generateTasks ( int N, int M, int P, channel (Task) sendTask ) {
      int   y     = 0;
      int   left  = 0;
      int   down  = 0;
      int   right = 0;
      int   MASK  = ( 1 < < N ) - 1;
      MainBacktrack ( y, left, down, right, MASK, M, sendTask );
      Task finish_marker = new Task ( -1, -1, -1 );
      for ( int i = 0; i < P; i++ )
       sendTask ! ( finish_marker );
     }
     //***************************************************************************//
     public void MainBacktrack ( int y, int left, int down, int right, int MASK,
                                 int M, channel (Task) sendTask               ) {
      int   bitmap, bit;
      if ( y == M )
       sendTask ! ( new Task ( left, down, right ) );
      else   {
       bitmap = MASK  ~ ( left | down | right );
       while ( bitmap != 0 )   {
        bit   = -bitmap  bitmap;
        bitmap = bitmap ^ bit;
        MainBacktrack (y + 1, (left | bit) < <1, down | bit, ( right | bit ) > > > 1,
                        MASK, M, sendTask                                      );
       }
      }
     }
     //***************************************************************************//
     public async Worker ( int myNumber, int N, int M, handler Task(int) getTask,
                      channel () sendStop                                    ) {
      int    MASK  = ( 1 < < N ) - 1;
      int    count = 0;
      Task   task  = (Task) getTask ? ( count );
      while ( task.left != -1 )   {
       WorkerBacktrack ( M, task.left, task.down, task.right, MASK, N, ref count );
       task  = (Task) getTask ? ( count );
       count = 0;
      }
      sendStop ! ();
     }
     //***************************************************************************//
     public void WorkerBacktrack ( int y, int left, int down, int right, int MASK,
                                   int N, ref int count                           ) {
      int   bitmap, bit;
      if ( y == N )
       count++;
      else   {
       bitmap = MASK  ~ ( left | down | right );
       while ( bitmap != 0 )   {
        bit   = -bitmap  bitmap;
        bitmap = bitmap ^ bit;
        WorkerBacktrack ( y + 1, (left|bit) < < 1, down|bit, (right|bit) > > 1,
                         MASK, N, ref count                                    );
       }
      }
     }
    }

    Задачи

  • Реализуйте распределенный вариант программы NQueens, использующий movable- методы.
  • Изучите последовательный алгоритм решения задачи ), и реализуйте на его основе параллельный вариант.
  • Ознакомьтесь с формулировкой задачи "Queens and Knights" ( http://www.vector.org.uk/archive/v213/hui213.htm). Реализуйте последовательный алгоритм решения этой задачи на языке C#, а затем - параллельный вариант на языке MC#.
  • Сведения о практической реализации языка MC#

    Как обычно, для любого параллельного языка программирования, реализация MC# состоит из компилятора и рантайм-системы. Главными функциональными частями рантайм-системы являются:

  • ResourceManager - процесс, исполняющийся на центральном узле и распределяющий по узлам movable-методы.
  • WorkNode - процесс, исполняющийся на каждом из рабочих узлов и контролирующий выполнение movable-методов.
  • Communicator - процесс, исполняющийся на каждом из узлов и ответственный за принятие сообщений для объектов, расположенных на данном узле.
  • Компилятор переводит программу из MC# в C#, его главной целью является создание кода, реализующего: выполнение movable-методов на других процессорах; пересылку канальных сообщений и; синхронизацию методов, объединенных связкой. Эти функции предоставляются соответствующими методами классов рантайм-системы. Среди них:

  • класс Session - реализует вычислительную сессию.
  • класс TCP - предоставляет возможность доставки запросов на исполнение movable-методов и канальных сообщений.
  • класс Serialization - предоставляет сериализацию/десериализацию объектов, перемещаемых на другие рабочие узлы.
  • класс Channel - содержит информацию о канале.
  • класс Handler - содержит информацию об обработчике.
  • Главные функции компилятора MC#:

  • Добавление вызовов функций Init() и Finalize() класса Session в главном методе программы. Функция Init() доставляет исполняемый модуль программы на другие узлы, запускает процесс Manager, создает объекты LocalNode и другие. Функция Finalize() останавливает запущенные потоки и завершает вычислительную сессию.
  • Добавление выражений, создающих объекты типа Channel и Handler для каждого из каналов и обработчиков, описанных в программе.
  • Замена вызовов async-методов на порождение соответствующих локальных потоков.
  • Замена вызовов movable-методов на запросы менеджеру распределения ресурсов.
  • Замена канальных вызовов на пересылку соответствующих сообщений по TCP-соединению. Трансляция связок, содержащих определения каналов, производится так же, как и в языке Polyphonic C#.
  • Страницы:

    Язык параллельного программирования MC# (http://www.mcsharp.net) предназначен для написания программ, работающих на всем спектре параллельных архитектур - от многоядерных процессоров до Grid-сетей. Единственное требование к таким системам со стороны MC# - на них должна быть установлена среда исполнения CLR (Common Language Runtime) с соответствующим набором библиотек. На машинах с операционной системой Windows реализацией такой среды является Microsoft .NET Framework, а на машинах с операционной системой Linux - система Mono (http://www.mono-project.com), которая является свободной реализацией платформы .NET для Unix-подобных систем.

    Язык MC# является адаптацией и развитием базовых идей языка Polyphonic C# на случай параллельных и распределенных вычислений. Язык Polyphonic C# был разработан в 2002г. в Microsoft Research Laboratory (г. Кембридж, Великобритания) Н. Бентоном (N. Benton), Л. Карделли (L. Cardelli) и Ц. Фурнье (C. Fournet). Целью его создания было добавление высокоуровневых средств асинхронного параллельного программирования в язык C# для использования в серверных и клиент-серверных приложениях на базе Microsoft .NET Framework.

    Ключевая особенность языка Polyphonic C# заключается в добавлении к обычным, синхронным методам, так называемых "асинхронных" методов, которые предназначены играть в (многопоточных) программах две основные роли:

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

    При этом исполнение Polyphonic C#-программ, по замыслу авторов этого языка, по-прежнему, предполагалось либо на одной машине, либо на нескольких машинах, с зафиксированными на них асинхронными методами, взаимодействующими между собой с использованием средств удаленного вызова методов (RMI - Remote Method Invocation), предоставляемых библиотекой System.Runtime.Remoting платформы .NET.

    В случае языка MC#, программист может предусмотреть исполнение автономных асинхронных методов либо локально, либо удаленно. В последнем случае, метод может быть спланирован для исполнения на другой машине, выбираемой двумя способами: либо согласно явному указанию программиста (что не является типичным случаем), либо автоматически (обычно, на наименее загруженном узле кластера или машине Grid-сети). Взаимодействие асинхронных методов, в рамках языка MC#, реализуется посредством передачи сообщений с использованием каналов и обработчиков канальных сообщений. Эти каналы и обработчики определяются в MC#-программах с помощью связок в стиле языка Polyphonic C#.

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

    Модель программирования языка MC#: async- и movable-методы, каналы, обработчики связки

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

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

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

    Async - и movable -методы являются единственным средством создания параллельных процессов (потоков) в языке MC#.

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

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

    Для синхронизации параллельных процессов в MC# используются связки (chords), определяемые в стиле языка Polyphonic C#.

    Async- и movable-методы

    Общий синтаксис определения async - и movable -методов в языке MC# следующий:

    модификаторы { async | movable } имя_метода ( аргументы )
    {
    		< тело метода >
    }

    Ключевые слова async и movable располагаются на месте типа возвращаемого значения, поэтому синтаксическое правило его задания при объявлении метода в языке MC# имеет вид:

    return-type ::= type | void | async | movable

    Задание ключевого слова async означает, что при вызове данного метода он будет запущен в виде отдельного потока локально, т.е., на данной машине (возможно, на отдельном ядре процессора), но без перемещения на другую машину. Ключевое слово movable означает, что данный метод при его вызове может быть спланирован для исполнения на другой машине.

    Отличия async- и movable-методов от обычных методов состоят в следующем:

  • вызов async- и movable-методов заканчивается, по существу, мгновенно (для последних из названных методов, время затрачивается только на передачу необходимых для вызова этого метода данных на удаленную машину),
  • эти методы никогда не возвращают результаты (о взаимодействии movable-методов между собой и с другими частями программы, см. Раздел 2.2 "Каналы и обработчики").
  • Соответственно, согласно правилам корректного определения async- и movable-методов:

  • они не могут объявляться статическими,
  • в их теле не может использоваться оператор return.
  • Вызов movable-метода имеет две синтаксические формы:

  • имя_объекта.имя_метода ( аргументы )

    (место исполнения метода выбирается Runtime-системой автоматически),

  • имя_машины@имя_объекта.имя_метода ( аргументы )

    ( имя_машины задает явным образом место исполнения данного метода).

  • При разработке распределенной программы на языке MC# (т.е., при использовании в ней movable-методов и исполнении ее на кластере или в Grid-сети), необходимо учитывать следующие особенности системы исполнения (Runtime-системы) MC#-программ.

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

    Поэтому, первой ключевой особенностью языка MC# (а точнее, его семантики) является то, что, в общем случае, во время вызова movable-метода, все необходимые данные, а именно:

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

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

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

    Каналы и обработчики канальных сообщений являются средствами для организации взаимодействия параллельных распределенных процессов между собой. Синтаксически, каналы и обработчики обычно объявляются в программе с помощью специальных конструкций - связок (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# нужно руководствоваться следующими правилами их корректного определения:

  • Формальные параметры каналов и обработчиков не могут содержать модификаторов ref или out.
  • Если в связке объявлен обработчик с типом возвращаемого значения return-type, то в теле связки должны использоваться операторы return только с выражениями, имеющими тип return-type.
  • Все формальные параметры каналов и обработчика в связке должны иметь различные идентификаторы.
  • Каналы и обработчики в связке не могут быть объявлены как 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;
    }

    Задача расстановки ферзей на шахматной доске (N-Queens)

    Хорошо известной задачей в учебниках по элементарному программированию, структурам данных и алгоритмам, является задача о расстановке на шахматной доске восьми ферзей таким образом, чтобы ни один из них не находился под боем какого-либо другого из ферзей. То, что эта задача имеет решение, было продемонстрировано Карлом Фридрихом Гауссом и Францем Науком в 1850 году. На самом деле, имеется 92 различных способа расставить указанным образом ферзей на обычной шахматной доске.

    Вычисление количества решений и всех их перечисление для данной задачи является одной из базовых проблем компьютерного программирования. Задача о 8 ферзях естественным образом обобщается до задачи об $$N $$ > ферзях, когда задается число $$N $$ - размер шахматной доски, и требуется найти все способы расстановки $$N $$ ферзей на этой доске, чтобы они попарно не атаковали друг друга.

    Для решения этой задачи предлагались различные методы - некоторые из них можно найти в известной книге Н. Вирта "Алгоритмы + Структуры Данных = Программы". Далее будут рассмотрены теоретические основы и реализация эффективного алгоритма решения задачи $$N $$ ферзей (N-Queens), эффективность которого достигается

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

    Далее, при объяснении методов решения, будет использоваться пример с 8 ферзями (т.е., доской 8 x 8).

    Прямая проверка. Предположим, что ферзь находится на $$i $$ -ой горизонтали (строке) и на $$j $$ -ой вертикали (столбце), т.е., на клетке ( $$i,j $$ ). Тогда, клетки, которые находятся под боем данного ферзя, включают в себя: все клетки $$i $$ -ой строки и все клетки $$j $$ -го столбца, а также все клетки ( $$k,l $$ ), где $$k -l = i - j $$, или $$k + l = i + j $$. Последние две группы клеток располагаются на двух диагоналях, которые находятся под боем ферзя, расположенного в клетке ( $$i,j $$ ).

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

    Эта процедура может быть представлена алгоритмически в виде последовательности проверок, при которых вначале делается попытка выставить ферзя в первой строке, затем второго ферзя - во второй строке, и т.д. Если для очередного ферзя в $$i $$ -ой строке безопасная позиция отсутствует, то необходима процедура бектрекинга.

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

    В усовершенствованном методе используются три массива column, left и >right для хранения информации о текущей конфигурации ферзей, которые состоят из 8, 15 и 15 элементов соответственно (так как $$15 = 8 + ( 8 - 1 ) $$ при $$N = 8 $$ ). Пусть column[i] равно 1, если имеется ферзь в $$i $$ -ом столбце доски, и $$0 $$ - в противном случае. Если ферзь находится в позиции ( $$i,j $$ ), то $$left [ i + j ] $$ и $$right [ 7 - j + i ] $$ будут равны 1, в противном случае - 0 (как обычно, мы предполагаем, что нумерация элементов массива начинается с 0).

    Имея такие массивы, проверка очередной клетки на возможность размещения в ней очередного ферзя, становится прямой и эффективной. Для проверки позиции ( $$i^',j^' $$ ), нам необходимо только проверить элементы column[i^'], left[i^'+j^'] и right[7-j^'+i^']. Если все три элемента массивов равны 0, то позиция ( $$i^',j^' $$ ) является безопасной для нового ферзя. Для поиска безопасной расстановки или всех возможных таких расстановок, как обычно, используются процедуры последовательного перебора и бектрекинга.

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

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

    Метод решения на основе битовых векторов

    Предположим, что $$b_1 $$ является битовым вектором для первого ряда доски, и $$j $$ -й бит в $$b_1 $$ содержит 1, представляющую размещенного в этой позиции ферзя. Тогда, ( $$j-1 $$ )-ая позиция во втором ряду атакуется (контролируется) данным ферзем, и соответствующая отметка в битовом векторе может быть получена сдвигом вектора $$b_1 $$ на один бит влево. Аналогичным образом, ( $$j+1 $$ )-ая позиция во втором ряду, которая также контролируется данным ферзем, определяется сдвигом вправо вектора $$b_1 $$ на один бит. Тогда, все контролируемые позиции во втором ряду представляются следующим битовым вектором (мы используем < < и > > как обозначения операций сдвига влево и вправо, символ | обозначает побитовое ИЛИ, 1 используется для обозначения занятой или контролируемой позиции, а 0 - для свободных позиций):

    $$gt;b_1 | ( b_1 lt; < 1 ) | (b_1 gt; gt; 1)$$

    Также легко найти позиции, контролируемые первым ферзем в третьей строке: достаточно сдвинуть вектор $$b_1$$ влево или вправо еще на один бит. Т.е.:

    $$b_1 | ( b_1 < < 2) | (b_1 > > 2)$$

    В общем случае, позиции, контролируемые первым ферзем в $$k$$ -ой строке, могут быть определены путем сдвига вектора $$b_1$$ на $$k - 1$$ битов влево и вправо и выполнением побитовой операции ИЛИ над этими тремя векторами. Ферзь в первой строке контролирует в каждой строке ниже самое большее три позиции. Отметим также, что при сдвиге биты, содержащие 1, могут выходить за пределы векторов, размер которых совпадает с размером доски. Пусть $$B_i[k] $$ обозначает вектор, представляющий контролируемые позиции в строке $$k$$ ферзем, находящимся в строке $$i ( k > i ) $$. Тогда, очевидно, имеем:

    $$B_i[k] = b_i | ( b_i < < ( k - i ) ) | (b_i > > ( k - i ) )$$

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

    Однако, существует естественный способ объединить все эти управляющие векторы в три рабочих вектора, которые будут называться left, down и right, соответственно.

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

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

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

    $$B = left | down | right$$

    На каждом шаге, новый ферзь добавляется в некоторую позицию, для которой бит в векторе $$B$$ равен 0, и все три рабочих вектора модифицируются в соответствующем бите. После этого, происходит переход к следующему шагу, при котором векторы left и right сдвигаются на 1 бит влево и вправо, соответственно. Тем самым, для каждой строки поддерживается инвариант

    контролируемые позиции = left | down | right,

    который гарантирует корректность процедуры.

    Описанный процесс поиска с бектрекингом легко реализовать в виде (последовательной) рекурсивной процедуры на языке C#:

    using System;
    public class NQueens   {
     public static int  N, MASK, COUNT;
     public static void Backtrack(int y, int left, int down, int right)
     {
         int  bitmap, bit;
         if (y == N) {
             COUNT++;
         } else {
             bitmap = MASK  ~(left | down | right);
             while (bitmap != 0) {
                 bit = -bitmap  bitmap;
                 bitmap ^= bit;
                 Backtrack(y+1, (left | bit) < <1, down | bit, (right | bit) > >1);
             }
         }
     }
     public static void Main(String[] args )
     {
         N = 10;   /*  <- N  */
         COUNT = 0;   /* result */
         MASK = (1 < < N) - 1;
         Backtrack(0, 0, 0, 0);
         Console.WriteLine ("N=" + N + " -> " + COUNT);
         return;
     }
    }

    Параллельный алгоритм решения задачи N-Queens

    Базовая идея параллельного алгоритма проста: для каждой возможной позиции ферзя в 1-ой строке ( а всего таких позиций $$N$$ для доски размером $$N x N$$ ) поиск расстановок всех остальных ферзей может проводиться независимо, а потому параллельно на нескольких процессорах. Однако, этот прямой вариант пригоден только когда $$N = P$$, где $$P$$ есть число доступных процессоров.

    Однако, прямой вариант легко обобщить путем независимого поиска решений для всех допустимых конфигураций ферзей на первых $$M ( M le; N ) $$ строках. Очевидно, что число таких конфигураций не превышает $$N^M$$, и все они могут быть сгенерированы с помощью процедуры Backtrack, приведенной выше.

    Все эти конфигурации оформляются в виде объектов класса Task, которые в качестве своих полей содержат векторы left, down и right, представляющие очередную расстановку ферзей на первых $$M$$ строках, для которой будет искаться полная расстановка.

    Таким образом, отдельные процессоры ( Worker' ы), будут брать из некоторой очереди (представляемой каналом sendTask и обработчиком getTask ) очередную задачу, решать ее и пересылать ответ главной программе при запросе очередного задания. Worker заканчивает свою работу при считывании из очереди концевого маркера (объекта класса Task со значением -1 для каждого из векторов left, down,right ).

    Полный текст программы NQueens на языке MC# представлен ниже.

    using System;
    public class Task   {
     public int left, down, right;
     public Task ( int l, int d, int r ) {
      left  = l;
      down  = d;
      right = r;
     }
    }
    //**************************************************//
    public class NQueens   {
     public static long totalCount = 0;
     public static void Main ( String[] args ) {
      int   N = System.Convert.ToInt32 ( args [ 0 ] );   //  Board size
      int   M = System.Convert.ToInt32 ( args [ 1 ] );   //  Number of fixed queens
      int   P = System.Convert.ToInt32 ( args [ 2 ] );   //  Number of workers
      NQueens nqueens = new NQueens();
      nqueens.launchWorkers ( N, M, P, nqueens.getTask, nqueens.sendStop, nqueens );
      nqueens.generateTasks ( N, M, P, nqueens.sendTask );
      for ( int i = 0; i < P; i++ )
       nqueens.getStop ? ();
      Console.Write     ("Task challenge : " + N + "   " );
      Console.WriteLine ("Solutions = " + totalCount );
     }
     //***************************************************************************//
     public handler getTask Task(int count )  channel sendTask ( Task task ) {
      totalCount += count;
      return ( task );
     }
     //***************************************************************************//
     public handler getStop void() channel sendStop () {
      return;
     }
     //***************************************************************************//
     public async launchWorkers ( int N, int M, int P, handler Task(int) getTask,
                                  channel () sendStop, NQueens nqueens            ){
      for ( int i = 0; i < P; i++ )
       nqueens.Worker ( i, N, M, getTask, sendStop );
     }
     //***************************************************************************//
     public void generateTasks ( int N, int M, int P, channel (Task) sendTask ) {
      int   y     = 0;
      int   left  = 0;
      int   down  = 0;
      int   right = 0;
      int   MASK  = ( 1 < < N ) - 1;
      MainBacktrack ( y, left, down, right, MASK, M, sendTask );
      Task finish_marker = new Task ( -1, -1, -1 );
      for ( int i = 0; i < P; i++ )
       sendTask ! ( finish_marker );
     }
     //***************************************************************************//
     public void MainBacktrack ( int y, int left, int down, int right, int MASK,
                                 int M, channel (Task) sendTask               ) {
      int   bitmap, bit;
      if ( y == M )
       sendTask ! ( new Task ( left, down, right ) );
      else   {
       bitmap = MASK  ~ ( left | down | right );
       while ( bitmap != 0 )   {
        bit   = -bitmap  bitmap;
        bitmap = bitmap ^ bit;
        MainBacktrack (y + 1, (left | bit) < <1, down | bit, ( right | bit ) > > > 1,
                        MASK, M, sendTask                                      );
       }
      }
     }
     //***************************************************************************//
     public async Worker ( int myNumber, int N, int M, handler Task(int) getTask,
                      channel () sendStop                                    ) {
      int    MASK  = ( 1 < < N ) - 1;
      int    count = 0;
      Task   task  = (Task) getTask ? ( count );
      while ( task.left != -1 )   {
       WorkerBacktrack ( M, task.left, task.down, task.right, MASK, N, ref count );
       task  = (Task) getTask ? ( count );
       count = 0;
      }
      sendStop ! ();
     }
     //***************************************************************************//
     public void WorkerBacktrack ( int y, int left, int down, int right, int MASK,
                                   int N, ref int count                           ) {
      int   bitmap, bit;
      if ( y == N )
       count++;
      else   {
       bitmap = MASK  ~ ( left | down | right );
       while ( bitmap != 0 )   {
        bit   = -bitmap  bitmap;
        bitmap = bitmap ^ bit;
        WorkerBacktrack ( y + 1, (left|bit) < < 1, down|bit, (right|bit) > > 1,
                         MASK, N, ref count                                    );
       }
      }
     }
    }

    Задачи

  • Реализуйте распределенный вариант программы NQueens, использующий movable- методы.
  • Изучите последовательный алгоритм решения задачи ), и реализуйте на его основе параллельный вариант.
  • Ознакомьтесь с формулировкой задачи "Queens and Knights" ( http://www.vector.org.uk/archive/v213/hui213.htm). Реализуйте последовательный алгоритм решения этой задачи на языке C#, а затем - параллельный вариант на языке MC#.
  • Сведения о практической реализации языка MC#

    Как обычно, для любого параллельного языка программирования, реализация MC# состоит из компилятора и рантайм-системы. Главными функциональными частями рантайм-системы являются:

  • ResourceManager - процесс, исполняющийся на центральном узле и распределяющий по узлам movable-методы.
  • WorkNode - процесс, исполняющийся на каждом из рабочих узлов и контролирующий выполнение movable-методов.
  • Communicator - процесс, исполняющийся на каждом из узлов и ответственный за принятие сообщений для объектов, расположенных на данном узле.
  • Компилятор переводит программу из MC# в C#, его главной целью является создание кода, реализующего: выполнение movable-методов на других процессорах; пересылку канальных сообщений и; синхронизацию методов, объединенных связкой. Эти функции предоставляются соответствующими методами классов рантайм-системы. Среди них:

  • класс Session - реализует вычислительную сессию.
  • класс TCP - предоставляет возможность доставки запросов на исполнение movable-методов и канальных сообщений.
  • класс Serialization - предоставляет сериализацию/десериализацию объектов, перемещаемых на другие рабочие узлы.
  • класс Channel - содержит информацию о канале.
  • класс Handler - содержит информацию об обработчике.
  • Главные функции компилятора MC#:

  • Добавление вызовов функций Init() и Finalize() класса Session в главном методе программы. Функция Init() доставляет исполняемый модуль программы на другие узлы, запускает процесс Manager, создает объекты LocalNode и другие. Функция Finalize() останавливает запущенные потоки и завершает вычислительную сессию.
  • Добавление выражений, создающих объекты типа Channel и Handler для каждого из каналов и обработчиков, описанных в программе.
  • Замена вызовов async-методов на порождение соответствующих локальных потоков.
  • Замена вызовов movable-методов на запросы менеджеру распределения ресурсов.
  • Замена канальных вызовов на пересылку соответствующих сообщений по TCP-соединению. Трансляция связок, содержащих определения каналов, производится так же, как и в языке Polyphonic C#.
  • Вернуться к учебному плану