Июль 2015

ТОМ 30 ВЫПУСК 7

C# - Разработка асинхронного кода для существующей синхронной кодовой базы

Стивен Клири

Продукты и технологии:

Visual Studio Async CTP, C#, Microsoft .NET Framework

В статье рассматриваются:

преобразование синхронного кода в асинхронный;

разрешение взаимоблокировок (deadlocks) в операциях в коде;

применение различных приемов для обхода конфликтов в коде.

Когда вышла CTP-версия Visual Studio Async, я оказался в выгодной позиции. Я был единственным разработчиком двух сравнительно небольших приложений, создаваемых с нуля (greenfield applications), которые могли выиграть от использования async и await. В то время участники форумов MSDN, включая меня самого, обсуждали, искали и реализовывали рекомендации по асинхронному программированию. Наиболее важные из этих рекомендаций были собраны в моей статье «Best Practices in Asynchronous Programming» за март 2013 года (msdn.microsoft.com/magazine/jj991977).

Применение async и await к существующей кодовой базе — проблема другого рода. Существующий код (brownfield code) может быть запутанным, что еще больше усложняет сценарий. Здесь я расскажу о нескольких приемах, которые, как я обнаружил, полезны при введении асинхронности в существующий код. Введение асинхронности может в некоторых случаях повлиять на архитектуру. Если есть необходимость в рефакторинге для разделения существующего кода на уровни, советую сделать это до введения асинхронности. Для целей этой статьи я предположу, что вы используете прикладную инфраструктуру, схожую с показанной на рис. 1.

Рис. 1. Простая структура кода с уровнем сервисов и уровнем бизнес-логики

public interface IDataService
{
  string Get(int id);
}
public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return client.DownloadString("http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public string GetFrob()
  {
    // Try to get the new frob id.
    var result = _dataService.Get(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return _dataService.Get(13);
  }
}

Когда использовать асинхронность

Лучший подход в целом — сначала продумать, а что, собственно, реально делает приложение. Асинхронность блистает в операциях, связанных с вводом-выводом, но иногда предпочтительнее выбирать других типы обработки. Есть два довольно распространенных сценария, где асинхронность далеко не идеальный выбор: код, интенсивно использующий процессор, и потоки данных (data streams).

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

Однако вы можете обрабатывать код, интенсивно использующий процессор, так, будто он является асинхронным, ожидая результата Task.Run. Это хороший способ снять вычислительную нагрузку с UI-потока. Следующий код является примером использования Task.Run в качестве мостика между асинхронным и параллельным кодом:

await Task.Run(() => Parallel.ForEach(...));

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

Функциональность async/await возможно использовать с потоками событий. Это потребует некоторых системных ресурсов для буферизации поступающих данных до тех пор, пока они не будут считаны приложением. Если источником является подписка на события, подумайте об использовании Reactive Extensions (Rx) или TPL Dataflow. Возможно, вы найдете эти варианты более естественными, чем чистая асинхронность. Как Rx, так и Dataflow нормально взаимодействуют с асинхронным кодом.

Асинхронность определенно является лучшим выбором для весьма большой части кода, но отнюдь не для всего кода. Далее в этой статье я буду предполагать, что вы рассматривали применение Task Parallel Library (TPL) и Rx/Dataflow и пришли к выводу, что async/await — самый подходящий вариант.

Преобразование синхронного кода в асинхронный

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

Эта процедура работает лучше всего, когда вы начинаете с более низких уровней и движетесь к пользовательскому уровню. Иначе говоря, начинайте вводить асинхронность в методы уровня данных, которые обращаются к базе данных или Web API. Затем вводите асинхронность в методы своего сервиса, далее в бизнес-логику и, наконец, в пользовательский уровень. Если в вашем ходе нет четко определенных уровней, его все равно можно преобразовать под использование функциональности async/await. Просто это будет труднее.

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

Если в нижележащей библиотеке есть API, готовый к работе с async, нужно лишь добавить суффикс Async (или TaskAsync) к имени синхронного метода. Например, вызов First из Entity Framework можно заменить вызовом FirstAsync. В некоторых случаях вам может понадобиться использовать альтернативный тип. Скажем, HttpClient является более «дружелюбной» к async заменой WebClient и HttpWebRequest. В каких-то случаях может потребоваться обновление версии вашей библиотеки. Так, Entity Framework поддерживает API, осведомленный об async, в версии 6.

Рассмотрим код на рис. 1. Это простой пример с уровнем сервисов и некоторой бизнес-логикой. В данном примере есть только одна низкоуровневая операция: получение строки идентификатора Frob от Web API в WebDataService.Get. Это логичное место, чтобы начать преобразование с него. Здесь разработчик может выбрать либо замену WebClient.DownloadString на WebClient.DownloadStringTaskAsync, либо замену WebClient на более «дружественный» к async HttpClient.

Второй шаг — замена вызова синхронного API на вызов асинхронного API и добавление await для ожидания возвращаемой задачи. Когда код вызывает какой-либо асинхронный метод, обычно имеет смысл ждать возвращаемую задачу через await. В этот момент компилятор начнет жаловаться. Следующий код вызовет ошибку компиляции с сообщением «The 'await' operator can only be used within an async method. Consider marking this method with the 'async' modifier and changing its return type to 'Task<string>'» («Оператор await можно использовать только в асинхронном методе. Подумайте о том, чтобы пометить этот метод модификатором async и сменить его возвращаемый тип на Task<string>»):

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

Компилятор проведет вас на следующий этап. Пометьте метод как async и смените возвращаемый тип. Если возвращаемый тип синхронного метода — void, тогда возвращаемым типом асинхронного метода должен быть Task. В ином случае любой возвращаемый тип T синхронного метода должен быть в асинхронном методе типом Task<T>. Изменяя возвращаемый тип на Task/Task<T>, вы должны также модифицировать имя метода согласно руководству Task-Based Asynchronous Pattern так, чтобы оно оканчивалось на «Async». В следующем коде показан получившийся асинхронный метод:

public sealed class WebDataService : IDataService
{
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

Прежде чем двигаться дальше, проверьте остальной код этого метода на наличие других блокирующих или синхронных API-вызовов, которые можно сделать асинхронными. Асинхронные методы не должны ничего блокировать, поэтому данный метод должен вызывать асинхронные API, если они доступны. В данном простом примере других блокирующих вызовов нет. В реальном коде смотрите в оба на логику повторных попыток и оптимистичное разрешение конфликтов (optimistic conflict resolution).

Здесь следует особо упомянуть Entity Framework. Один тонкий подвох — отложенная загрузка (lazy loading) связанных сущностей. Это всегда делается синхронно. По возможности используйте дополнительные явные асинхронные запросы вместо отложенной загрузки.

Теперь этот метод готов. Далее переходим ко всем методам, которые ссылаются на этот метод, и вновь следуем той же процедуре. Здесь WebDataService.Get был частью реализации интерфейса, поэтому вы должны изменить интерфейс для поддержки асинхронных реализаций:

public interface IDataService
{
  Task<string> GetAsync(int id);
}

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

Рис. 2. Преобразование всех вызывающих методов в асинхронные

public interface IDataService
{
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public async Task<string> GetFrobAsync()
  {
    // Try to get the new frob id.
    var result = await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetAsync(13);
  }
}

Уровень асинхронных операций в вашей кодовой базе будет постепенно расти, пока вы не натолкнетесь на метод, который не вызывается никакими другими методами в вашем коде. Ваши методы верхнего уровня вызываются напрямую используемой вами инфраструктурой. Некоторые инфраструктуры вроде ASP.NET MVC непосредственно поддерживают асинхронный код. Например, операции контроллера ASP.NET MVC могут возвращать Task или Task<T>. Другие инфраструктуры наподобие Windows Presentation Foundation (WPF) разрешают лишь асинхронные обработчики событий.

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

Если вы зашли в тупик

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

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

Советы по преобразованию

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

Преобразуя свой код, смотрите в оба на предмет возможностей параллельной обработки. Асинхронно-параллельный код зачастую короче и проще синхронно-параллельного. Возьмем, к примеру, метод, который должен скачивать два разных ресурса из REST API. Синхронная версия этого метода почти наверняка будет скачивать сначала один ресурс, а потом другой. Однако асинхронная версия могла бы легко запустить обе операции скачивания, а затем асинхронно ожидать окончания обеих операций, используя Task.WhenAll.

Другое соображение — отмена. Пользователи синхронных приложений привыкли к ожиданию. Если UI остается «отзывчивым» в новой версии, они предположат возможность отмены операции. Асинхронный код, в целом, должен поддерживать отмену, если только нет какой-то причины на запрет отмены. По большей части ваш асинхронный код может поддерживать отмену простой передачей аргумента CancellationToken асинхронным методам, которые он вызывает.

Вы можете преобразовать любой код, использующий Thread или BackgroundWorker, на применение Task.Run. Task.Run гораздо проще в композиции, чем Thread или BackgroundWorker. Например, с помощью современных await и Task.Run выразить «начать два фоновых вычисления, а затем заняться другими делами, когда они оба завершатся» куда легче, чем при использовании примитивных конструкций поточной обработки.

Вертикальные разделы

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

Если у вас нет времени на преобразование всей кодовой базы за один раз, можно использовать подход к преобразованию с небольшой модификацией, который называется вертикальными разделами (vertical partitions). Используя этот прием, можно выполнять преобразование до определенных разделов кода. Вертикальные разделы идеальны, если вы хотите просто «опробовать» асинхронный код.

Чтобы создать вертикальный раздел, идентифицируйте код пользовательского уровня, который вы хотели бы сделать асинхронным. Возможно, это обработчик событий для UI-кнопки, которая вызывает сохранение в базе данных (и при этом вам нужно, чтобы UI оставался «отзывчивым»), или часто используемый запрос ASP.NET, делающий то же самое (причем вы хотели бы сократить ресурсы, требуемые для этого конкретного запроса). Пройдите по коду, сформировав дерево вызовов для этого метода. Затем вы можете начать с низкоуровневых методов и выполнять преобразования, поднимаясь вверх по дереву.

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

Рис. 3. Использование вертикальных разделов для преобразования разделов кода в асинхронные

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return client.DownloadString("http://www.example.com/api/values/" + id);
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public string GetFrob()
  {
    // Try to get the new frob id.
    var result = _dataService.Get(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return _dataService.Get(13);
  }
  public async Task<string> GetFrobAsync()
  {
    // Try to get the new frob id.
    var result = await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetAsync(13);
  }
}

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

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

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

И в третьих, можно применить один из приемов, описываемых в следующих разделах. Хотя на самом деле я не рекомендую ни один из этих приемов, они могут оказаться полезными в крайнем случае. Поскольку они работают асинхронно естественным образом, каждый из этих приемов ориентирован на предоставление синхронного API для естественно асинхронной операции, что является общеизвестным антишаблоном, очень подробно описанным в Server & Tools Blogs (bit.ly/1JDLmWD).

Прием с блокированием

Самый прямолинейный подход — просто блокировать асинхронную версию. Я советую блокировать с помощью GetAwaiter().GetResult вместо Wait или Result. Wait и Result будут обертывать любое исключение в AggregateException, что усложнит обработку ошибок. Пример кода уровня сервисов, где применяется прием с блокированием, показан на рис. 4.

Рис. 4. Код уровня сервисов, где используется прием с блокированием

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return GetAsync(id).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    // This code will not work as expected.
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

К сожалению, как становится понятно из комментария, этот код на самом деле работать не будет. Это приведет к взаимоблокировке, описанной в моей статье «Best Practices in Asynchronous Programming», о которой я упоминал ранее.

И здесь данный прием может подставить вам подножку. Обычный модульный тест пройдет успешно, но тот же код приведет к взаимоблокировке, если будет вызван из UI или контекста ASP.NET. Если вы используете прием с блокировкой, то должны написать модульные тесты, которые проверяют это поведение. В коде на рис. 5 используется тип AsyncContext из моей библиотеки AsyncEx, который создает контекст, аналогичный UI или контексту ASP.NET.

Рис. 5. Применение типа AsyncContext

[TestClass]
public class WebDataServiceUnitTests
{
  [TestMethod]
  public async Task GetAsync_RetrievesObject13()
  {
    var service = new WebDataService();
    var result = await service.GetAsync(13);
    Assert.AreEqual("frob", result);
  }
  [TestMethod]
  public void Get_RetrievesObject13()
  {
    AsyncContext.Run(() =>
    {
      var service = new WebDataService();
      var result = service.Get(13);
      Assert.AreEqual("frob", result);
    });
  }
}

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

В данном случае нашему асинхронному коде не хватает ConfigureAwait(false). Однако та же проблема может быть вызвана применением WebClient. WebClient использует более старый асинхронный шаблон на основе событий (event-based asynchronous pattern, EAP), который всегда захватывает контекст. Поэтому, даже если ваш код использует ConfigureAwait(false), та же взаимоблокировка возникнет из-за кода WebClient. В этом случае можно заменить WebClient на более «дружественный» к асинхронности HttpClient и добиться нормальной работы, как показано на рис. 6.

Рис. 6. Применение HttpClient с ConfigureAwait(false) для предотвращения взаимоблокировки

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return GetAsync(id).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new HttpClient())
      return await client.GetStringAsync(
      "http://www.example.com/api/values/" + id).ConfigureAwait(false);
  }
}

Прием с блокированием требует от вашей группы строгой дисциплины. Они должны везде использовать ConfigureAwait(false). Тому же принципу должны следовать все зависимые библиотеки. В некоторых случаях это просто невозможно. На момент написания этой статьи даже HttpClient захватывал контекст на отдельных платформах.

Другой недостаток приема с блокированием в том, что он требует от вас использования ConfigureAwait(false). Это вообще не годится, если асинхронный код действительно должен возобновлять выполнение в захваченном контексте. Если вы используете прием с блокированием, настоятельно рекомендуется выполнять модульные тесты, используя AsyncContext или другой подобный однопоточный контекст для отлова любых взаимоблокировок.

Прием с пулом потоков

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

Рис. 7. Код, использующий прием с пулом потоков

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return Task.Run(() => GetAsync(id)).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

Вызов Task.Run выполняет асинхронный метод в потоке из пула. Здесь он будет выполняться без контекста, что предотвратит взаимоблокировку. Одна из проблем с этим подходом в том, что асинхронный метод не может зависеть от выполнения в конкретном контексте. Поэтому он не в состоянии использовать UI-элементы или ASP.NET HttpContext.Current.

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

Вы можете создать контекст для фонового потока. Тип AsyncContext в моей библиотеке AsyncEx установит однопоточный контекст вместе с «основным циклом». Это приведет к принудительному возобновлению асинхронного кода в том же потоке. Тем самым вы избежите более тонких подвохов, связанных с приемом, в котором использует пул потоков. Пример кода с основным циклом для потока из пула выглядит примерно так, как на рис. 8.

Рис. 8. Использование основного цикла в приеме с пулом потоков

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    var task = Task.Run(() => AsyncContext.Run(() => GetAsync(id)));
    return task.GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

Конечно, и в этом подходе есть недостаток. Поток из пула блокируется в AsyncContext, пока не завершится выполнение асинхронного метода. В итоге блокируется этот поток и основной, вызвавший синхронный API. То есть на время вызова вы получаете два блокированных потока. В ASP.NET, например, такой подход значительно уменьшает масштабируемость приложения.

Прием с аргументом-флагом

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

В этом случаете вы берете исходный метод, делаете его закрытым и добавляете флаг, указывающий, как должен выполняться этот метод — синхронно или асинхронно. Затем он предоставляет два открытых API — один синхронный, а другой асинхронный (рис. 9).

Рис. 9. При использовании приема с аргументом-флагом предоставляются два API

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  private async Task<string> GetCoreAsync(int id, bool sync)
  {
    using (var client = new WebClient())
    {
      return sync
        ? client.DownloadString("http://www.example.com/api/values/" + id)
        : await client.DownloadStringTaskAsync(
        "http://www.example.com/api/values/" + id);
    }
  }
  public string Get(int id)
  {
    return GetCoreAsync(id, sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetAsync(int id)
  {
    return GetCoreAsync(id, sync: false);
  }
}

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

Синхронная оболочка Get передает true для аргумента-флага, а затем получает результат операции. Заметьте, что никакой вероятности взаимоблокировки нет, поскольку задача уже завершена. Бизнес-логика следует похожему шаблону (рис. 10).

Рис. 10. Использование приема с аргументом-флагом для бизнес-логики

public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  private async Task<string> GetFrobCoreAsync(bool sync)
  {
    // Try to get the new frob id.
    var result = sync
      ? _dataService.Get(17)
      : await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return sync
      ? _dataService.Get(13)
      : await _dataService.GetAsync(13);
  }
  public string GetFrob()
  {
    return GetFrobCoreAsync(sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetFrobAsync()
  {
    return GetFrobCoreAsync(sync: false);
  }
}

У вас есть возможность предоставлять методы CoreAsync из своего уровня сервисов. Это упрощает бизнес-логику. Однако метод аргумента-флага — это нечто большее, чем деталь реализации. Вам понадобится оценить, насколько велико преимущество более ясного кода над недостатком открытия деталей реализации, как показано на рис. 11. Преимущество этого приема в том, что логика методов остается в основном той же. Она просто вызывает разные API в зависимости от значения аргумента-флага. Это прекрасно работает, если между синхронным и асинхронным API есть соответствии «один в один», что обычно так и бывает.

Рис. 11. Детали реализации предоставляются, но код более ясный

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
  Task<string> GetCoreAsync(int id, bool sync);
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  private async Task<string> GetFrobCoreAsync(bool sync)
  {
    // Try to get the new frob id.
    var result = await _dataService.GetCoreAsync(17, sync);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetCoreAsync(13, sync);
  }
  public string GetFrob()
  {
    return GetFrobCoreAsync(sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetFrobAsync()
  {
    return GetFrobCoreAsync(sync: false);
  }
}

Но может не сработать, если вы захотите добавить параллельную обработку в свой асинхронный код или если нет идеально соответствующего асинхронного API. Например, я предпочел бы использовать HttpClient вместо WebClient в WebDataService, но должен принять во внимание дополнительную сложность, которая появилась бы в методе GetCoreAsync.

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

Прием с вложенным циклом обработки сообщений

Этот последний прием относится к числу тех, которые мне нравятся меньше всего. Идея в том, что создаете вложенный цикл обработки сообщений в UI-потоке и выполняете асинхронный код внутри этого цикла. Данный подход не годится для ASP.NET. Он также может потребовать разного кода для разных UI-платформ. Например, WPF-приложение могло бы использовать фреймы вложенного диспетчера (nested dispatcher frames), а приложение Windows Forms — DoEvents внутри цикла. Если асинхронные методы не зависят от конкретной UI-платформы, вы также можете задействовать AsyncContext для выполнения вложенного цикла (рис. 12).

Рис. 12. Выполнение вложенного цикла обработки сообщений с помощью AsyncContext

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return AsyncContext.Run(() => GetAsync(id));
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

Пусть вас не обманывает простота этого примера кода. Данный прием — самый опасный изо всех описанных, поскольку вы должны учитывать реентерабельность. Это особенно верно, если код использует фреймы вложенного диспетчера или DoEvents. В таком случае весь UI-уровень должен обрабатывать неожиданные повторные вхождения. Приложения с безопасной реентерабельностью требуют очень тщательного продумывания и планирования.

Заключение

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

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


Стивен Клири (Stephen Cleary) — отец, муж и программист, живет в северной части Мичигана. Имеет 16-летний опыт работы в области многопоточного и асинхронного программирования и использует поддержку асинхронности в Microsoft .NET Framework с момента ее первой CTP-версии. Автор книги «Concurrency in C# Cookbook» (O’Reilly Media, 2014). Его проекты и блог можно найти по ссылке stephencleary.com.

Выражаю благодарность за рецензирование статьи экспертам Microsoft Джеймсу Маккафри (James McCaffrey) и Стефену Таубу (Stephen Toub).