Шаблон разработки асинхронного программирования (исходники)

Источник: rsdn
Павлов Эдуард

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

ПРИМЕЧАНИЕ

Для понимания этой статьи потребуется некоторое знакомство с предлагаемыми Microsoft шаблонами разработки асинхронного программирования (Asynchronous Programming Design Patterns) http://msdn.microsoft.com/ru-ru/library/ms228969.aspx.

Что такое шаблоны разработки асинхронного программирования и зачем они нужны?

Приложениям время от времени нужно вызывать некоторую логику в отдельном потоке. В основном это делается для того, чтобы графический интерфейс пользователя (GUI) не "зависал" на время длительных вычислений или других операций. Теперь, с распространением многопроцессорных систем, можно извлечь пользу также и из распараллеливания вычислений. .NET Framework предоставляет все необходимое для создания многопоточных приложений, но еще в первой версии .NET Microsoft предложила к использованию шаблон асинхронного программирования - "асинхронные операции, использующие IAsyncResult" (http://msdn.microsoft.com/ru-ru/library/ms228963(en-us,VS.80).aspx). В этой статье данный шаблон будет называться более кратко - шаблон IAsyncResult. Для чего же нужны шаблоны, если библиотека классов .NET, как уже было сказано, предоставляет все необходимые классы?

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

Здесь надо отметить, что асинхронизация не всегда реализуется через вызов метода в контексте созданного в приложении потока. Вернемся к классу FileStream. Его асинхронные методы реализованы через "перекрывающийся ввод/вывод" (Overlapped I/O). То есть вызов, скажем, обычной версии метода Write в отдельном потоке не идентичен вызову его асинхронного аналога, BeginWrite. Write будет занимать вызывающий поток, пока не закончит работу, тогда как BeginWrite не только вернет управление немедленно, не дожидаясь окончания работы метода, но и будет выполняться в фоновом режиме, не создавая дополнительный поток в приложении. Дополнительный поток будет создан только тогда, когда асинхронная операция завершится, и нужно будет оповестить вызывающую сторону об этом и то, только в случае, если в BeginWrite был передан делегат метода обратного вызова.

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

Представим, что в некотором приложении есть графический интерфейс пользователя, который не должен "зависать" при выполнении длительных операций и, к примеру, сгенерированный автоматически класс-прокси доступа к Web-службе, предоставляющий асинхронные методы. На первый взгляд у нас есть все для выполнения нашей задачи, так как Web-служба предоставляет асинхронные методы, построенные по одному из предлагаемых Microsoft шаблонов (и реализованные через перекрывающий ввод/вывод), - знай, вызывай себе из слоя GUI асинхронные методы. Но в хорошо спроектированных системах и сам класс-прокси будет инкапсулирован в транспортном слое, да еще и между транспортным слоем и GUI будут лежать один или несколько слоев. Например, слой модели и слой представления, если мы воспользуемся шаблоном model-view-presenter. Таким образом, чтобы асинхронными операциями, предоставляемыми классом-прокси, можно было воспользоваться из слоя GUI, каждый слой должен реализовывать шаблон асинхронного программирования и предоставлять возможность выполнять операции асинхронно.

Недостатки предлагаемых Microsoft шаблонов

На данный момент Microsoft предлагает два шаблона разработки для асинхронного программирования. Уже упоминавшийся шаблон IAsyncResult основан на интерфейсе IAsyncResult. Второй - "асинхронная модель, основанная на событиях" (Event-based Asynchronous Pattern) http://msdn.microsoft.com/ru-ru/library/wewwczdw.aspx. В дальнейшем он будет именоваться event-based-шаблон. Что же не устраивает в предлагаемых Microsoft шаблонах и для чего понадобилось создавать что-то свое? Давайте рассмотрим подробнее, что нам предлагает MS. Первым рассмотрим шаблон IAsyncResult. Вот его объявление с комментариями разработчиков:

/// <summary>
/// Представляет состояние асинхронной операции. /// </summary>
[ComVisible(true)]
public interface IAsyncResult
{
  /// <summary>
  /// Возвращает определенный пользователем объект, который определяет или содержит в себе сведения об асинхронной операции.
  /// </summary>
  /// <returns>пределенный пользователем объект, который определяет или содержит в себе сведения об асинхронной операции.</returns>
  object AsyncState { get; }

  /// <summary>
  /// Возвращает дескриптор <see cref = System.Threading. WaitHandle />, используемый для режима ожидания завершения асинхронной операции.
  /// </summary>
  /// <returns>Объект <see cref = System.Threading. WaitHandle />, используемый для режима ожидания завершения асинхронной операции. </returns>
  WaitHandle AsyncWaitHandle { get; }

  /// <summary>
  /// Возвращает значение, показывающее, синхронно ли закончилась асинхронная операция.
  /// </summary>
  /// <returns>Значение true, если асинхронная операция завершилась синхронно, в противном случае - значение false.</returns>
  bool CompletedSynchronously { get; }

  /// <summary>
  /// Возвращает значение, показывающее, выполнена ли асинхронная операция.
  /// </summary>
  /// <returns>Значение true, если операция завершена, в противном случае - значение false.</returns>
  bool IsCompleted { get; }
}

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

ПРИМЕЧАНИЕ

Условимся называть класс, предоставляющий асинхронные методы, классом-поставщиком или просто поставщиком, а класс, вызывающий эти методы, - классом-потребителем или просто потребителем.

Классу-поставщику, в соответствии с этим шаблоном, предписывается иметь определенный интерфейс. На каждую асинхронную операцию объявляется по два метода - BeginOperationName и EndOperationName. Рассмотрим его использование на примере простейшего класса, предоставляющего всего одну асинхронную операцию, вычисляющую число Фибоначчи по его индексу.

class NaiveMath
{
  public IAsyncResult BeginCalculateFibonacciNumber(int index,
    AsyncCallback callback, object state);
public int EndCalculateFibonacciNumber(IAsyncResult ar);
}

ПРИМЕЧАНИЕ

Выделены те части сигнатуры методов, которые являются реализацией шаблона IAsyncResult.

Метод BeginCalculateFibonacciNumber начинает операцию вычисления числа Фибонначи и возвращает управление. По завершении операции будет вызван метод обратного вызова, переданный через аргумент callback (если он был передан). Для получения результата операции необходимо вызвать метод EndCalculateFibonacciNumber у того же экземпляра, у которого был вызван BeginCalculateFibonacciNumber. Вот простой пример использования класса NaiveMath.

class NaiveMathConsumer
{
  public void On_SomeUserActivity()
  {
    int index = AskUserForIndex();
    NaiveMath math = new NaiveMath();
    math.BeginCalculateFibonacciNumber(index,On_CalculateFibonacciNumberCompleted, math);
  }

  private void On_CalculateFibonacciNumberCompleted(IAsyncResult ar)
  {
    try
    {
      NaiveMath math = (NaiveMath)ar.AsyncState;
      int theResult = math.EndCalculateFibonacciNumber(ar);
      ShowToUser(theResult);
    }
    catch (Exception exc)
    {
      NotifyUserAboutError(exc);
    }
  }
}

В данном примере, чтобы вызвать метод EndCalculateFibonacciNumber, нужный экземпляр класса NaiveMath передается в метод BeginCalculateFibonacciNumber через аргумент state. Его можно сохранять в члене класса, но это не всегда удобно, и такой прием (передача в BeginOperationName) является обычной практикой при использовании шаблона IAsyncResult.

Каковы же недостатки этого шаблона? В первую очередь - объявление метода для получения результата асинхронной операции, то есть EndOperationName, в классе-поставщике. Кроме абсолютно ненужного метода в интерфейсе класса, это приводит еще и к показанным в примере ужимкам для доступа к нужному экземпляру в методе обратного вызова. Но буду справедлив, скорее всего, наличие этого метода - жестокий пережиток отсутствия обобщений в .NET 1.x и сделан для типизации результата асинхронной операции. То есть в .NET 1.x этот метод был действительно нужен, но на данный момент утратил свой смысл.

Все остальное - это не столько недостатки, сколько малый набор возможностей. Нет возможности прервать начатую операцию. Не предусмотрен механизм оповещения о прогрессе операции. При вызове асинхронного метода не обязательно указывать метод обратного вызова, это несомненный плюс, но если уж вы его передали, то отказаться от его вызова по завершении операции уже нельзя. Есть в этом шаблоне и очень удачное решение - это свойство AsyncWaitHandle интерфейса IAsyncResult, позволяющее вызывать методы WaitAny и WaitAll класса WaitHandle для множества асинхронных операций.

Event-based-шаблон предоставляет перечисленные выше недостающие возможности, но имеет при этом свои недостатки. На недостатках этого шаблона я хотел бы остановиться более подробно по той причине, что на данный момент именно этот шаблон рекомендуется Microsoft для повсеместного использования. При этом, по моему мнению, он абсолютно не справляется с возложенной на него задачей. Реализуем класс NaiveMath в соответствии с event-based-шаблоном. Для оповещения потребителя о завершении асинхронной операции этот шаблон предполагает наличие события (event) типа:

public delegate void OperationNameCompletedEventHandler(
  object sender, 
  OperationNameCompletedEventArgs args);

То есть кроме самого класса, предоставляющего асинхронные операции, нам понадобятся объявления делегата и класса, наследующего AsyncCompletedEventArgs.

public class CalculateFibonacciNumberCompletedEventArgs 
  : AsyncCompletedEventArgs
{
  private readonly int _fibonacciNumber;

  public CalculateFibonacciNumberCompletedEventArgs(int fibonacciNumber)
  {
    _fibonacciNumber = fibonacciNumber;
  }

  public int FibonacciNumber
  {
    get { return _fibonacciNumber; }
  }
}


public delegate void CalculateFibonacciNumberCompletedEventHandler(
  object sender, 
  CalculateFibonacciNumberCompletedEventArgs args);


class NaiveMath
{
  public void CalculateFibonacciNumberAsync(int index, object state){}
  public event CalculateFibonacciNumberCompletedEventHandler 
    CalculateFibonacciNumberCompleted;
}

И если от объявления делегата можно избавиться, воспользовавшись делегатом Action<T1, T2>, появившимся в .NET 3.5, то класс CalculateFibonacciNumberCompletedEventArgs придется объявлять в любом случае. К тому же, Event-based-шаблон , так же, как и IAsyncResult, раздувает интерфейс класса. Кроме самого асинхронного метода, надо, как минимум, объявить событие, уведомляющее о его завершении.

Приведенный пример реализации event-based-шаблона по возможностям идентичен примеру шаблона IAsyncResult, то есть не предоставляет возможности отмены асинхронной операции и не уведомляет потребителя о прогрессе исполнения операции. Это сделано сознательно, для корректности сравнения. Если же воспользоваться дополнительными возможностями event-based-шаблона, то в интерфейсе класса появятся еще один метод и одно событие (event). Таким образом, получается два-три открытых члена класса на одну асинхронную операцию.

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

ПРИМЕЧАНИЕ

Будем называть такие методы реентерабельными (multiple-invocation).

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

ПРИМЕЧАНИЕ

Такие методы соответственно будем называть нереентерабельными (single-invocation).

Event-based-шаблон различает эти две группы операций. Различие заключается в сигнатуре методов. Реентерабельные методы, имеют дополнительный аргумент object userState - некий объект, уникально идентифицирующий асинхронную операцию. userState используется классом-потребителем, чтобы отличать операции друг от друга. Так же этот объект-идентификатор используется в методе void CancelAsync(object userState) поставщика, то есть реализация класса-поставщика идентифицирует эти операции по идентификаторам, переданным ей откуда-то снаружи. По моему мнению, в одном этом уже есть некоторый криминал. Но не это главное. У нереентерабельных методов аргумента userState нет, следовательно, такие операции не имеют идентификатора и не могут быть отменены!

ПРЕДУПРЕЖДЕНИЕ

Цитата из MSDN: "Методы, поддерживающие только одну операцию в очереди, ..., не поддерживают отмену." http://msdn.microsoft.com/ru-ru/library/wewwczdw.aspx

В шаблоне IAsyncResult, в сигнатуре асинхронных методов, тоже используется аргумент userState, но его назначение совершенно иное. В шаблоне IAsyncResult этот аргумент используется для передачи контекста вызова асинхронного метода в метод обработки результата, и используется только классом-потребителем. В event-based-шаблоне нет такого понятия, как передача контекста вызова. Аргумент userState, несмотря на его название, используется только для идентификации вызовов реентерабельных методов. В принципе и понятно, смешивать в одном объекте идентификацию и контекст вызова опасно.

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

Но самым главным недостатком, на мой взгляд, является отсутствие контроля со стороны потребителя, в контексте какого потока будет вызвано уведомление о завершении операции. В "best practice" MS рекомендует основывать реализацию event-based-шаблона на функциональности классов AsyncOperation и AsyncOperationManager.

В .NET методы элементов управления (UI controls) нельзя вызывать в контексте произвольного потока, а можно только в контексте того потока, в котором был создан дескриптор (handle) этого элемента управления - будем называть этот поток GUI-потоком. Для вызова произвольного метода в контексте этого потока используются методы Control.Invoke и Control.BeginInvoke. В .NET 2.0 появился новый класс SynchronizationContext, решающий задачу вызова методов в контексте определенного потока в общем случае, а не только для элементов управления. При создании экземпляра класса AsyncOperation запоминается текущий контекст синхронизации - экземпляр класса, наследующего SynchronizationContext (см. статическое свойство Current класса SynchronizationContext), и вызов метода обратного вызова по окончании асинхронной операции производится через запомненный экземпляр.

Большинство вызовов методов в клиентской части приложения инициируются из слоя графического интерфейса пользователя, при этом в качестве текущего контекста синхронизации выставлен экземпляр класса WindowsFormsSynchronizationContext, наследника SynchronizationContext. То есть вызов метода обратного вызова, согласно идеологии event-based-шаблона, будет происходить в контексте GUI-потока.

В реальных приложениях часто возникает необходимость вызвать одновременно несколько асинхронных методов и дождаться, когда все они закончат свое выполнение, чтобы продолжить обработку данных. При использовании шаблона IAsyncResult это делается путем вызова WaitHandle.WaitAll на массиве дескрипторов, доступных через свойство AsyncWaitHandle интерфейса IAsyncResult. Проиллюстрирую на примере.

public void On_SomeUserActivity()
{
  // не передаем метод обратного вызова при начале двух первых операций 
  IAsyncResult operation0 = _provider.BeginOperationName0(null, null);
  IAsyncResult operation1 = _provider.BeginOperationName1(null, null);
  // передаем созданные дескрипторы асинхронных операций в качестве контекста вызова
  _provider.BeginOperationName2(On_BeginOperationName2Completed, new IAsyncResult[]{operation0, operation1});
}

private void On_BeginOperationName2Completed(IAsyncResult operation)
{
  IAsyncResult[] operations = (IAsyncResult[])operation.AsyncState;
  // дожидаемся окончания первых двух операций
  WaitHandle.WaitAll(new WaitHandle[]{operations[0].AsyncWaitHandle, operations[1].AsyncWaitHandle});

  // получаем результаты работы
  int result0 = _provider.EndOperationName0(operations[0]);
  int result1 = _provider.EndOperationName1(operations[1]);
  int result2 = _provider.EndOperationName2(operation);
  //...
}

Естественно, если какие-либо дальнейшие действия необходимо произвести в контексте какого-то определенного потока, потребитель сам должен позаботиться об этом.

При использовании event-based-шаблона для достижения вышеописанного результата потребитель будет вынужден предоставить собственную реализацию класса SynchronizationContext. Эта особенность event-based-шаблона становится реальной головной болью в клиентских приложениях, взаимодействующих с Web-службами. Впрочем, MS допускает реализацию event-based-шаблона, не заботящуюся о том, в контексте какого потока вызывать оповещения. Но в любом случае, то, в контексте какого потока будут вызываться оповещения, зависит именно от реализации шаблона, а не от потребностей потребителя - это и есть самый большой недостаток.

Забавно, что Microsoft рекомендует использовать шаблон IAsyncResult в ядре системы, на низких уровнях, а event-based-шаблон на верхних, предоставляемых клиенту. Что преследуется этой рекомендацией, мне, признаться, непонятно. Видимо Microsoft считает event-based-шаблон более удобным в использовании, но я не разделяю их мнение.

Подведем итог, перечислим недостатки предлагаемых MS шаблонов.

Шаблон IAsyncResult:

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

Event-based-шаблон:

  • Перегрузка интерфейса поставщика - минимум два члена класса на одну операцию: метод начала операции и событие ее окончания; при добавлении оповещения о прогрессе добавляется еще одно событие; плюс метод прерывания операций, один на класс.
  • Отсутствует возможность передать контекст вызова операции в метод обработки результата.
  • Невозможно прервать нереентерабельную операцию.
  • Затруднена пакетная обработка состояния асинхронных операций из-за отсутствия абстракции "асинхронная операция" и необходимости доступа к экземпляру класса-поставщика.
  • Потребитель не контролирует, в контексте какого потока будут вызываться методы обратного вызова, это целиком и полностью зависит от реализации поставщика.

Создание нового шаблона

Итак, перед нами стоит задача создать такой шаблон, который объединил бы в себе достоинства обоих предлагаемых MS шаблонов, по возможности избежав их недостатков. За основу я взял шаблон IAsyncResult, потому что он не страдает от системных проблем. По сути, у него всего два недостатка - два метода в интерфейсе класса-поставщика на одну операцию и недостаточная функциональность.

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

public interface IAsyncOperation : IAsyncResult
{
  bool IsCancelable{ get; }
  void Cancel();
  bool IsCanceled{ get; }
}

ПРИМЕЧАНИЕ

Интерфейс называется IAsyncOperation, что гораздо точнее передает суть этой абстракции. Условимся называть новый шаблон в статье "шаблон AsyncOperation".

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

public interface IAsyncOperation<TResult> : IAsyncOperation
{
  TResult GetResult();
  TResult GetResult(out Exception exception);
}

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

Осталось определить тип делегата метода обратного вызова, и можно посмотреть шаблон в использовании. Он похож на AsyncCallback, используемый в шаблоне IAsyncResult, только принимает аргумент типа описанного выше интерфейса.

public delegate void AsyncOperationCompleted<TResult>(IAsyncOperation<TResult> operation);

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

class NaiveMath
{
  public IAsyncOperation<int> CalculateFibonacciNumberAsync(int index, AsyncOperationCompleted<int> callback, object state);
}

Если сравнивать с аналогичным классом на основе шаблона IAsyncResult, исчез метод EndCalculateFibonacciNumber, что облегчило интерфейс класса, а BeginCalculateFibonacciNumber получил новое имя, заимствованное из event-based-шаблона, так как метод с именем, начинающимся с Begin при отсутствии соответствующего ему метода, начинающегося с End, нелогичен.

А вот его использование (без каких бы то ни было проверок, только для иллюстрации).

class NaiveMathConsumer
{
  private NaiveMath        _math;
  private IAsyncOperation  _currentOperation;

  public void On_SomeUserActivity()
  {
    int index = AskUserForIndex();
    NaiveMath math = new NaiveMath();
    _currentOperation = math.CalculateFibonacciNumberAsync(
      index, On_CalculateFibonacciNumberCompleted, null);
  }

  private void On_CalculateFibonacciNumberCompleted(IAsyncOperation<int> operation)
  {
    try
    {
      // здесь не нужен экземпляр класса NaiveMath
      // мы получаем результат операции из самой операции
      int theResult = operation.GetResult();
      ShowToUser(theResult);
    }
    catch (Exception exc)
    {
      NotifyUserAboutError(exc);
    }
  }
  
  public void CancelCurrentOperation()
  {
    if (currentOperation != null)
      _currentOperation.Cancel();
  }
}

Посмотрим, что изменилось по сравнению с аналогичным примером на основе шаблона IAsyncResult.

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

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

public interface IAsyncOperation : IAsyncResult
{
  bool IsCancelable{ get; }
  void Cancel();
  void IsCanceled{ get; }

  void Abandon();
}

Назначение и использование метода Abandon не очевидны, и я хотел бы раскрыть этот аспект подробнее.

В подавляющем большинстве случаев (по крайней мере, на стороне клиента) вызов операций в не блокирующем режиме нужен для того, чтобы графический интерфейс пользователя не "зависал". В не блокирующем режиме запускаются потенциально долгие операции, это могут быть некоторые математические расчеты, как в приведенном выше примере или любая другая обработка данных. Но гораздо чаще встречается другой класс асинхронных операций - это операции, основанные на перекрывающемся вводе/выводе (overlapped I/O). Такие классы, как Socket и Stream, а также классы, на них основанные, например, прокси Web-служб, предоставляют асинхронные методы, основанные на перекрывающемся вводе/выводе.

Итеративные алгоритмы можно прервать на любой итерации, а с операциями, основанными на перекрывающемся вводе/выводе, не все так просто. Рассмотрим для примера взаимодействие клиента и сервера через сеть на основе сокетов (socket). Клиент отправляет серверу некоторый запрос и ожидает ответа. Клиент может запросить некоторые данные с сервера, может запросить сервер изменить некоторые данные. И тот, и другой запросы клиент физически отменить не может, он может только отказаться от результатов выполнения запросов, не обрабатывать их. В случае запроса на получение данных отказ от результата равносилен отмене запроса, здесь все просто, можно использовать метод Cancel. Отказ же от обработки результата изменения данных не отменит самих изменений, то есть использование метода Cancel в данном случае внесет путаницу.

Для чего вообще может понадобиться отказываться от результатов выполнения асинхронной операции? В основном это контроль за временем жизни экземпляра класса-потребителя. При вызове асинхронного метода потребитель отдает поставщику делегат, указывающий на один из своих методов (хотя это и не обязательно). Когда логическая жизнь потребителя заканчивается, например, когда пользователь закрывает соответствующую часть графического интерфейса пользователя, необходимо отписаться от всех событий во избежание утечек памяти и побочных эффектов, в том числе и от начатых асинхронных операций. Как уже упоминалось выше, шаблон IAsyncResult не обязывает потребителя передавать в асинхронный метод callback, но если он передан, то изменить эту ситуацию уже никак нельзя. Таким образом, метод Abandon шаблона AsyncOperation восполняет этот пробел, позволяя "отписаться" от уведомления о завершении асинхронной операции в любой момент. Теоретически, можно было наделить метод Cancel такой семантикой, что бы он отменял операцию, если ее можно отменить (запрос данных, итеративный алгоритм) и "отписывал" потребителя если отменить ее нельзя (запрос на изменение). Но такой подход мог бы только запутать использование метода Cancel и поэтому для отказа от результата, или если хотите, для "отписывания" от уведомления о завершении асинхронной операции был создан отдельный метод.

Итак, что мы имеем на данный момент по сравнению с event-based-шаблоном.

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

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

ПРИМЕЧАНИЕ

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

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

[AttributeUsage(AttributeTargets.Method, Inherited = false)]
public class SynchronizedCallbackAttribute : Attribute{}

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

ПРИМЕЧАНИЕ

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

Настало время добавить в шаблон уведомление о прогрессе операции. Самым простым вариантом было бы объявить следующее событие в интерфейсе IAsyncOperation.

event EventHandler<ProgressChangedEventArgs> ProgressChanged;

Но здесь есть две проблемы. Первая связана с самим событием. К тому моменту, когда объект, представляющий асинхронную операцию, будет доступен потребителю, и потребитель подпишется на это событие, операция может быть уже завершена. Вторая проблема касается информации, передаваемой в событии, - в случае ProgressChangedEventArgs это одно целочисленное значение. В большинстве случаев этого вполне достаточно, но бывают и случаи, когда необходима более подробная информация о прогрессе операции. Можно было бы ввести еще один аргумент обобщения для типа информации о прогрессе:

interface IAsyncOperation : IAsyncResult { ... }
interface IAsyncOperation<TResult, TProgress> : IAsyncOperation{ ... }

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

interface IAsyncOperation : IAsyncResult { ... }

interface IAsyncOperation<TResult> : IAsyncOperation { ... }

interface IAsyncOperation<TResult, TIntermediateResult> : IAsyncOperation<TResult>
{
  TIntermediateResult IntermediateResult { get; }
  bool BreakExecution{ get; set; }
}

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

delegate void AsyncOperationCompleted<TResult, TIntermediateResult>(
  IAsyncOperation<TResult, TIntermediateResult> operation);

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

class NaiveMath
{
  public IAsyncOperation<int, ProgressChangedEventArgs> CalculateFibonacciNumberAsync(
    int index, 
    AsyncOperationCompleted<int, ProgressChangedEventArgs> callback, 
    object state){ //... }
}


class NaiveMathConsumer
{
  private IAsyncOperation _currentOperation;

  public void On_SomeUserActivity()
  {
    int index = AskUserForIndex();
    NaiveMath math = new NaiveMath();
    math.CalculateFibonacciNumberAsync(
      index, On_CalculateFibonacciNumberCompleted, null) ;
  }

  public void CancelCurrentOperation()
  {
    if (currentOperation != null)
    {
      _currentOperation.Cancel();
    }
  }

  private void On_CalculateFibonacciNumberCompleted(
    IAsyncOperation<int, ProgressChangedEventArgs> operation)
  {
    try
    {
      if (!operation.IsCompleted)
      {
        ShowProgressToUser(operation.IntermediateResult);
      }
      else
      {
        int theResult = operation.GetResult();
        ShowToUser(theResult); 
      }
    }
    catch (Exception exc)
    {
      NotifyUserAboutError(exc);
    }
  }
}

Чтобы определить причину вызова метода обратного вызова, окончание операции или изменение состояния прогресса операции, используется свойство IsCompleted интерфейса IAsyncResult.

Сравнение шаблонов

Использование классом потребителем

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

class NotNaiveMath
{
  public IAsyncOperation<int> CalculateGDCAsync(
    int number1, int number2, AsyncOperationCompleted<int> callback,
    object state){...}

  public IAsyncOperation<int, int> CalculateFibonacciNumberAsync(
    int index, AsyncOperationProgressChangedOrCompleted<int, int> callback,
    object state){...}
    
  public IAsyncOperation<Double> CalculateExponentaByTeilorSeriesAsync(
    int x, AsyncOperationProgressChangedOrCompleted<double, double> callback, 
    object state){...}
}

Рассмотрим введенные методы подробней.

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

CalculateFibonacciNumberAsync - этот метод, как и в предыдущих примерах, вычисляет число Фибоначчи по индексу. Оповещает о прогрессе операции, используя значение типа int от 0 до 100 %%. Операция может быть прервана.

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

Теперь этот же класс, реализованный в соответствии с event-based-шаблоном.

class NotNaiveMath
{  
  public void  CalculateGCDAsync(int number1, int number2){}
  public event Action<object, CalculateGCDCompletedEventArgs> CalculateGCDCompleted;

  public void  CalculateFibonacciNumberAsync(int index, object state){}
  public event Action<object, CalculateFibonacciNumberCompletedEventArgs> CalculateFibonnaciNumberCompleted;
  public event Action<object, CalculateFibonacciNumberProgressChangedEventArgs> CalculateFibonnaciNumberProgressChanged;

  
  public void  CalculateExpByTeilorSeriesAsync(int x, object state){}
  public event Action<object, CalculateExpCompletedEventArgs> CalculateExpByTeilorSeriesCompleted;
  public event Action<object, CalculateExpByTeilorSeriesProgressChangedEventArgs> CalculateExpByTeilorSeriesProgressChanged;

  public void CancelAsync(object state){}
}

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

public class CalculateGCDCompletedEventArgs : AsyncCompletedEventArgs
{
  public CalculateGCDCompletedEventArgs(int gcd)
  {
    Gcd = gcd;
  }

  public int Gcd { get; private set; }
}


public class CalculateFibonacciNumberCompletedEventArgs : AsyncCompletedEventArgs
{
  public CalculateFibonacciNumberCompletedEventArgs(int fibonacciNumber)
  {
      FibonacciNumber = fibonacciNumber;
  }

  public int FibonacciNumber { get; private set; }
}


public class CalculateExpByTeilorSeriesCompletedEventArgs : AsyncCompletedEventArgs
{
  public CalculateExpCompletedEventArgs(double exp)
  {
    Exp = exp;
  }

  public double Exp{get; private set; }
}


public class CalculateExpByTeilorSeriesProgressChangedEventArgs : ProgressChangedEventArgs
{
  public CalculateExpByTeilorSeriesProgressChangedEventArgs(double incrementalResult)
  {
    _incrementalResult = incrementalResult;
  }

  public double IncrementalResult{ get; private set; }
}

В примере класса на основе event-based-шаблона используются нововведения C# 3.5 для уменьшения количества кода. Но, несмотря на это, первый раунд event-based-шаблон проиграл вчистую.

Второй раунд - использование класса-поставщика потребителем.

ПРЕДУПРЕЖДЕНИЕ

Весь код, не имеющий отношения к шаблонам, является псевдо кодом, то есть в нем нет обработки ошибок, и он не заботится о потокобезопасности.

Первым рассмотрим использование метода CalculateGCDAsync, реализованного в соответствии с шаблоном AsyncOperation.

class NotNaiveMathConsumer
{
  // интерфейс взаимодействия с GUI
  private View            _view;
  private IAsyncOperation _calculateGDCOperation;

  private void On_ViewWantToCalculateGCD(int number1, int number2)
  {
    NotNaiveMath math = new NotNaiveMath();
    _calculateGDCOperation = math.CalculateGCDAsync(number1, number2, On_CalculateGCDCompleted, new[]{number1, number2});
  }

  private void On_ViewWantToCancelGCDCalculation()
  {
    if (_calculateGDCOperation != null)
    {
      _calculateGDCOperation.Cancel();
    }
  }

  [SynchronizedCallback]
  private void On_CalculateGCDCompleted(IAsyncOperation<Int32> operation)
  {
    try
    {
      _view.SetGCDResult(operation.GetResult());
    }
    catch (NoGCDException exc)
    {
      int[] numbers = (int[])operation.AsyncState;
      _view.ReportError(string.Format("У пары чисел {0} и {1} нет НОД", numbers[0], numbers[1]));
    }
    finally
    {
      _calculateGDCOperation = null;
    }
  }
}

ПРИМЕЧАНИЕ

Метод обработки результата операции помечен атрибутом [SynchronizedCallback], следовательно, он будет вызван в контексте того же потока, в котором происходил вызов метода On_ViewWantToCalculateGCD.

Этот пример иллюстрирует вызов асинхронного метода, обработку результата по окончании его работы и прерывание исполнения асинхронной операции по запросу пользователя. Также здесь используется прием передачи контекста вызова в метод обработки результата через аргумент "state" асинхронного метода. Теперь то же самое для event-based-шаблона.

class NotNaiveMathConsumer
{
  // интерфейс взаимодействия с GUI
  private View         _view;
  private NotNaiveMath _math = new NotNaiveMath();

  private void On_ViewWantToCalculateGCD(int number1, int number2)
  {
    _math.CalculateGCDCompleted += On_CalculateGCDCompleted;
    _math.CalculateGCDAsync(number1, number2);
  }

  void On_CalculateGCDCompleted(
    object sender, CalculateGCDCompletedEventArgs args)
  {
    try
    {
      _math.CalculateGCDCompleted -= On_CalculateGCDCompleted;
      _view.SetGCDResult(args.Gcd);
    }
    catch (NoGCDException exc)
    {
      //...
    }
  }
}

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

Как уже говорилось выше, event-based-шаблон не предоставляет унифицированного механизма передачи контекста вызова. Если мы хотим в случае отсутствия НОД предоставить клиенту развернутое оповещение (см. блок обработки исключительной ситуации в методе-обработчике результата в примере для шаблона AsyncOperation), включающее числа, для которых производилось вычисление, мы будем вынуждены сохранять и эти числа как члены класса.

Можно заметить, что эти числа можно передать в метод обработки результата посредством класса CalculateGCDCompletedEventArgs. Но это будет не передача контекста, а жестко закодированная возможность. К тому же эта возможность потенциально избыточна в большинстве случаев, потому что контекст выполнения определяется потребностями класса-потребителя, тогда как класс CalculateGCDCompletedEventArgs входит в контракт класса-поставщика. Теоретически, мы могли бы воспользоваться аргументом userState, который используется в event-base шаблоне для идентификации асинхронных операций, для передачи контекста вызова. Например, создав класс, который бы гарантировал надежную идентификацию и позволял передавать дополнительные данные. Но в данном случае мы определили метод CalculateGCDAsync как нереентерабельный, а у таких методов, согласно идеологии event-based-шаблона, нет аргумента userState.

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

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

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

private void On_ViewWantToCalculateFibonacciNumbers(int index1, int index2, int index3)
{
  NotNaiveMath math = new NotNaiveMath();

  // не передаем метод обратного вызова при начале первых двух операций
  IAsyncOperation<int, int> operation1 = math.CalculateFibonacciNumberAsync(
    index1, null, null);
  IAsyncOperation<int, int> operation2 = math.CalculateFibonacciNumberAsync(
    index2, null, null);

  // передаем дескрипторы первых двух операций через аргумент state  
  math.CalculateFibonacciNumberAsync(
    index2, On_CalculateFibonacciNumberCompleted, 
    new IAsyncOperation<int, int>[]{operation1, operation2});
}

private void On_CalculateFibonacciNumberCompleted(
  IAsyncOperation<int, int> operation)
{
  try
  {
    // прогресс операции нас в данном случае не интересует
    if (operation.IsCompleted)
    {
      // извлекаем дескрипторы первых двух операций
      IAsyncOperation<int, int>[] operations = (
        IAsyncOperation<int, int>[])operation.AsyncState;
      // ожидаем их окончания
      WaitHandle.WaitAll(
        new WaitHandle[]{operations[0].AsyncWaitHandle, 
        operations[1].AsyncWaitHandle});
      _view.Invoke(
         _view.SetFibonacciResult, 
         new int []{operations[0].GetResult(), 
         operations[1].GetResult(), operation.GetResult()});
    }
  }
  catch (Exception exc)
  {
    _view.Invoke(_view.ReportError, new object[]{exc.Message});
  }
}

Вызывая метод CalculateFibonacciNumberAsync для первых двух индексов, мы вообще не указываем метод обработки результата, вместо этого сохраняя возвращенные методом дескрипторы асинхронных операций. При вызове метода CalculateFibonacciNumberAsync для третьего индекса, мы указываем метод On_CalculateFibonacciNumberCompleted как метод обработки результата, а два дескриптора операций передаем через аргумент state. Таким образом, они будут нам доступны в методе On_CalculateFibonacciNumberCompleted, для того чтобы дождаться окончания их выполнения. Метод On_CalculateFibonacciNumberCompleted не помечен атрибутом [SynchronizedCallback] и, следовательно, будет вызван не в контексте GUI-потока и не заблокирует графический интерфейс пользователя, пока мы ожидаем окончания выполнения остальных операций. Правда, при этом приходится заботиться о том, чтобы вызывать методы View в контексте нужного потока, что отображено вызовом метода View.Invoke в псевдокоде.

Попробуем реализовать то же самое с помощью event-based-шаблона. Как уже упоминалось выше, event-based-шаблон предполагает, что методы-обработчики событий автоматически вызываются в контексте того потока, из которого был вызван метод CalculateFibonacciNumberAsync, то есть в нашем случае в контексте GUI-потока. Следовательно, мы не можем использовать простую схему, использованную выше. То есть, мы не можем при обработке результата одной из операций, с помощью каких-либо событий, дождаться окончания двух других, потому что случится не просто зависание графического интерфейса пользователя, а взаимоблокировка. Поэтому приходится использовать менее тривиальные способы, например, такой:

private NotNaiveMath _math                   = new NotNaiveMath();
private Object       _fibonacciResultsAccess = new Object();
private List<int>    _fibonacciResults        = new List<int>();

private void On_ViewWantToCalculateFibonacciNumbers(
  int index1, int index2, int index3)
{
  _math.CalculateFibonnaciNumberCompleted 
    += On_CalculateFibonnaciNumberCompleted;
  _math.CalculateFibonacciNumberAsync(index1, null);
  _math.CalculateFibonacciNumberAsync(index2, null);
  _math.CalculateFibonacciNumberAsync(index2, null);
}

void On_CalculateFibonnaciNumberCompleted(
  object sender, CalculateFibonacciNumberCompletedEventArgs args)
{
  try
  {
    lock (_fibonacciResultsAccess)
    {
      _fibonacciResults.Add(args.FibonacciNumber);
      if (_fibonacciResults.Count == 3)
      {
        _math.CalculateFibonnaciNumberCompleted 
          -= On_CalculateFibonnaciNumberCompleted;
        _view.SetFibonacciResult(_fibonacciResults[0], 
          _fibonacciResults[1], _fibonacciResults[2]);
      }
    }
  }
  catch (Exception exc)
  {
    _view.ReportError(exc.Message);
  }
}

Можно придумать еще несколько способов добиться того же результата, но все они будут лишь обходными маневрами. На самом деле, в event-based-шаблоне есть целый комплекс недостатков, вытекающих из одной единственной причины - отсутствия четко определенной абстракции асинхронной операции - то, что в шаблоне AsyncOperation представлено интерфейсом IAsyncOperation. Именно наличие такой абстракции сильно упрощает жизнь в большом количестве случаев. Взять тот же часто применяемый метод опроса состояний операций (polling). Вот так он будет реализован при помощи шаблона AsyncOperation:

List<IAsyncOperation> operations = ...;
//...
foreach(IAsyncOperation operation in operations)
{
  if(operation.IsCompleted)
  {
    Do_Something(operation);
    //...
  }
}

Или так.

Dictionary<WaitHandle, IAsyncOperation> operations = ...;
WaitHandle[] events = operations.Keys;
//...
IAsyncOperation operation = operations[events[WaitHandle.WaitAny(events)]];
Do_Something(operation);
//...

Чтобы реализовать нечто подобное при помощи event-based-шаблона, придется вводить абстракцию операции самостоятельно, в отрыве от шаблона. В шаблоне AsyncOperation операция централизовано предоставлена самим шаблоном и глубоко с ним интегрирована. Счет 3:0, двигаемся дальше.

Пришло время посмотреть, как пользоваться оповещением о прогрессе исполнения операции. Для иллюстрации будет использован метод, вычисляющий значение экспоненциальной функции методом рядов Тейлора. Столь экзотичный метод был выбран потому, что в этом методе от количества итераций зависит точность вычислений. Проще говоря, данный метод на каждой итерации дает "корректный" промежуточный результат с некоторой точностью. Как обычно, сначала пример реализации с шаблоном AsyncOperation.

private void On_ViewWantToCalculateExponenta(int x)
{
  NotNaiveMath math = new NotNaiveMath();
  math.CalculateExponentByTeilorSeriesAsync(
    x, On_CalculateExponentaByTeilorSeriesComleted, null);
}

[SynchronizedCallback]
private void On_CalculateExponentaByTeilorSeriesComleted(
  IAsyncOperation<double, double> operation)
{
  double? result = null;

  if (operation.IsCompleted)
    result = operation.GetResult();
  else if (IsAccuracyEnough(...))
  {
    result = operation.IntermediateResult;
    operation.BreakOperation = true;
  }

  if (result.HasValue)
  {
    _view.SetExponentResult(result);
  }
}

И для обработки окончательного результата, и для обработки прогресса исполнения/промежуточного результата используется один и тот же метод-обработчик. Чтобы понять, по какой причине он вызван, используется свойство IsCompleted интерфейса IAsyncResult. В качестве аргумента метод-обработчик принимает перегрузку интерфейса IAsyncOperation с двумя аргументами обобщения. Этот интерфейс, кроме прочего, содержит свойство BreakOperation булева типа. Если в обработчике присвоить этому свойству значение true, то исполнение асинхронной операции будет прервано на этой итерации, что и проиллюстрировано в данном примере.

Теперь то же самое для event-based-шаблона .

private NotNaiveMath _math = new NotNaiveMath();
private object       _calculateExponentId;

private void On_ViewWantToCalculateExponenta(int x)
{
  _math.CalculateExpByTeilorSeriesCompleted 
    += On_CalculateExpByTeilorSeriesCompleted;
  _math.CalculateExpByTeilorSeriesProgressChanged 
    += On_CalculateExpByTeilorSeriesProgressChanged;
  _calculateExponentId = new object();
  _math.CalculateExponentByTeilorSeriesAsync(x, _calculateExponentId);
}

void On_CalculateExpByTeilorSeriesProgressChanged(
  object sender, CalculateExpByTeilorSeriesProgressChangedEventArgs args)
{
  if (IsAccuracyEnough(...))
  {
    _math.CalculateExpByTeilorSeriesCompleted 
      -= On_CalculateExpByTeilorSeriesCompleted;
    _math.CalculateExpByTeilorSeriesProgressChanged 
      -= On_CalculateExpByTeilorSeriesProgressChanged;
    _math.CancelAsync(_calculateExponentId);
    _view.SetExponentResult(args.IncrementalResult);
  }
}

void On_CalculateExpByTeilorSeriesCompleted(
  object sender, CalculateExpByTeilorSeriesCompletedEventArgs args)
{
  _math.CalculateExpByTeilorSeriesCompleted 
    -= On_CalculateExpByTeilorSeriesCompleted;
  _math.CalculateExpByTeilorSeriesProgressChanged 
    -= On_CalculateExpByTeilorSeriesProgressChanged;
  _view.SetExponentResult(args.Exp);
}

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

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

Во всех сценариях использования шаблонов классом-потребителем шаблон AsyncOperation на голову превзошел своего конкурента от Microsoft. Возможно, код, иллюстрирующий применение event-based-шаблона , выглядит слегка небрежным, а решения - не оптимальными. Но это не от того, что я специально ставил event-based-шаблон в невыгодное положение. Основная проблема иллюстрирования применения event-based-шаблона заключается в том, что для реализации всего "по уму" придется написать много дополнительного кода. Кроме того, реализация будет очень сильно зависеть от деталей взаимодействия класса-поставщика со своим клиентом, то есть View. При применении шаблона AsyncOperation, напротив, код получается слабо связанным с деталями реализации и зависит только от бизнес-логики. Достигается это тем, что взаимодействие с классом-поставщиком сведено к минимуму, а так же возможностью элегантно передавать контекст вызова асинхронного метода в метод (или место) обработки результата операции. По сути, класс-поставщик используется только для создания экземпляра операции, дальнейшее же общение происходит именно с этим экземпляром. Вообще говоря, использование шаблона AsyncOperation потребителем мало чем отличается от использования шаблона IAsyncResult, так что любой, кто пользовался шаблоном IAsyncResult, с легкостью сможет освоиться и с шаблоном AsyncOperation.

Использование классом поставщиком

Рассмотрим теперь реализацию классов-поставщиков. Начнем, как обычно, с шаблона AsyncOperation. Код сопровожден поясняющими комментариями.

ПРЕДУПРЕЖДЕНИЕ

Чтобы вычисления выполнялись в отдельном потоке, в примерах используется "Асинхронное программирование с использованием делегатов" (Asynchronous Programming Using Delegates http://msdn.microsoft.com/ru-ru/library/22t547yb.aspx). То есть для вызова метода в контексте отдельного потока используется метод BeginInvoke делегата. Асинхронное программирование с использованием делегатов само основано на шаблоне IAsyncResult, и объекты типа IAsyncResult будут использоваться для ожидания завершения потока. Пусть использование объектов типа IAsyncResult не введет вас в заблуждение, в данном случае это всего лишь детали данной конкретной реализации (аналогичный пример на основе event-based-шаблона тоже будет использовать асинхронное программирование с использованием делегатов) и не имеет отношения к шаблону AsyncOperation. Теоретически, можно было использовать, класс Thread вместо делегатов. Асинхронное программирование с использованием делегатов используется в примерах исключительно из-за его лаконичности и простоты для понимания.

public class NotNaiveMath
{
  // С каждой операцией будет связан экземпляр этого класса
  private class OperationAux
  {
    public IAsyncResult AsyncResult {get; set;}
    public bool         Cancel      {get; set;}
  }


  private int _calculateGCDStarted;

  public IAsyncOperation<int> CalculateGCDAsync(
    int number1, int number2, 
    AsyncOperationCompleted<int> callback, object state)
  {
    // мы определили этот метод как нереентерабельный, соответственно,
    // одновременно может исполнятся только одна такая операция
    if (Interlocked.CompareExchange(
      ref _calculateGCDStarted, 1, _calculateGCDStarted) != 0)
    {
      throw new InvalidOperationException(
        "Эта операция уже начата, дождитесь ее окончания.");
    }

    OperationAux aux = new OperationAux();

    // Создаем дескриптор операции указывая, что ее можно прервать,
    // и передаем метод который будет вызван, если потребитель вызовет 
    // прерывание операции. Передаем дополнительные данные как контекст вызова.
    AsyncOperation<int> operation = new AsyncOperation<int>(
      callback, state, true, CancelCalculateGCDOperation, aux);

    // Начинаем вычисления, передаем операцию и дополнительные данные, 
    // сохраняем IAsyncResult в дополнительных данных
    Action<AsyncOperation<int>, OperationAux, int, 
      int> @delegate = CalculateGCD;
    aux.AsyncResult = @delegate.BeginInvoke(
      operation, aux, number1, number2, null, null);

    return operation;
  }

  private void CalculateGCD(
    AsyncOperation<int> operation, OperationAux aux, int number1, int number2)
  {
    int result = 1;
    while (number2 != 0 && !aux.Cancel)
    {
      result = number1 % number2;
      number1 = number2;
      number2 = result;        
    }
    result = Math.Abs(result);

    // Если операция была прервана, ничего делать не нужно, 
    // реализация AsyncOperation обо всем позаботится сама
    if (!operation.IsCanceled)
    {
      // Заканчиваем операцию, указав результат или ошибку
      operation.SetAsCompleted(
        result, result == 1 ? new ArgumentException(
          "Не найден НОД больше единицы.") : null);
    }
  }

  // Этот метод будет вызван если потребитель 
  // вызовет метод IAsyncOperation.Cancel
  private void CancelCalculateGCDOperation(AsyncOperation<int> operation)
  {
    // Вынимаем дополнительные данные переданные в операцию при создании
    OperationAux aux = (OperationAux)operation.ProviderState;
    // Выставляем флаг прерывания операции в true
    aux.Cancel = true;
    // И дожидаемся выхода из метода CalculateFibonacciNumber
    aux.AsyncResult.AsyncWaitHandle.WaitOne();

    // отмечаем, что операция завершена
    _calculateGCDStarted = 0;
  }
}

Как видите, весь код, относящийся к реализации шаблона AsyncOperation, сводится к созданию экземпляра класса AsyncOperation<TResult> и вызову метода SetAsCompleted по окончанию вычислений. Реализация на основе event-based-шаблона мало чем отличается в этом плане.

class NotNaiveMath
{
  private          AsyncOperation _calculateGCDOperation;
  private readonly object         _calculateGCDOperationAccess = new object();

  public void CalculateGCDAsync(int number1, int number2)
  {
    lock (_calculateGCDOperationAccess)
    {
      // мы определили этот метод как нереентерабельный, соответственно,
      // одновременно может исполнятся только одна такая операция
      if (_calculateGCDOperation != null)
      {
        throw new InvalidOperationException(
          "Эта операция уже начата, дождитесь ее окончания.");
      }

      // создаем экземпляр класса System.ComponentModel.AsyncOperation. 
      // Несмотря на свое название, этот класс не представляет собой 
      // асинхронную операцию, а скорее является оберткой 
      // над классом SynchronizationContext
      _calculateGCDOperation = AsyncOperationManager.CreateOperation(null);

      Action<int, int> @delegate = CalculateGCD;
      @delegate.BeginInvoke(number1, number2, null, null);
    }
  }

  public event Action<Object, 
    CalculateGCDCompletedEventArgs> CalculateGCDCompleted;

  private void CalculateGCD(int number1, int number2)
  {
    Exception exception = null;
    int result = 1;
    try
    {
      while (number2 != 0)
      {
        result = number1 % number2;
        number1 = number2;
        number2 = result;
      }
      result = Math.Abs(result);
      if (result == 1)
      {
        exception = new ArgumentException("Не найден НОД больше единицы.");
      }
    }
    catch (Exception exc)
    {
      exception = exc;
    }
    finally
    {
      // заканчиваем операцию, указав результат или ошибку, 
      // вызвав метод CalculateGCDCompletionMethod
      _calculateGCDOperation.PostOperationCompleted(
        CalculateGCDCompletionMethod,
        new CalculateGCDCompletedEventArgs(
        exception, false, _calculateGCDOperation.UserSuppliedState, result));
    }
  }

  private void CalculateGCDCompletionMethod(object eventArgs)
  {
    Action<object, CalculateGCDCompletedEventArgs> @delegate = 
      CalculateGCDCompleted;
    if (@delegate != null)
      @delegate(this, (CalculateGCDCompletedEventArgs)eventArgs);
    _calculateGCDOperation = null;
  }
}

Из отличий можно отметить необходимость создания экземпляров классов-наследников AsyncCompletedEventArgs, а также то, что метод PostOperationCompleted принимает делегат типа SendOrPostCallback. Это не позволяет передать в него непосредственно событие, а вынуждает создать дополнительный метод или воспользоваться анонимным методом. Но за исключением этих мелочей код идентичен. Все сводится к созданию некоторого объекта в начале операции и вызову метода обработки результата, предоставленного потребителем в конце. Ну и надо помнить, что в event-based-шаблоне нельзя прервать нереентерабельную операцию, поэтому код прерывания в примере отсутствует.

Теперь посмотрим на реализацию реентерабельных методов, которые можно прервать. Шаблон AsyncOperation:

public IAsyncOperation<int, int> CalculateFibonacciNumberAsync(
  int index, AsyncOperationCompleted<int, int> callback, object state)
{
  OperationAux aux = new OperationAux();

  // Создаем дескриптор операции указывая, что ее можно прервать, 
  // и передаем метод который будет вызван, если
  // потребитель вызовет прерывание операции. 
  // Передаем дополнительные данные как контекст вызова.
  AsyncOperation<int, int> operation = new AsyncOperation<int, int>(
    callback, state, true, CancelCalculateFibonacciNumber, aux);

  Action<AsyncOperation<int, int>, OperationAux, int> @delegate = 
    CalculateFibonacciNumber;
  // Начинаем вычисления, передаем операцию и дополнительные данные, 
  // сохраняем IAsyncResult в дополнительных данных
  aux.AsyncResult = @delegate.BeginInvoke(operation, aux, index, null, null);

  // Отдаем операцию потребителю
  return operation;
}

private void CalculateFibonacciNumber(
  AsyncOperation<int, int> operation, OperationAux aux, int index)
{
  Exception exception = null;
  int result = 0;
  try
  {
    if (index <= 2)
      result = 1;
    else
    {
      int n1 = 1;
      int n2 = 1;
      result = 0;

      int progressStep = 100 / index;
      int progress = progressStep;

      // На каждой итерации проверяем не надо-ли прервать операцию
      for (int i = 3; i <= index && !aux.Cancel && !operation.BreakExecution;
        i++, progress += progressStep)
      {
        result = n2 + n1;
        n2 = n1;
        n1 = result;

        // Передаем информацию о прогрессе операции
        operation.SetIntermediateResult(progress);
      }
    }
  }
  catch (Exception exc)
  {
    exception = exc;
  }
  finally
  {
    // Если операция была прервана, ничего делать не нужно, 
    // реализация AsyncOperation обо всем позаботится сама
    if (!operation.IsCanceled)
    {
      // Заканчиваем операцию указав результат или ошибку
      operation.SetAsCompleted(result, exception);
    }
  }
}

private void CancelCalculateFibonacciNumber(AsyncOperation<int> operation)
{
  // Вынимаем дополнительные данные переданные в операцию при создании
  OperationAux aux = (OperationAux)operation.ProviderState;
  // Выставляем флаг прерывания операции в true
  aux.Cancel = true;
  // И дожидаемся выхода из метода CalculateFibonacciNumber
  aux.AsyncResult.AsyncWaitHandle.WaitOne();
}

Event-based-шаблон:

// С каждой операцией будет связан экземпляр этого класса
private class OperationAux
{
  public OperationAux(AsyncOperation asyncOperation)
  {
    AsyncOperation = asyncOperation;
  }

  public AsyncOperation AsyncOperation {get; private set;}
  public bool           Cancel         {get; set;}
}

// Множество всех начатых операций
private readonly Dictionary<object, OperationAux> 
  _pendingOperations = new Dictionary<object, OperationAux>();
private readonly object _pendingOperationsAccess = new object();

public void CalculateFibonacciNumberAsync(int index, object state)
{
  OperationAux operationAux;
  lock (_pendingOperationsAccess)
  {
    // Идентификатор операции, по замыслу Microsoft 
    // передается потребителем, поэтому надо быть бдительным
    if (state == null // _pendingOperations.ContainsKey(state))
    {
      throw new ArgumentException(
        "Аргумент state должен уникально идентифицировать операцию.", 
        "state");
    }

    // Создаем экземпляр класса System.ComponentModel.AsyncOperation
    AsyncOperation operation = AsyncOperationManager.CreateOperation(state);
    // Создаем дополнительные данные и связываем их с операцией
    operationAux = new OperationAux(operation);
    _pendingOperations.Add(state, operationAux);
  }

  // Начинаем вычисления
  Action<OperationAux, int> @delegate = CalculateFibonacciNumber;
  @delegate(operationAux, index);
}

public event ProgressChangedEventHandler 
  CalculateFibonacciNumberProgressChanged;
public event Action<Object, CalculateFibonacciNumberCompletedEventArgs> 
  CalculateFibonnaciNumberCompleted;

private void CalculateFibonacciNumber(OperationAux aux, int index)
{
  Exception exception = null;
  int result = 0;
  try
  {
    if (index <= 2)
    {
      result = 1;
    }
    else
    {
      int n1 = 1;
      int n2 = 1;
      result = 0;

      int progressStep = 100 / index;
      int progress = progressStep;

      // На каждой итерации проверяем, не надо ли прервать операцию
      for (int i = 3; i <= index && !aux.Cancel; i++, progress += progressStep)
      {
        result = n2 + n1;
        n2 = n1;
        n1 = result;

        // Передаем информацию о прогрессе операции
        aux.AsyncOperation.Post(
          ReportCalculateFibonacciNumberProgress, 
          new ProgressChangedEventArgs(
            progress, 
            aux.AsyncOperation.UserSuppliedState));
      }
    }
  }
  catch (Exception exc)
  {
    exception = exc;
  }
  finally
  {
    // Заканчиваем операцию указав результат или ошибку
    aux.AsyncOperation.PostOperationCompleted(
      CalculateFibonacciNumberCompletionMethod,
      new CalculateFibonacciNumberCompletedEventArgs(
        exception, 
        aux.Cancel, 
        aux.AsyncOperation.UserSuppliedState, 
        result));
  }
}

private void ReportCalculateFibonacciNumberProgress(object eventArgs)
{
  ProgressChangedEventHandler @delegate = 
    CalculateFibonacciNumberProgressChanged;
  if (@delegate != null)
  {
    @delegate(this, (ProgressChangedEventArgs)eventArgs);
  }
}

private void CalculateFibonacciNumberCompletionMethod(object eventArgs)
{
  Action<Object, CalculateFibonacciNumberCompletedEventArgs> @delegate = 
    CalculateFibonnaciNumberCompleted;
  if (@delegate != null)
  {
    @delegate(this, (CalculateFibonacciNumberCompletedEventArgs) eventArgs);
  }
}

public void CancelAsync(Object state)
{
  lock (_pendingOperationsAccess)
  {
    OperationAux operationAux;
    if (_pendingOperations.TryGetValue(state, out operationAux))
    {
      // Выставляем флаг прерывания операции в true и выходим из метода
      operationAux.Cancel = true;
      _pendingOperations.Remove(state);
    }
  }
}

Из-за отсутствия возможности хранения контекста вызова в самой операции (в данном случае контекста поставщика), связывание операции и дополнительных данных приходится производить с помощью поля типа Dictionary. Это не только добавляет поле, но и заставляет заботиться о потокобезопасном доступе к нему. В целом, хотя реализация поставщиков на основе обоих шаблонов очень схожа, все же надо отметить, что код на основе шаблона AsyncOperation более чист и лаконичен. Я старался сделать таким и код на основе event-based-шаблона. Как выглядит класс, предоставляющий всего одну асинхронную операцию в примере, предлагаемом Microsoft для подражания, вы можете ознакомиться по этой ссылке http://msdn.microsoft.com/ru-ru/library/9hk12d4y.aspx

Реализация

Реализация шаблона AsyncOperation состоит из двух классов - реализации интерфейсов IAsyncOperation<TResult> и IAsyncOperation<TResult, TIntermediateResult>. Код этих классов достаточно тривиален и сопровожден подробными комментариями. Так что в статье не будет подробного разбора реализации, но несколько слов о том, почему были приняты те или иные решения, все-таки необходимо сказать.

Рассмотрим подробнее процесс прерывания операции. Первое решение, которое необходимо принять - нужно ли оповещать потребителя, что операция прервана или достаточно вызвать метод Cancel и забыть об этой операции. С одной стороны, напрашивается решение "отменил и забыл", но с другой, логика приложений бывает разной, в том числе и достаточно сложной. К примеру, операция может быть прервана "третьим" по отношению к потребителю, начавшему операцию, "лицом". В таком случае было бы желательно, чтобы потребитель был оповещен о том, что операция была прервана. Или другой, более вероятный вариант - необходимо отображать в GUI процесс отмены операции (отобразить что-то наподобие "Отмена" с песочными часами и заблокировать соответствующие элементы управления), дождаться, когда операция прервется, и привести состояние графического интерфейса пользователя в соответствие с состоянием модели.

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

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

Бывают и обратные ситуации, когда метод обратного вызова передан, а оповещение о прерывании операции потребителя не интересует. Можно завести перегрузку метода Cancel с параметром, например, так:

void Cancel(bool abandon);

или даже завести дополнительный метод

void AbandonAndCancel();

Но в данной реализации было принято решение не делать этого, дабы не плодить сущности, а в случае необходимости вызывать метод Abandon перед вызовом Cancel.

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

Также стоит отметить, что в реализации шаблона не используются возможности C# и .NET выше второй версии.

Заключение

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

Возможности AsyncOperation EventBased
Открытый интерфейс класса-поставщика. Дополнительные объявления. Один метод. Дополнительных объявлений не требуется. Метод и событие. Если операция оповещает о прогрессе исполнения, добавляется еще одно событие. Плюс один метод прерывания операций на класс.Необходимо объявлять класс-наследник AsyncCompletedEventArgs, используемый в типе события об окончании операции. Дополнительно, для операций, оповещающих о прогрессе исполнения, нужен класс, используемый в типе события об изменении прогресса.
Прерывание операций. Можно прерывать любые операции, логика которых это позволяет. Нереентерабельные операции прерывать нельзя вследствие идеологии шаблона, даже если по логике эту операцию прервать можно.
Идентификация операций. Существует абстракция асинхронной операции, представленная интерфейсом IAsyncOperation и его наследниками, идентифицирующая сама себя. Идентификатор операции предоставляется потребителем, а используется как потребителем, так и поставщиком.
Передача пользовательских данных (контекста вызова) в место обработки результата операции. Через свойство IAsyncResult.AsyncState для потребителя. Через AsyncOperation<TResult>.ProviderState для поставщика. Отсутствует. В каждом случае придется реализовывать это самостоятельно.
Обработка состояния операций отличная от предоставления метода обратного вызова, например, опрос (polling). Обработка третьим, по отношению к потребителю "лицом". Через IAsyncResult.IsCompleted или через IAsyncResult.AsyncWaitHandle. Отсутствует. Для реализации потребуется вводить абстракцию асинхронной операции самостоятельно.
Отказ от вызова метода обратного вызова. Метод IAsyncOperation.Abandon Отписывание от соответствующего события.
Контроль за тем в контексте какого потока вызывать методы обратного вызова. Потребитель решает, в контексте какого потока вызывать метод обратного вызова, через SynchronizationContext или в контексте потока взятого из пула потоков. Зависит от реализации шаблона. Рекомендуемая Microsoft реализация вызывает методы обратного вызова через экземпляр SynchronizationContext.
Использование поставщиком Сводится к созданию экземпляра класса AsyncOperation<>, вызову метода SetAsCompleted, и проставлению свойства IntermediateResult. Сводится к созданию экземпляра класса AsyncOperation, и вызову методов Post и PostOperationCompleted. Требует создания экземпляров классов наследующих EventArgs при оповещении потребителя. Сам код чуть менее лаконичен.

Практика использования шаблона AsyncOperation показала его удобство, гибкость и мощь, особенно в случаях, когда каждый слой приложения должен реализовать шаблон асинхронного программирования. Если даже по какой-то причине, вы не можете использовать его вместо event-based-шаблона, им всегда можно заменить устаревший шаблон IAsyncResult, который Microsoft рекомендует для использования в "классах нижнего уровня", а также в приложениях, критичных к производительности (http://msdn.microsoft.com/ru-ru/library/ms228966(en-us,VS.80).aspx).


Страница сайта http://www.interface.ru
Оригинал находится по адресу http://www.interface.ru/home.asp?artId=21513