Сообщений 4    Оценка 170 [+1/-0]         Оценить  
Система Orphus

Реализация активных объектов

Параллелизм на уровне приложения

Автор: Гурин Сергей Васильевич
Томский политехнический университет

Источник: RSDN Magazine #2-2006
Опубликовано: 21.06.2006
Исправлено: 03.09.2006
Версия текста: 1.0
Введение
Основные принципы
Метод Step
Использование делегатов
Использование yield return
Управление активностью объекта
Методы Open и Close
Диспетчер
Таймер
Активатор
Протокол
Переносимость
Косвенная активация объектов
Взаимодействие активных объектов
Заключение
Список литературы

Исходный текст, unit-тесты и пример – 18К

Введение

Когда речь идет о параллельности, то обычно подразумевается вытесняющая многозадачность и использование объектов операционной системы – нитей (threads). Значительно реже говорят о кооперативной многозадачности и сопрограммах. С одной стороны, внимание к вытесняющей многозадачности вполне объяснимо, когда параллельность касается отдельных процессов операционной системы. Но можно ли однозначно оценить применимость вытесняющей многозадачности на уровне отдельно взятого процесса-приложения? Хотя часть проблем уровня приложения вполне естественно решается нитями, существуют трудности, связанные с таким решением. Первая, и, вероятно, наибольшая, обусловлена проблемами синхронизации и взаимоисключающего доступа – большинство статей и библиотек сконцентрированы именно на этой проблеме. Когда нитей немного и взаимодействуют они между собой относительно слабо, то синхронизация выполняется относительно просто. Если же нитей много и они интенсивно взаимодействуют друг с другом, то проблемы синхронизации становятся весьма значительными. Особенно часто это возникает в таких задачах, которые естественно (в силу специфики своей проблемной области) разбиваются на набор взаимодействующих параллельных процессов. Пример – задачи имитационного моделирования. Вторая трудность связана с довольно высокими требованиями к ресурсам, что существенно ограничивает число создаваемых нитей. Более подробно этот вопрос рассматривается в [1]. Следствие ограничения числа нитей – низкая масштабируемость приложения и невозможность «естественной» декомпозиции при большом числе параллельных процессов (десятки и сотни тысяч). Третья трудность – плохая переносимость на различные платформы и языки программирования.

В статье рассматривается альтернативный способ организации параллелизма в рамках одного приложения, который основан на кооперативной многозадачности, но без использования сопрограмм. Отказ от сопрограмм связан с тем, что полноценная их поддержка возможна только на уровне языка. Можно использовать для реализации сопрограмм внеязыковые механизмы операционной системы [2], но это вносит существенную зависимость от платформы. Сопрограммы на основе волокон (fibers) требуют меньше ресурсов, чем нити, но основной ресурс, индивидуальный стек сопрограммы, остается достаточно большим. Если сказать обзорно, то в статье рассматривается такой способ организации параллелизма, который:

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

Основные принципы

Под параллелизмом в статье понимается только программно реализованный параллелизм, а фактически, его имитация на одном процессоре. Из рассмотрения исключаются специфические системы, связанные с аппаратурной поддержкой, например, массовый (векторный) параллелизм или «тонкий» параллелизм на уровне операторов (как это делается в транспьютерных системах и в таких языках, как Оккам [6]). Традиционная реализация параллелизма основана на функциональной декомпозиции, то есть единицей параллельности является процедура (функция нити или сопрограмма). Рассматриваемая реализация основана на объектной декомпозиции и известна под названиями «живой объект», «активный объект», «агент». Суть этой концепции состоит в том, что объект, с одной стороны, является полноценным объектом (в терминах объектно-ориентированного подхода), а с другой стороны, он проявляет свою активность независимо от активности других объектов. Если в последовательной программе можно выделить только один поток управления, то в параллельной программе существует множество потоков управления, причем каждый «активный» объект живет своей особой жизнью.

Поскольку одним из критериев выбрана независимость от операционной системы, то активные объекты не базируются на объектах ядра. Учет другого условия, независимости от языка программирования, исключает возможность использования сопрограмм. Рассмотрим более подробно особенности сопрограмм, которые требуют поддержки со стороны компилятора. Во-первых, это необходимость «мягкого» возврата из подпрограммы. Под этим термином понимается возможность возобновления подпрограммы с той точки, в которой она была прервана оператором возврата. Такой возврат существенно отличается от обычного возврата из подпрограммы, при котором локальный контекст полностью теряется и стек остается в том же состоянии, в каком он был до вызова подпрограммы. Для сопрограммы требуется сохранить как локальный контекст, так и стек. Частично задачу можно решить, если сопрограмму заменить объектом, который будет сохранять локальный контекст. Но проблема стека (а точнее, его неопределенного размера) остается, и избежать ее можно, только если исключить «мягкий» возврат и восстанавливать стек в том же состоянии, что и при вызове.

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

Кооперативная многозадачность предполагает, что ответственность за диспетчеризацию параллельных процессов (активных объектов) несет программист, и справедливое распределение процессорного времени требует, чтобы каждый шаг активного объекта был коротким и не был связан с непредсказуемыми временными задержками. Другое требование к шагу объекта связано с транзактностью – после каждого шага объект должен находиться в непротиворечивом состоянии и должен полностью завершить изменение своего состояния. «Оживление» всех активных объектов приложения происходит в бесконечном цикле, на каждой итерации которого выполняется шаг очередного активного объекта.

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

В дальнейшем изложении будет использован язык C#.

Скелет активного объекта
public class ActiveObject
{
  protected virtual void Step() { }

  public bool Active { get{} set{} }
  public bool Sleeping { get{} }
  public void Sleep(int time) { }
  public void Sleep() { }
  public void WakeUp() { }

  public static void Open(ThreadPriority priority) { }
  public static void Close() { }
}

В приведенном коде выделяются три группы методов:

В целом, функциональность активного объекта соответствует функциональности нити или класса Thread. Объект может быть активным или неактивным. В активном состоянии его жизненный цикл определяется методом Step. Установка свойства Active изменяет активность объекта. Методы Sleep деактивируют объект на указанный интервал времени или до вызова WakeUp. Свойство Sleeping возвращает истину, если объект находится в состоянии ожидания. Диспетчеризацию (циклический обход всех активных объектов и вызов у каждого активного объекта метода Step) инициирует статический метод Open. Он открывает театр жизненных драм всех активных объектов приложения, а метод Close его закрывает.

Метод Step

Как уже было сказано, активный объект реализуется как конечный автомат. Простейшая, классическая реализация автомата использует целочисленную переменную состояния (state) и оператора выбора (switch).

Классический автомат
public class SampleActiveObject : ActiveObject
{
  private int state = 0;

  protected override void Step()
  {
    switch (state)
    {
      case 0:
        // действия 0
        break;
      case N:
       // действия N
       break;
    }
  }
}

При вызове метода Step объект выполняет действие, которое либо изменяет состояние переменной state, либо оставляет ее в неизменном состоянии, после чего метод завершается. Такая реализация в высшей степени эффективна и переносима. Однако она весьма трудоемка при большом числе состояний автомата. Для преодоления этого недостатка было предложено множество решений на основе паттернов «State» или «State Machine», которые подробно описаны в литературе, например [3], [4]. Тема конечных автоматов поистине безбрежна, но, тем не менее, рискну добавить в это море еще пару своих решений, ориентированных на язык C#.

Использование делегатов

Основная идея состоит в том, что каждое состояние автомата описывается методом-делегатом, причем смена состояния приводит к смене делегата.

Автомат на основе делегатов
public class SampleActiveObject : ActiveObject
{
  private delegate void State();
  private State state;

  private State state0;
  private State stateN;

  private void State0()
  {
    // действие 0
  }

  private void StateN()
  {
    // действие N
  }

  public SampleActiveObject()
  {
    state0 = new State(State0);
    stateN = new State(StateN);

    state = state0;
  }

  protected override void Step()
  {
    state();
  }
}

Смена состояния реализуется присваиванием переменной state нового делегата. В отличие от классической реализации, добавление нового состояния сводится к добавлению нового делегата, подобно тому, как в паттерне «State» добавляется новый объект-состояние.

Использование yield return

Во второй версии C# появился весьма удобный метод реализации итераторов [5]. Оператор yield return выполняет «мягкий» возврат очередного значения итератора, подобно тому, как это происходит в сопрограммах, но реализуется он через конечный автомат. Итераторы C#, по большому счету и являются аналогами сопрограмм, точнее, разновидностью т. н. продолжений (continuations). Заглянув в код, генерируемый компилятором, можно увидеть, что компилятор создает скрытый класс, реализующий интерфейс IEnumerator, целочисленное поле state и метод MoveNext, содержащий оператор switch. То есть, реализация yield в точности соответствует классическому конечному автомату. Поскольку оператор yield return можно применять только в итераторах, потребуется немного изменить сигнатуру метода Step.

Автомат на основе yield return
public class SampleActiveObject : ActiveObject
{
  protected override IEnumerator Step()
  {
    while (true)
    {
      // действие 0
      yield return null;
      // действие N
      yield return null;
    }
  }
}

Поскольку yield return не используется для возврата текущего состояния итератора, нам не важно возвращаемое значение. Оператор yield return завершает выполнение шага и приводит к выходу из метода Step. Фактически, мы предоставляем компилятору реализацию конечного автомата. Естественно, что метод Step может содержать различные условные и циклические конструкции и yield return может завершать выполнение метода в той или иной точке. Бесконечный цикл нужен в том случае, если жизнь активного объекта не заканчивается на последнем операторе yield return. В этом коде конечный автомат совсем не виден, но зато становится явной «сопрограммная» ориентация без явного выделения состояния. Другими словами, генерируемый компилятором конечный автомат позволяет явным образом реализовать сопрограммы. Поскольку иногда удобно использовать «автоматную» реализацию, а иногда «сопрограммную», то введем в определение активного объекта оба варианта методов.

Два варианта реализации
private IEnumerator enumerator;

protected virtual IEnumerator Life()
{
  throw new NotImplementedException();
}

protected virtual void Step()
{
  if (enumerator == null)
    enumerator = Life();
  if (!enumerator.MoveNext())
    Active = false;
}

Если жизнь активного объекта более естественно выражается в терминах конечного автомата, то наследуемый класс будет переопределять метод Step, если же более удобна «сопрограммная» реализация, то наследуемый класс будет переопределять метод Life. В последнем случае метод Step переадресует управление методу Life явным получением и вызовом итератора. Когда MoveNext возвращает значение false, это означает, что жизненный цикл активного объекта завершился и нужно выполнить его деактивацию. Наследуемый класс должен обязательно переопределить один из методов, в противном случае при активации объекта будет сгенерирована исключительная ситуация. Особо можно отметить, что итератор (enumerator) инициализируется в методе Step, а не в конструкторе объекта. Это нужно, чтобы иметь возможность создания объекта через System.Activator.CreateInstance и устранить генерирование исключения при создании объекта.

Управление активностью объекта

Часть класса, которая управляет активностью объекта, довольно проста.

Управление активностью
private static Dispatcher dispatcher;

private ActiveObject prev;
private ActiveObject next;
private int timeout;
private bool sleeping;

public bool Active
{
  get
  {
    return next != null;
  }
  set
  {
    if (value)
      dispatcher.Add(this);
    else
      dispatcher.Remove(this);
  }
}

public bool Sleeping
{
  get
  {
    return sleeping;
  }
}

public void Sleep(int interval)
{
  dispatcher.Sleep(this, Environment.TickCount + interval);
}

public void Sleep()
{
  dispatcher.Sleep(this);
}

public void WakeUp()
{
  dispatcher.WakeUp(this);
}

В последующих разделах статьи детали этого кода будут объяснены подробнее, а пока можно сказать, что класс активного объекта имеет скрытый статический объект – диспетчер, который содержит список объектов, находящихся в активном состоянии. Всем объектам из списка диспетчера периодически выделяется процессорное время – вызывается их метод Step. Каждый объект имеет два поля – ссылку на предыдущий активный объект (prev) и ссылку на следующий активный объект (next). Если объект не содержится в списке диспетчера (не имеет соседа), он находится в неактивном состоянии. Активация объекта заключается в его добавлении в конец списка диспетчера, а деактивация – в удалении из списка диспетчера.

Методы Open и Close

Open и Close
private static Activator activator; 
private static Timer timer; 
private static Log log;

public static void Open(ThreadPriority priority)
{
  activator.Start(priority);
}

public static void Close()
{
  activator.Stop();
  dispatcher.Clear();
  timer.Clear();
  log.Clear();
}

Метод Open создает и стартует нить, вызывая метод Start у скрытого статического объекта activator, а метод Close останавливает и уничтожает нить, а также очищает содержимое остальных скрытых статических объектов.

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

Конструктор типа
static ActiveObject()
{
  dispatcher = new Dispatcher();
  timer = new Timer();
  activator = new Activator();
  log = new Log();
}

Диспетчер

Диспетчер представляет собой список объектов, находящихся в активном состоянии. Реализация диспетчера основана на двусвязном списке с явно выделенными граничными узлами first и last. Связи активного объекта с предыдущим и последующим хранятся в приватных полях prev и next. Некоторая сложность списка обусловлена тем, что активный объект может во время своего выполнения создать новый активный объект, активировать или деактивировать другой объект, или деактивировать самого себя. То есть, состояние списка может измениться при его обходе.

Класс Dispatcher

Использование фиктивных граничных объектов дает максимальную эффективность при добавлении и удалении элементов, так как эти операции всегда происходят в предположении, что элемент имеет как предыдущего, так и последующего соседа. В целом реализация списка достаточно традиционна, но особо следует отметить поле currentPrev. В начале итерации (при вызове метода GetFirst) это поле устанавливается в null, а после получения очередного активного объекта (GetNext) изменяется на предыдущий активный объект. Поскольку активный объект в ходе своего выполнения может деактивировать себя, то ссылка на следующий объект будет потеряна. Ситуация еще больше усложняется, если объект деактивирует не только себя, но и своих соседей. Для решения этой задачи и служит currentPrev. Корректировка currentPrev выполняется в методе Remove. Если объект деактивируется (удаляется из списка) и, при этом, он является currentPrev, то currentPrev вначале переводится на предыдущий объект и только потом объект удаляется. Таким образом, currentPrev всегда ссылается на объект, который находится в списке и предшествует текущему объекту, но не обязательно является его ближайшим соседом.

При активации объекта (добавлении его в список) выполняется три проверки:

  1. контроль неактивности объекта. Неактивность определяется тем, что поле next объекта равно null, то есть, у него еще нет соседа;
  2. контроль отсутствия фатальной ошибки. Если объект содержит непустую ссылку на исключительную ситуацию, то он был завершен с фатальной ошибкой (необработанной исключительной ситуацией) и не может быть активирован вновь;
  3. контроль отсутствия состояния ожидания. Если поле sleeping объекта имеет значение true, то объект стал неактивным в результате вызова одного из методов Sleep и может быть активирован только с помощью WakeUp.

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

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

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

Таймер

Таймер представляет собой список временно неактивных объектов. Объекты попадают к таймеру при выполнении метода Sleep. Список сортируется по убыванию времен, и, таким образом, объекты с меньшими значениями времени ожидания (timeout) размещаются в конце списка. Сортировка в обратном порядке дает большую эффективность, так как объекты, завершившие свое ожидание, удаляются из конца (а не из начала) списка. К сожалению, библиотека .Net Framework не имеет контейнера, который можно использовать в данном случае. Наиболее подходящий контейнер, SortedList, требует уникальных значений ключей, а время ожидания для нескольких объектов может совпадать.

Класс Timer

Метод Add выполняет добавление объекта в список на основе его поля timeout, используя традиционный сортирующий алгоритм с двумя изменяемыми пределами L (low) и H (high). Метод GetReady извлекает из конца списка объект, если его время ожидания истекло. Если же такого объекта нет, то метод возвращает null.

Активатор

Назначение активатора – циклическая итерация по всем активным объектам и вызов их метода Step. Активатор создает свою собственную нить, в контексте которой и выполняются все активные объекты.

Класс Activator

Метод Start создает новую нить (если она еще не создана) и активирует ее. Метод Stop устанавливает признак завершения нити (terminate) и ожидает ее завершения. Основная работа нити происходит в методе Execute. Метод выполняет бесконечный цикл, в ходе которого вначале контролируется наличие активных объектов, и, если таковые имеются, то выполняется итерация по всем активным объектам. В ходе итерации для каждого объекта вызывается его метод Step. Если при выполнении метода возникает необработанная исключительная ситуация, то она фиксируется в поле объекта (exception), что позволяет другим объектам обнаружить наличие фатальной ошибки данного объекта. Объект с необработанной исключительной ситуацией считается нежизнеспособным и больше не может стать активным. Чтобы при фатальной ошибке объекта не нарушать работу других объектов, ошибка просто фиксируется в протоколе и больше не выполняется никаких действий.

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

Протокол

Назначение протокола – хранение списка активных объектов, которые были аварийно завершены по необработанной исключительной ситуации.

Класс Log

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

Переносимость

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

Реализация на C для микроконтроллера
void main()
{
  for (;;)
  {
    Step1();
    StepN();
  }
}

void StepX()
{
  static byte state;
  switch (state)
  {
    case 0:
      /* действия */
      break;
    case M:
      /* действия */
      break;
  }
}

Программы для микроконтроллеров обычно содержат статический набор параллельных активностей, поэтому список активных объектов может быть заменен простой последовательностью функций. Роль активатора выполняет функция main, а роль активного объекта – соответствующая функция StepX. Конечный автомат реализуется по классической схеме.

Эта реализация позволяет создать достаточно большое число параллельных процессов с затратами оперативной памяти всего в один байт на каждый параллельный процесс.

Косвенная активация объектов

Внимательные читатели могли отметить некоторую нелогичность декларации методов Sleep. Они были объявлены как public, хотя вполне достаточно их объявления как protected, ведь активные объекты только по собственной инициативе могут перейти в состояние ожидания. Объяснение принятой декларации следующее. Для того чтобы объект стал параллельным (активным), совсем необязательно порождать его от ActiveObject. Такая ситуация может возникнуть при эволюции программы в том случае, если класс объекта, который желательно сделать активным, уже встроен в какую-то иерархию классов. В такой ситуации достаточно создать proxy-объект, порожденный от ActiveObject, который будет косвенно активировать основной объект. Активность основного объекта управляется изменением активности proxy-объекта, а proxy-объект активирует основной объект, вызывая его методы из своих методов Step или Life. В свою очередь, основной объект может переходить в состояние ожидания, вызывая соответствующие методы proxy-объекта.

Взаимодействие активных объектов

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

СОВЕТ

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

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

Заключение

Использование параллельных активностей дает возможности более удобной декомпозиции задачи в тех случаях, когда проблемная область «естественно» разделяется на набор параллельно работающих и взаимодействующих процессов. Активные объекты снижают сложность реализации параллелизма в рамках одного приложения. Обсуждение способов синхронизации не входило в планы данной статьи, но может быть предметом другой статьи. Надеюсь, этот материал был вам интересен.

Список литературы

  1. RSDN. Роман Хациев. Заметка о производительности многопоточных Win32-программ.
  2. MSDN. Ajai Shankar. Implementing Coroutines for .NET by Wrapping the Unmanaged Fiber API.
  3. Эрих Гамма и др. Приемы объектно-ориентированного проектирования.
  4. RSDN. Александр Николаенко. Static Finite State Machine.
  5. RSDN. Владислав Чистяков. Нововведения в C# 2.0.


Эта статья опубликована в журнале RSDN Magazine #2-2006. Информацию о журнале можно найти здесь
    Сообщений 4    Оценка 170 [+1/-0]         Оценить