Книга: Конкурентность в C#. Асинхронное, параллельное и многопоточное программирование. 2-е межд. изд.
Назад: Приложение А. Поддержка унаследованных платформ
Дальше: Об авторе

Приложение Б. Распознавание и интерпретация асинхронных паттернов

Преимущества асинхронного кода были хорошо понятны за много лет до изобретения .NET. На заре существования .NET было разработано несколько разных стилей асинхронного кода. Они использовались тут и там, но в итоге от них отказались. Нельзя сказать, что все идеи были плохими; многие из них проложили путь к современному подходу с async/await. Но есть еще много старого кода, в котором используются более старые асинхронные паттерны. В этом приложении рассматриваются наиболее распространенные паттерны; я объясню, как они работают и интегрируются с современным кодом.

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

class Socket

{

  // Синхронный

  public int Send(byte[] buffer, int offset, int size, SocketFlags flags);

 

  // APM

  public IAsyncResult BeginSend(byte[] buffer, int offset, int size,

      SocketFlags flags, AsyncCallback callback, object state);

  public int EndSend(IAsyncResult result);

 

  // Специализированный, очень близок к APM

  public IAsyncResult BeginSend(byte[] buffer, int offset, int size,

      SocketFlags flags, out SocketError error,

      AsyncCallback callback, object state);

  public int EndSend(IAsyncResult result, out SocketError error);

 

  // Специализированный

  public bool SendAsync(SocketAsyncEventArgs e);

  // TAP (как метод расширения)

  public Task<int> SendAsync(ArraySegment<byte> buffer,

      SocketFlags socketFlags);

 

  // TAP (как метод расширения) с использованием более эффективных типов

  public ValueTask<int> SendAsync(ReadOnlyMemory<byte> buffer,

      SocketFlags socketFlags, CancellationToken cancellationToken =

         default);

}

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

Асинхронный паттерн на основе Task (TAP)

Асинхронный паттерн на основе Task (TAP) — современный паттерн асинхронных API, готовый для использования с await. Каждая асинхронная операция представляется одним методом, который возвращает ожидаемый тип. В данном случае «ожидаемым» является любой тип, который может потребляться await; обычно это Task или Task<T>, но им также может быть ValueTask, ValueTask<T>, тип, определенный фреймворком (например, IAsyncAction или IAsyncOperation<T>, используемый приложениями Universal Windows), или даже специализированный тип, определяемый библиотекой.

Методы TAP обычно снабжаются суффиксом Async. Впрочем, это всего лишь условное соглашение; не все методы TAP имеют суффикс Async. Он может отсутствовать, если разработчик API считает, что асинхронный контекст и так выражен достаточно ясно; например, у методов Task.WhenAll и Task.WhenAny нет суффикса Async. Кроме того, следует помнить, что суффикс Async может присутствовать у методов, к TAP не относящихся (например, WebClient.DownloadStringAsync не является методом TAP).Обычно в таких случаях метод TAP имеет суффикс TaskAsync (например, WebClient.DownloadStringTaskAsync является методом TAP).

Методы, возвращающие асинхронные потоки, также следуют похожему паттерну с использованием суффикса Async. И хотя они не возвращают ожидаемые объекты, они возвращают ожидаемые потоки — типы, которые могут потребляться конструкцией awaitforeach.

Паттерн TAP можно узнать по следующим характеристикам:

1. Операция представляется одним методом.

2. Операция возвращает ожидаемый объект или ожидаемый поток.

3. Имя метода обычно завершается суффиксом Async.

Пример типа с TAP API:

class ExampleHttpClient

{

  public Task<string> GetStringAsync(Uri requestUri);

  // Синхронный эквивалент для сравнения

  public string GetString(Uri requestUri);

}

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

Модель асинхронного программирования (APM)

Вероятно, следующим по популярности после TAP является паттерн модели асинхронного программирования, или APM (Asynchronous Programming Model). Это был первый паттерн, в котором асинхронные операции получили полноценные объектные представления. Характерный признак этого паттерна — объекты IAsyncResult в сочетании с парой методов, управляющих операцией; имя одного начинается с Begin, а имя другого — с End.

На разработку IAsyncResult сильно повлиял платформенный ввод/вывод с перекрытием. Паттерн APM позволяет потреблять код с синхронным или асинхронным поведением. Потребляющий код может выбирать из следующих вариантов:

• Блокироваться до завершения операции. Это делается вызовом метода End.

• Периодически опрашивать завершение операции, занимаясь чем-то другим.

• Предоставить делегата обратного вызова, который должен вызываться при завершении операции.

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

Метод Begin получает в двух последних позициях параметр AsyncCallback и параметр object (обычно с именем state). Они используются потреб­ляющим кодом для передачи делегата обратного вызова, который должен вызываться при завершении операции. Параметр object может содержать что угодно; это пережиток самых первых дней существования .NET, еще до появления лямбда-методов и даже анонимных методов. Он просто используется для предоставления контекста для параметра AsyncCallback.

Паттерн APM широко распространен в библиотеках Microsoft, но в экосистеме .NET встречается нечасто. Это объясняется тем, что реализации IAsyncResult для повторного использования были недоступны, а правильно реализовать этот интерфейс достаточно сложно. Кроме того, системы на базе APM трудно включать в композицию. Я видел несколько нестандартных реализаций IAsyncResult; все они были разновидностями реализации IAsyncResult общего назначения, разработанной Джеффри Рихтером (Jeffrey Richter) и опубликованной в его статье «Concurrent Affairs: Implementing the CLR Asynchronous Programming Model» из MSDN Magazine в марте 2007 года.

Паттерн APM можно узнать по следующим характеристикам:

1. Операция представляется парой методов; имя одного начинается с Begin, а имя другого — с End.

2. Метод Begin возвращает IAsyncResult и получает все обычные входные параметры наряду с дополнительным параметром AsyncCallback и дополнительным параметром object.

3. Метод End получает только IAsyncResult и возвращает результирующее значение, если оно есть.

Пример типа с APM API:

class MyHttpClient

{

  public IAsyncResult BeginGetString(Uri requestUri,

      AsyncCallback callback, object state);

  public string EndGetString(IAsyncResult asyncResult);

 

  // Синхронный эквивалент для сравнения

  public string GetString(Uri requestUri);

}

Потребление паттерна APM осуществляется преобразованием в TAP с помощью Task.Factory.FromAsync; см. рецепт 8.2 и документацию Microsoft ().

Возможны ситуации, в которых код почти следует паттерну APM; например, старые клиентские библиотеки Microsoft.TeamFoundation не включали параметр object в свои методы Begin. В таких случаях Task.Factory.FromAsync работать не будет, и у вас появляется выбор из двух вариантов. Менее эффективный вариант — вызов метода Begin и передача IAsyncResult методу FromAsync. Менее элегантный вариант — использование более гибкого типа TaskCompletionSource<T>; см. рецепт 8.3.

Асинхронный паттерн на основе событий (EAP)

Асинхронный паттерн на основе событий (EAP) определяет пару «метод/событие». Имя метода обычно завершается суффиксом Async, и он в конечном итоге приводит к выдаче события, имя которого завершается суффиксом Completed.

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

Паттерн EAP можно узнать по следующим характеристикам:

1. Операция представляется событием и методом.

2. Имя события завершается суффиксом Completed.

3. Тип аргументов для события Completed должен быть производным от AsyncCompletedEventArgs.

4. Имя метода обычно завершается суффиксом Async.

5. Метод возвращает void.

Методы EAP, имена которых завершаются суффиксом Async, можно отличить от имен методов TAP с суффиксом Async, потому что методы EAP возвращают void, тогда как методы TAP возвращают ожидаемый тип.

Пример типа с EAP API:

class GetStringCompletedEventArgs : AsyncCompletedEventArgs

{

  public string Result { get; }

}

 

class MyHttpClient

{

  public void GetStringAsync(Uri requestUri);

  public event Action<object, GetStringCompletedEventArgs>

     GetStringCompleted;

 

  // Синхронный эквивалент для сравнения

  public string GetString(Uri requestUri);

}

Потребление паттерна EAP осуществляется преобразованием в TAP с помощью TaskCompletionSource<T>; см. рецепт 8.3 и документацию Microsoft ().

Стиль передачи продолжений (CPS)

Этот паттерн намного чаще встречается в других языках, особенно в JavaScript и TypeScript при использовании разработчиками Node.js. В этом паттерне каждая асинхронная операция получает делегата обратного вызова, который вызывается при завершении операции (успешном или с ошибкой). Разновидность этого паттерна использует двух делегатов обратного вызова, для успеха и для ошибки. Такая разновидность обратного вызова называется продолжением; продолжение передается в параметре, отсюда и название «стиль с передачей продолжений» (CPS, Continuation Passing Style»). Этот паттерн никогда не был широко распространен в мире .NET, но применялся в некоторых старых библиотеках с открытым кодом.

Паттерн EAP можно узнать по следующим характеристикам:

1. Операция представляется одним методом.

2. Метод получает дополнительный параметр с делегатом обратного вызова; делегат обратного вызова получает два аргумента, для ошибок и для результатов.

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

4. Делегатам обратного вызова обычно присваиваются имена done или next.

Вот пример типа API-интерфейса в стиле передачи продолжения:

class MyHttpClient

{

  public void GetString(Uri requestUri, Action<Exception, string> done);

  // Синхронный эквивалент для сравнения

  public string GetString(Uri requestUri);

}

Потребление паттерна CPS осуществляется преобразованием в TAP с помощью TaskCompletionSource<T> с передачей делегатов обратного вызова, которые просто завершают TaskCompletionSource<T>; см. рецепт 8.3.

Нестандартные асинхронные паттерны

Сильно специализированные типы иногда определяют собственные асинхронные паттерны. Самый известный пример такого рода — тип Socket, который определяет паттерн с передачей экземпляров SocketAsyncEventArgs, представляющих операцию. Причина для введения этого паттерна состоя­ла в том, что SocketAsyncEventArgs может использоваться повторно, сокращая нарастающие затраты памяти в приложениях с интенсивными сетевыми операциями. Современные приложения используют ValueTask<T> с ManualResetValueTaskSourceCore<T> для того, чтобы добиться сходного выигрыша по быстродействию.

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

Пример типа с нестандартным асинхронным API:

class MyHttpClient

{

  public void GetString(Uri requestUri,

      MyHttpClientAsynchronousOperation operation);

 

  // Синхронный эквивалент для сравнения

  public string GetString(Uri requestUri);

}

TaskCompletionSource<T> — единственный способ потребления нестандартных асинхронных паттернов; см. рецепт 8.3.

ISynchronizeInvoke

Все предыдущие паттерны предназначены для асинхронных операций, которые запускаются, после чего завершаются однократно. Некоторые компоненты следуют паттерну подписки: они представляют поток событий вместо одной операции, которая один раз запускается и один раз завершается. Хорошим примером модели подписки служит тип FileSystemWatcher. Чтобы отслеживать изменения в файловой системе, код-потребитель сначала подписывается на несколько событий, после чего задает свойству EnableRaisingEvents значение true. Если EnableRaisingEvents содержит true, могут инициироваться множественные события изменений в файловой системе.

Некоторые компоненты используют для своих событий паттерн ISynchro­nizeInvoke. Они предоставляют одно свойство ISynchronizeInvoke, а потребители задают этому свойству реализацию, которая позволяет компоненту планировать работу. Чаще всего оно используется для планирования работы в UI-потоке, чтобы события компонента выдавались в UI-потоке. По соглашениям, если ISynchronizeInvoke содержит null, то синхронизация событий не осуществляется и события могут выдаваться в фоновых потоках.

Паттерн ISynchronizeInvoke можно узнать по следующим характеристикам:

1. Свойство типа ISynchronizeInvoke.

2. Свойству обычно присваивается имя SynchronizingObject.

Пример типа, использующего паттерн ISynchronizeInvoke:

class MyHttpClient

{

  public ISynchronizeInvoke SynchronizingObject { get; set; }

  public void StartListening();

  public event Action<string> StringArrived;

}

Так как паттерн ISynchronizeInvoke подразумевает существование множественных событий в модели подписки, правильный способ потребления этих компонентов заключается в преобразовании событий в наблюдаемый поток — с использованием либо FromEvent (см. рецепт 6.1), либо Observable.Create.

Назад: Приложение А. Поддержка унаследованных платформ
Дальше: Об авторе