Асинхронное программирование в C# 5

Автор: Тепляков Сергей Владимирович
Источник: RSDN Magazine #4-2010
Опубликовано: 05.02.2011
Версия текста: 1.1
Синхронное и асинхронное выполнение операций
Асинхронное программирование в C# 5
Заключение

Все знают, что синхронное выполнение длительных операций – это плохо. Это плохо по многим причинам, начиная с того, что синхронные вызовы приводят к подвисанию пользовательского интерфейса, плохой масштабируемости и расширяемости, заканчивая тем, что вы не сможете воспользоваться всеми возможностями многоядерного процессора даже вашего домашнего компьютера, не говоря уже о преимуществах асинхронного ввода/вывода, эффективность которого проявляется даже на одноядерных процессорах. О теме асинхронности и многопоточности говорят на каждом шагу, начиная от небезызвестных авторов вроде Джеффри Рихтера, Джо Даффи или Джозефа Албахари, и заканчивая множеством статей на каждом втором техническом сайте и обязательным вопросом на собеседовании типа «А сколько вы знаете способов выполнить операцию асинхронно?».

Одной из причин такого состояния дел является то, что вся поддержка многопоточности в языке C# заканчивается оператором lock, а все остальное прикручено к нему с помощью библиотек, начиная с BCL и RX, заканчивая Power Threading и другими "велосипедами" различного вида и формы (хотя нужно признать, что главная причина заключается в том, что темы многопоточности и асинхронности сами по себе весьма сложны, и все попытки их упростить настолько, чтобы они были понятна домохозяйкам, успехом не увенчались и едва ли увенчаются когда-либо). Однако, как говорилось в недавнем докладе Андерса Хейлсберга на конференции Microsoft PDC, счастье должно наступить с выходом новой версии языка C#, в котором работа с асинхронными операциями будет добавлена на уровне языка. Однако прежде чем приступать к рассмотрению возможностей, которые в очередной раз должны упростить решение асинхронных задач, следует поближе рассмотреть проблемы, которые возникают при использовании синхронных операций и то, как эти проблемы решались до сих пор.

Синхронное и асинхронное выполнение операций

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

Очень популярным примером выполнения асинхронных операций является обращение к какому-нибудь внешнему ресурсу типа Web-сервиса, доступ к Web-странице или асинхронный доступ к файлу. И хотя в качестве примера с тем же успехом можно использовать метод с телом вида Sleep(5000), сделаем вид, что мы стараемся решить настоящую задачу, например, написать свой собственный Web-браузер с поддержкой нескольких вкладок.

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

      static
      void SyncVersion()
{
  Stopwatch sw = Stopwatch.StartNew();
  string url1 = "http://rsdn.ru";
  string url2 = "http://gotdotnet.ru";
  string url3 = "http://blogs.msdn.com";
  var webRequest1 = WebRequest.Create(url1);
  var webResponse1 = webRequest1.GetResponse();
  Console.WriteLine("{0} : {1}, elapsed {2}ms", url1,
    webResponse1.ContentLength, sw.ElapsedMilliseconds);
 
  var webRequest2 = WebRequest.Create(url2);
  var webResponse2 = webRequest2.GetResponse();
  Console.WriteLine("{0} : {1}, elapsed {2}ms", url2,
    webResponse2.ContentLength, sw.ElapsedMilliseconds);
 
  var webRequest3 = WebRequest.Create(url3);
  var webResponse3 = webRequest3.GetResponse();
  Console.WriteLine("{0} : {1}, elapsed {2}ms", url3,
    webResponse3.ContentLength, sw.ElapsedMilliseconds);
}
ПРИМЕЧАНИЕ

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

У синхронного выполнения операций, подобных чтению Web-страниц, имеется ряд недостатков: во-первых, текущий поток будет блокирован на неопределенный срок, а ведь этот поток вполне может оказаться потоком пользовательского интерфейса, и наше приложение будет выглядеть замечательным беленьким окошком с песочными часиками посередине. Кроме того, если каждый запрос будет длиться 5 секунд, то три запроса будут выполняться 15 секунд, в то время, как при параллельном выполнении все три запроса могут быть выполнены за 5 секунд. Конечно, этого никогда не будет, особенно при обращении к Web-страницам, поскольку часть этого времени тратится на передачу данных по разделяемому каналу, что приведет к увеличению времени выполнения каждого параллельного запроса, но у запроса есть и постоянная составляющая, которая не будет увеличиваться при одновременном выполнении нескольких запросов. И хотя о конкретных цифрах увеличения эффективности параллельного выполнения подобных операций можно спорить очень долго, можно с уверенностью сказать, что асинхронное выполнение операций, интенсивно использующих ввод/вывод (IO Bound), операция выгодная с точки зрения как эффективности, так и масштабируемости.

Поскольку идея асинхронного выполнения далеко не нова, классы, подобные WebRequest (и его наследнику HttpWebRequest) поддерживают специальный набор функций, которые позволяют выполнять длительные операции асинхронно с помощью методов BeginGetResponse и EndGetResponse. Традиционно такая модель называется APM – Asynchronous Programming Model.

      static
      void SimpleApm()
{
  string url1 = "http://rsdn.ru";
  string url2 = "http://gotdotnet.ru";
  string url3 = "http://blogs.msdn.com";
  var webRequest1 = WebRequest.Create(url1);
  webRequest1.BeginGetResponse(ProcessWebRequest, webRequest1);
 
  var webRequest2 = WebRequest.Create(url2);
  webRequest2.BeginGetResponse(ProcessWebRequest, webRequest2);
 
  var webRequest3 = WebRequest.Create(url3);
  webRequest3.BeginGetResponse(ProcessWebRequest, webRequest3);
}

staticvoid ProcessWebRequest(IAsyncResult ar)
{
  var webRequest = (WebRequest)ar.AsyncState;
  var webResponse = webRequest.EndGetResponse(ar);
  Console.WriteLine("{0}: {1}", 
    webRequest.RequestUri, webResponse.ContentLength);
}

Теперь основной поток выполняет только запросы на выполнение асинхронных операций, а сами операции выполняются в рабочих потоках пула потоков и большую часть времени спят, ожидая завершения операций ввода/вывода. Это позволяет запустить практически одновременно все три запроса ввода/вывода и независимо ожидать завершения каждого из них. Этот код выглядит достаточно просто, однако попытка сохранить результаты всех трех асинхронных операций в файл (причем тоже асинхронно) с полноценной обработкой ошибок, сделает код значительно более сложным для понимания и сопровождения. Кроме того, добавление функций обратного вызова для обработки результатов асинхронных операций изменяет поток управления таким образом, что становятся недоступны такие удобные вспомогательные языковые конструкции, как блоки try/catch/finally, lock или using.

Асинхронное программирование в C# 5

Идея, которая лежит в основе асинхронных операций в C# 5, очень похожа на ту, которую использовал Джеффри Рихтер в своем классе AsyncEnumerator, только на этот раз, помимо нас с вами и старины Рихтера, о ней узнал еще и компилятор (что здорово сказывается на простоте использования этих синтаксических конструкций). Теперь давайте возьмем нашу синхронную версию, рассмотренную в предыдущем разделе, и сделаем из нее асинхронную с помощью новых возможностей языка C#.

ПРИМЕЧАНИЕ

На данный момент никто не знает, когда будет доступна новая версия языка программирования C# 5. Все примеры, приведенные в данной статье, основываются на CTP (Community Technology Preview) версии, представленной Андерсом Хейлсбергом на конференции Microsoft PDC в октябре 2010 года.

Первое, что нужно сделать, это изменить объявление нашей функции таким образом:

      static async Task AsyncVersion()

Ключевое слово async(которое в CTP версии является ключевым словом, а в окончательной версии языка будет контекстным ключевым словом) в сигнатуре метода говорит о том, что этот метод выполняется асинхронно и возвращает управление вызывающему коду сразу после начала некоторой асинхронной операции. «Асинхронные методы» могут возвращать один из трех типов возвращаемого значения: void, Task и Task<T>. Если метод возвращает void, то это будет асинхронная операция типа: «запустили и забыли», поскольку обработать результат этой операции будет невозможно. Примером такой операции может служить асинхронное уведомление всех удаленных подписчиков о каком-либо событии, если вам не важно, получат они эти сообщения или нет. С классами Task и Task<T> читатель, может быть, знаком, поскольку они доступны в .Net Framework начиная с 4 версии. Основная идея этих классов заключается в том, что они инкапсулируют в себе «незавершенную задачу», и мы можем дождаться ее завершения, установить «продолжение» (нечто, что должно быть вызвано при завершении этой задачи) и т.п. При этом класс Task<T> является наследником класса Task и отличается от последнего тем, что позволяет получить возвращаемое значение типа T посредством свойства Result, в то время, как класс Task говорит о том, что некоторая задача возвращает void, и ценна за счет своих побочных эффектов.

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

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

      var webResponse1 = webRequest1.GetResponse();

Нужно заменить на:

      var webResponse1 = await webRequest1.GetResponseAsync();

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

Идея, которая лежит в основе реализации этой возможности языка, аналогична реализации блоков итераторов в языке C#: в обоих случаях компилятор генерирует конечный автомат, который отслеживает, где было прервано выполнение метода, и продолжает выполнение с этой же точки при следующем вызове. Однако если в случае итераторов возобновление выполнения блока итераторов происходит при последующем вызове метода MoveNext внешним кодом (например, foreach), то асинхронный метод продолжает выполняться автоматически после завершения асинхронной операции. Для этого для вновь созданной задачи в качестве «продолжения» устанавливается этот же метод (точнее, та часть кода метода, которая идет непосредственно за точкой вызова), который и будет вызван после завершения этой задачи.

Теперь давайте посмотрим на измененный метод целиком:

      static async Task AsyncVersion()
{
  Stopwatch sw = Stopwatch.StartNew();
  string url1 = "http://rsdn.ru";
  string url2 = "http://gotdotnet.ru";
  string url3 = "http://blogs.msdn.com";
  var webRequest1 = WebRequest.Create(url1);
  Console.WriteLine(
    "Перед вызовом webRequest1.GetResponseAsync(). Thread Id: {0}",
    Thread.CurrentThread.ManagedThreadId);
  var webResponse1 = await webRequest1.GetResponseAsync();
  Console.WriteLine("{0} : {1}, elapsed {2}ms. Thread Id: {3}", url1,
    webResponse1.ContentLength, sw.ElapsedMilliseconds, 
    Thread.CurrentThread.ManagedThreadId);
 
  var webRequest2 = WebRequest.Create(url2);
  Console.WriteLine(
    "Перед вызовом webRequest2.GetResponseAsync(). Thread Id: {0}",
    Thread.CurrentThread.ManagedThreadId);
  var webResponse2 = await webRequest2.GetResponseAsync();
  Console.WriteLine("{0} : {1}, elapsed {2}ms. Thread Id: {3}", url2,
    webResponse2.ContentLength, sw.ElapsedMilliseconds, 
    Thread.CurrentThread.ManagedThreadId);
 
  var webRequest3 = WebRequest.Create(url3);
  Console.WriteLine(
    "Перед вызовом webRequest3.GetResponseAsync(). Thread Id: {0}", 
    Thread.CurrentThread.ManagedThreadId);
  var webResponse3 = await webRequest3.GetResponseAsync();
  Console.WriteLine("{0} : {1}, elapsed {2}ms. Thread Id: {3}", url3,
    webResponse3.ContentLength, sw.ElapsedMilliseconds, 
    Thread.CurrentThread.ManagedThreadId);
}

И вот как этот метод вызывается:

      static
      void Main(string[] args)
{

  try
  {
    Console.WriteLine("Id основного потока: {0}",
      Thread.CurrentThread.ManagedThreadId);
    var task = AsyncVersion();
    Console.WriteLine("Метод AsyncVersion() вернул управление");
    //Ожидаем завершения асинхронной операции
    task.Wait();
    Console.WriteLine("Асинхронная операция завершена!");
    
  }
  catch(System.AggregateException e)
  {
    //Все исключения пробрасываются, обернутые в AggregateException
    Console.WriteLine("AggregateException: {0}", e.InnerException.Message);
  }

А вот результат его выполнения:

Id основного потока: 10
Перед вызовом webRequest1.GetResponseAsync(). Thread Id: 10
Метод AsyncVersion() Вернул управление
http://rsdn.ru: 1672, elapsed 657ms. Thread Id: 13
Перед вызовом webRequest2.GetResponseAsync(). Thread Id: 13
http://gotdotnet.ru: 99470, elapsed 1915ms. Thread Id: 14
Перед вызовом webRequest3.GetResponseAsync(). Thread Id: 14
http://blogs.msdn.com: 47927, elapsed 2628ms. Thread Id: 15
Асинхронная операция завершена!

А теперь давайте разберем подробно, что происходит внутри этого кода. Вызов метода AsyncVersion происходит в текущем потоке (мы видим, что перед вызовом метода webReqest1.GetResponseAsync идентификатор потока равен 10, т.е. равен идентификатору основного потока), и управление из метода AsyncVersion возвращается сразу же после первого оператора await, а не после завершения первой асинхронной операции. Эта функция возвращает объект типа Task, чтобы мы смогли дождаться завершения операции и обработать ее результаты. Прежде чем вернуть управление вызывающему коду, метод AsyncVersion устанавливается в качестве «продолжения» текущей задачи, и запоминается место, с которого нужно продолжить выполнение. Затем, после завершения первой асинхронной операции, выполнение этого метода возобновляется с места, непосредственно идущего за асинхронным вызовом, и, как мы видим, уже в другом потоке. Далее, этот процесс продолжается до тех пор, пока не будет завершена третья асинхронная операция, после чего метод task.Wait вернет управление, и мы увидим на консоли заветное: “Асинхронная операция завершена!”.

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

Обработка ошибок также претерпела некоторых изменений, но также весьма незначительных. Если вы уже знакомы с TPL (Task Parallel Library), которая входит в состав .Net Framework начиная с версии 4.0, то класс System.AggregateExcpetionдолжен быть вам уже знаком. Этот класс «собирает» все исключения, произошедшие во всех асинхронных операциях, и накапливает их у себя внутри. Причина этого заключается в том, что у одной задачи может быть десяток дочерних задач, каждая из которых может содержать еще несколько «подзадач», и каждое задание из этого «дерева» заданий может завершиться неудачно. Конечно, обработка исключений несколько усложнилась, но совсем незначительно по сравнению с теми проблемами, которые возникают при обработке ошибок в классической модели APM.

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

      static async void AsyncVersionWithSaving()
{
  Stopwatch sw = Stopwatch.StartNew();

  // теперь мы уже можем воспользоваться массивом адресовvar urls = newstring[] {"http://rsdn.ru", "http://gotdotnet.ru", 
      "http://blogs.msdn.com"};
  
  // создаем WebRequest для каждого url из списка// и получаем WebResponse асинхронно.// Обратите внимание на вызов ToList(), поскольку// в противном случае «отложенная» природа LINQ// приведет к созданию объектов WebReqest только при// переборе результатов выполнения LINQ-запросаvar tasks = (from url in urls 
             let webRequest = WebRequest.Create(url)
             selectnew {Url = url, Response = webRequest.GetResponseAsync()})
          . ToList();

  // Дожидаемся выполнения всех асинхронных запросовvar data = await TaskEx.WhenAll(tasks.Select(t => t.Response));
  
  // Создаем строку с результатами всех Web-запросовvar sb = new StringBuilder();
  foreach(var s in tasks)
  {
    sb.AppendFormat("{0}: {1}, elapsed {2}ms. Thread Id: {3}", s.Url, 
      s.Response.Result.ContentLength, sw.ElapsedMilliseconds, 
      Thread.CurrentThread.ManagedThreadId).AppendLine();
  }
  var outputText = sb.ToString();
  Console.WriteLine("Web request results: {0}", outputText);
      
  using (var fs = new FileStream("d:\\results.txt", FileMode.Create,
    FileAccess.Write, FileShare.Write))
  {
    // Получаем бинарное представление нашей строкиbyte[] data = UnicodeEncoding.Default.GetBytes(outputText);
    // Сохраняем полученную строку асинхронно
    await fs.WriteAsync(data, 0, data.Length);
  }
      
}

Вот это уже действительно интересно! Мы получили полностью асинхронный код, но при этом он остался таким же читабельным, как и синхронная версия; он содержит простой и понятный поток выполнения, привычные конструкции, обеспечивающие корректную работу с ресурсами (речь идет о конструкции using), а для обработки ошибок, достаточно вызов этого метода обернуть в блок try/catch и перехватить AggregateException.

Заключение

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


Эта статья опубликована в журнале RSDN Magazine #4-2010. Информацию о журнале можно найти здесь