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

Глава 2. Основы async

В этой главе будут представлены основы использования синтаксиса async и await для асинхронных операций. Мы рассмотрим только естественные асинхронные операции: запросы HTTP, команды баз данных и вызовы веб-служб.

Если имеется операция, создающая интенсивную нагрузку на процессор, которую вы хотели бы рассматривать как асинхронную (например, чтобы она не блокировала UI-поток), обращайтесь к главе 4 и рецепту 8.4. Кроме того, в этой главе рассматриваются только операции, которые один раз начинаются и один раз завершаются; если нужно обрабатывать потоки событий, обращайтесь к главам 3 и 6.

2.1. Приостановка на заданный период времени

Задача

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

Решение

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

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

async Task<T> DelayResult<T>(T result, TimeSpan delay)

{

  await Task.Delay(delay);

  return result;

 

}

Экспоненциальная задержка — стратегия увеличения задержек между повторными попытками. Используйте ее при работе с веб-службами, чтобы не перегружать сервер повторными попытками. Ниже приведен пример простой реализации экспоненциальной задержки:

async Task<string> DownloadStringWithRetries(HttpClient client, string uri)

{

  // Повторить попытку через 1 секунду, потом через 2 и через 4 секунды.

  TimeSpan nextDelay = TimeSpan.FromSeconds(1);

  for (int i = 0; i != 3; ++i)

  {

    try

    {

      return await client.GetStringAsync(uri);

    }

    catch

    {

    }

 

    await Task.Delay(nextDelay);

    nextDelay = nextDelay + nextDelay;

  }

 

  // Попробовать в последний раз и разрешить распространение ошибки.

  return await client.GetStringAsync(uri);

}

lemur.tiff

В реальном коде я бы рекомендовал применить более качественное решение (например, использующее библиотеку Polly NuGet); код, приведенный здесь, является всего лишь примером использования Task.Delay.

Task.Delay также можно использовать для организации простого тайм-аута. Обычно для реализации тайм-аута используется тип Cancellation­TokenSource  (рецепт 10.3). Его можно упаковать в Task.Delay с неограниченной задержкой, чтобы предоставить задачу, которая отменяется по истечении заданного времени. Наконец, используйте задачу с таймером в сочетании с Task.WhenAny (рецепт 2.5) для реализации «мягкого» тайм-аута. Следующий пример возвращает null, если служба не вернет ответ в течение 3 секунд:

async Task<string> DownloadStringWithTimeout(HttpClient client, string uri)

{

  using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

  Task<string> downloadTask = client.GetStringAsync(uri);

  Task timeoutTask = Task.Delay(Timeout.InfiniteTimeSpan, cts.Token);

 

  Task completedTask = await Task.WhenAny(downloadTask, timeoutTask);

  if (completedTask == timeoutTask)

    return null;

  return await downloadTask;

}

И хотя Task.Delay можно использовать для реализации «мягкого» тайм-аута, у такого подхода есть свои ограничения. Если в операции происходит тайм-аут, она не отменяется; в предыдущем примере задача загрузки продолжит прием данных и загрузит весь ответ перед тем, как потерять его. Рекомендуемое решение основано на использовании маркера отмены (cancellation token) в качестве тайм-аута и передаче его операции напрямую (GetStringAsync в последнем примере). При этом операция может оказаться неотменяемой; в этом случае Task.Delay может использоваться другим кодом для имитации действий, выполняемых по тайм-ауту.

Пояснение

Task.Delay неплохо подходит для модульного тестирования асинхронного кода или реализации логики повторных попыток. Но если нужно реализовать тайм-аут, лучшим кандидатом будет CancellationToken.

Дополнительная информация

В рецепте 2.5 рассматривается использование Task.WhenAny для определения того, какая задача завершится первой.

В рецепте 10.3 рассматривается использование CancellationToken в качестве тайм-аута.

2.2. Возвращение завершенных задач

Задача

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

Решение

Можно использовать Task.FromResult для создания и возвращения нового объекта Task<T>, уже завершенного с заданным значением:

interface IMyAsyncInterface

{

  Task<int> GetValueAsync();

}

 

class MySynchronousImplementation : IMyAsyncInterface

{

  public Task<int> GetValueAsync()

  {

    return Task.FromResult(13);

  }

}

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

interface IMyAsyncInterface

{

  Task DoSomethingAsync();

}

 

class MySynchronousImplementation : IMyAsyncInterface

{

  public Task DoSomethingAsync()

  {

    return Task.CompletedTask;

  }

}

Task.FromResult предоставляет завершенные задачи только для успешных результатов. Если потребуется задача с другим типом результата (например, задача, завершенная с NotImplementedException), вы можете использовать Task.FromException:

Task<T> NotImplementedAsync<T>()

{

  return Task.FromException<T>(new NotImplementedException());

}

Аналогично существует метод Task.FromCanceled для создания задач, уже отмененных из заданного маркера CancellationToken:

Task<int> GetValueAsync(CancellationToken cancellationToken)

{

  if (cancellationToken.IsCancellationRequested)

    return Task.FromCanceled<int>(cancellationToken);

  return Task.FromResult(13);

}

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

interface IMyAsyncInterface

{

  Task DoSomethingAsync();

}

 

class MySynchronousImplementation : IMyAsyncInterface

{

  public Task DoSomethingAsync()

  {

    try

    {

      DoSomethingSynchronously();

      return Task.CompletedTask;

    }

    catch (Exception ex)

    {

      return Task.FromException(ex);

    }

  }

}

Пояснение

Если вы реализуете асинхронный интерфейс синхронным кодом, избегайте любых форм блокировки. Избегайте блокирования с последующим возвращением завершенной задачи в асинхронном методе, если метод может быть реализован асинхронно. В качестве контрпримера рассмотрим средства чтения текста из Console в .NET BCL. Console.In.ReadLineAsync блокирует вызывающий поток, пока не будет прочитана строка, после чего возвращает завершенную задачу. Такое поведение не интуитивно, оно преподносило сюрпризы многим разработчикам. Если асинхронный метод блокируется, он не позволяет вызывающему потоку запускать другие задачи, что противоречит идее конкурентности и может привести к взаимоблокировке.

Если вы регулярно используете Task.FromResult с одним значением, подумайте о кэшировании задачи. Например, если вы один раз создали Task<int> с нулевым результатом, избегайте создания других экземпляров, которые должны будут уничтожаться в ходе уборки мусора:

private static readonly Task<int> zeroTask = Task.FromResult(0);

Task<int> GetValueAsync()

{

  return zeroTask;

}

На логическом уровне Task.FromResult, Task.FromException и Task.FromCanceled  являются вспомогательными методами и сокращенными формами обобщенного типа TaskCompletionSource<T>. TaskCompletionSource<T> представляет собой низкоуровневый тип, полезный для взаимодействия с другими формами асинхронного кода. В общем случае следует применять сокращенную форму Task.FromResult и родственные формы, если хотите вернуть уже завершенную задачу. Используйте TaskCompletionSource<T> для возвращения задачи, которая завершается в некоторый момент будущего.

Дополнительная информация

В рецепте 7.1 рассматривается модульное тестирование асинхронных методов.

В рецепте 11.1 рассматривается наследование async-методов.

В рецепте 8.3 показано, как использовать TaskCompletionSource<T> для обобщенного взаимодействия с другим асинхронным кодом.

2.3. Передача информации о ходе выполнения операции

Задача

Требуется отреагировать на прогресс выполнения операции.

Решение

Используйте типы IProgress<T> и Progress<T>. Ваш async-метод должен получать аргумент IProgress<T>; здесь T — тип прогресса, о котором вы хотите сообщать:

async Task MyMethodAsync(IProgress<double> progress = null)

{

  bool done = false;

  double percentComplete = 0;

  while (!done)

  {

    ...

    progress?.Report(percentComplete);

  }

}

Пример использования в вызывающем коде:

async Task CallMyMethodAsync()

{

  var progress = new Progress<double>();

  progress.ProgressChanged += (sender, args) =>

  {

    ...

  };

  await MyMethodAsync(progress);

}

Пояснение

По действующим соглашениям параметр IProgress<T> может быть равен null, если вызывающей стороне не нужны уведомления о прогрессе; включите соответствующую проверку в свой async-метод.

Помните, что метод IProgress<T>.Report обычно является асинхронным. Это означает, что MyMethodAsync может продолжить выполнение перед сообщением о прогрессе.

По этой причине лучше определить T как неизменяемый тип (или по крайней мере тип-значение). Если T является изменяемым ссылочным типом, то вам придется самостоятельно создавать отдельную копию при каждом вызове IProgress<T>.Report.

Progress<T> сохраняет текущий контекст при создании и активизирует свой обратный вызов в этом контексте. Это означает, что если Progress<T> конструируется в UI-потоке, то вы сможете обновить пользовательский интерфейс из его обратного вызова, даже если асинхронный метод вызывает Report из фонового потока.

Если метод поддерживает уведомления о прогрессе, он также должен приложить максимальные усилия для поддержки отмены.

IProgress<T> не ограничивается одним асинхронным кодом; как прогресс, так и отмена также могут (и должны) использоваться в долгосрочном синхронном коде.

Дополнительная информация

В рецепте 10.4 рассматривается поддержка отмены в асинхронных методах.

2.4. Ожидание завершения группы задач

Задача

У вас есть несколько задач, и нужно подождать, пока они все закончатся.

Решение

Фреймворк предоставляет для этой цели метод Task.WhenAll. Метод получает несколько задач и возвращает задачу, которая завершается при завершении всех указанных задач.

Task task1 = Task.Delay(TimeSpan.FromSeconds(1));

Task task2 = Task.Delay(TimeSpan.FromSeconds(2));

Task task3 = Task.Delay(TimeSpan.FromSeconds(1));

 

await Task.WhenAll(task1, task2, task3);

Если все задачи имеют одинаковый тип результата и все завершаются успешно, то задача Task.WhenAll возвращает массив, содержащий результаты всех задач:

Task<int> task1 = Task.FromResult(3);

Task<int> task2 = Task.FromResult(5);

Task<int> task3 = Task.FromResult(7);

 

int[] results = await Task.WhenAll(task1, task2, task3);

 

// "results" содержит { 3, 5, 7 }

Есть перегруженная версия Task.WhenAll, которая получает IEnumerable с задачами; тем не менее я не рекомендую ее использовать. Каждый раз, когда я смешиваю асинхронный код с LINQ, на мой взгляд, код получается более понятным, когда я явно «материализую» последовательность (т. е. обрабатываю последовательность с созданием коллекции):

async Task<string> DownloadAllAsync(HttpClient client,

    IEnumerable<string> urls)

{

  // Определить действие, выполняемое для каждого URL.

  var downloads = urls.Select(url => client.GetStringAsync(url));

  // Обратите внимание: задачи еще не запущены,

  //  потому что последовательность не была обработана.

 

  // Запустить загрузку для всех URL одновременно.

  Task<string>[] downloadTasks = downloads.ToArray();

  // Все задачи запущены.

 

  // Асинхронно ожидать завершения всех загрузок.

  string[] htmlPages = await Task.WhenAll(downloadTasks);

 

  return string.Concat(htmlPages);

}

Пояснение

Если какие-либо задачи выдают исключения, то Task.WhenAll сообщает об отказе своей возвращенной задачи с этим исключением. Если сразу несколько задач выдают исключение, то все эти исключения помещаются в задачу Task, возвращаемую Task.WhenAll. Тем не менее при ожидании этой задачи будет выдано только одно из них. Если нужно каждое конкретное исключение, проверьте свойство Exception задачи Task, возвращаемой Task.WhenAll:

async Task ThrowNotImplementedExceptionAsync()

{

  throw new NotImplementedException();

}

 

async Task ThrowInvalidOperationExceptionAsync()

{

  throw new InvalidOperationException();

}

 

async Task ObserveOneExceptionAsync()

{

  var task1 = ThrowNotImplementedExceptionAsync();

  var task2 = ThrowInvalidOperationExceptionAsync();

 

  try

  {

    await Task.WhenAll(task1, task2);

  }

  catch (Exception ex)

  {

    // "ex" - либо NotImplementedException, либо InvalidOperationException.

    ...

  }

}

 

async Task ObserveAllExceptionsAsync()

{

  var task1 = ThrowNotImplementedExceptionAsync();

  var task2 = ThrowInvalidOperationExceptionAsync();

 

  Task allTasks = Task.WhenAll(task1, task2);

  try

  {

    await allTasks;

  }

  catch

  {

    AggregateException allExceptions = allTasks.Exception;

    ...

  }

}

Как правило, я не отслеживаю все исключения при использовании Task.WhenAll. Обычно достаточно отреагировать только на первую выданную ошибку, а не на все.

Обратите внимание: в предыдущем примере методы ThrowNot­Imple­mentedExceptionAsync и ThrowInvalidOperationExceptionAsync не выдают свои исключения напрямую; они используют ключевое слово async, поэтому исключения перехватываются и помещаются в задачу, которая возвращается нормальным образом. Это нормальное поведение методов, которые возвращают типы, допускающие ожидание.

Дополнительная информация

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

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

В рецепте 2.8 рассматривается обработка исключений для методов asyncTask.

2.5. Ожидание завершения любой задачи

Задача

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

Решение

Используйте метод Task.WhenAny. Метод Task.WhenAny получает последовательность задач и возвращает задачу, которая завершается при завершении любой из задач последовательности. Результатом возвращенной задачи является завершенная задача. Не огорчайтесь, если это прозвучало непонятно; некоторые вещи трудно объяснить, но легко понять на примере кода:

// Возвращает длину данных первого ответившего URL-адреса.

async Task<int> FirstRespondingUrlAsync(HttpClient client,

    string urlA, string urlB)

{

  // Запустить обе загрузки параллельно.

  Task<byte[]> downloadTaskA = client.GetByteArrayAsync(urlA);

  Task<byte[]> downloadTaskB = client.GetByteArrayAsync(urlB);

 

  // Ожидать завершения любой из этих задач.

  Task<byte[]> completedTask =

      await Task.WhenAny(downloadTaskA, downloadTaskB);

 

  // Вернуть длину данных, загруженных по этому URL-адресу.

  byte[] data = await completedTask;

  return data.Length;

}

Пояснение

Задача, возвращенная Task.WhenAny, никогда не завершается в состоянии отказа или отмены. Эта «внешняя» задача всегда завершается успешно, а ее результирующее значение представляет собой первую завершенную задачу Task («внутреннюю»). Если внутренняя задача завершилась с исключением, то это исключение не распространяется на внешнюю задачу (возвращенную Task.WhenAny). Обычно ваш код ожидает внутреннюю задачу посредством await, чтобы обеспечить отслеживание всех исключений.

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

Вы можете использовать Task.WhenAny для реализации тайм-аута (например, при использовании Task.Delay как одной из задач), но так поступать не рекомендуется. Более естественно выражать тайм-ауты отменой, и у отмены есть дополнительное преимущество: она позволяет действительно отменить операцию(-и) в случае тайм-аута.

Другой антипаттерн Task.WhenAny — обработка задач по мере их завершения. Сначала может показаться разумным вести список задач и удалять каждую задачу из списка при завершении. Проблема в том, что такое решение выполняется за время O(N2), хотя существует алгоритм со временем O(N). Правильный алгоритм O(N) рассматривается в рецепте 2.6.

Дополнительная информация

В рецепте 2.4 рассматривается асинхронное ожидание завершения всех задач из набора.

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

В рецепте 10.3 рассматривается использование маркера отмены для реализации тайм-аута.

2.6. Обработка задач при завершении

Задача

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

Следующий пример запускает три отложенные задачи, а затем ожидает каждую из них:

async Task<int> DelayAndReturnAsync(int value)

{

  await Task.Delay(TimeSpan.FromSeconds(value));

  return value;

}

 

// В текущей версии метод выводит "2", "3" и "1".

// При этом метод должен выводить "1", "2" и "3".

async Task ProcessTasksAsync()

{

  // Создать последовательность задач.

  Task<int> taskA = DelayAndReturnAsync(2);

  Task<int> taskB = DelayAndReturnAsync(3);

  Task<int> taskC = DelayAndReturnAsync(1);

  Task<int>[] tasks = new[] { taskA, taskB, taskC };

 

  // Ожидать каждую задачу по порядку.

  foreach (Task<int> task in tasks)

  {

    var result = await task;

    Trace.WriteLine(result);

  }

}

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

Решение

Есть несколько разных подходов к решению этой задачи. Метод, который описан первым в этом рецепте, является рекомендуемым; другой описан в разделе «Пояснение».

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

async Task<int> DelayAndReturnAsync(int value)

{

  await Task.Delay(TimeSpan.FromSeconds(value));

  return value;

}

 

async Task AwaitAndProcessAsync(Task<int> task)

{

  int result = await task;

  Trace.WriteLine(result);

}

 

// Этот метод теперь выводит "1", "2" и "3".

async Task ProcessTasksAsync()

{

  // Создать последовательность задач.

  Task<int> taskA = DelayAndReturnAsync(2);

  Task<int> taskB = DelayAndReturnAsync(3);

  Task<int> taskC = DelayAndReturnAsync(1);

  Task<int>[] tasks = new[] { taskA, taskB, taskC };

 

  IEnumerable<Task> taskQuery =

      from t in tasks select AwaitAndProcessAsync(t);

  Task[] processingTasks = taskQuery.ToArray();

 

  // Ожидать завершения всей обработки

  await Task.WhenAll(processingTasks);

}

С другой стороны, этот код можно записать в следующем виде:

async Task<int> DelayAndReturnAsync(int value)

{

  await Task.Delay(TimeSpan.FromSeconds(value));

  return value;

}

 

// Этот метод теперь выводит "1", "2" и "3".

async Task ProcessTasksAsync()

{

  // Создать последовательность задач.

  Task<int> taskA = DelayAndReturnAsync(2);

  Task<int> taskB = DelayAndReturnAsync(3);

  Task<int> taskC = DelayAndReturnAsync(1);

  Task<int>[] tasks = new[] { taskA, taskB, taskC };

  Task[] processingTasks = tasks.Select(async t =>

  {

    var result = await t;

    Trace.WriteLine(result);

  }).ToArray();

 

  // Ожидать завершения всей обработки.

  await Task.WhenAll(processingTasks);

}

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

Пояснение

Если рефакторинг не дает приемлемого решения, есть альтернатива. Стивен Тауб (Stephen Toub) и Джон Скит (Jon Skeet) разработали методы расширения, возвращающие массив задач, которые завершаются по порядку. Решение Стивена Тауба (Stephen Toub) доступно в блоге Parallel Programming with .NET (/), а решение Джона Скита (Jon Skeet) — в его блоге, посвященном программированию (/).

lemur.tiff

Метод расширения OrderByCompletion также доступен в библиотеке с открытым кодом AsyncEx (NuGet-пакет Nito.AsyncEx).

С таким методом расширения, как OrderByCompletion, изменения в исходной версии кода сводятся до минимума:

async Task<int> DelayAndReturnAsync(int value)

{

  await Task.Delay(TimeSpan.FromSeconds(value));

  return value;

}

 

// Этот метод теперь выводит "1", "2" и "3".

async Task UseOrderByCompletionAsync()

{

  // Создать последовательность задач.

  Task<int> taskA = DelayAndReturnAsync(2);

  Task<int> taskB = DelayAndReturnAsync(3);

  Task<int> taskC = DelayAndReturnAsync(1);

  Task<int>[] tasks = new[] { taskA, taskB, taskC };

 

  // Ожидать каждой задачи по мере выполнения.

  foreach (Task<int> task in tasks.OrderByCompletion())

  {

    int result = await task;

    Trace.WriteLine(result);

  }

}

Дополнительная информация

В рецепте 2.4 рассматривается асинхронное ожидание завершения последовательности задач.

2.7. Обход контекста при продолжении

Задача

Когда async-метод возобновляет работу после await, по умолчанию он продолжает выполнение в том же контексте. Это может создать проблемы с быстродействием, если контекстом был UI-контекст, а в UI-контексте возобновляет работу большое количество async-методов.

Решение

Чтобы избежать возобновления в контексте, используйте await для результата ConfigureAwait и передайте false в параметре continueOnCapturedContext:

async Task ResumeOnContextAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1));

 

  // Этот метод возобновляется в том же контексте.

}

 

async Task ResumeWithoutContextAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);

 

  // Этот метод теряет свой контекст при возобновлении.

}

Пояснение

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

Остается понять: сколько продолжений в UI-потоке превышает допустимый порог? Простого и однозначного ответа на этот вопрос нет, но Люциан Вищик из Microsoft огласил рекомендацию, которая использовалась командой Universal Windows: около сотни в секунду — нормально, но около тысячи в секунду — уже слишком много.

Лучше обойти эту проблему с самого начала. Для каждого написанного вами async-метода, если он не должен возобновляться в исходном контексте, используйте ConfigureAwait. Никаких неудобств это не создаст.

Также стоит учитывать контекст при написании async-кода. Обычно async-метод должен либо требовать определенного контекста (работа с UI-элементами или запросами/ответами ASP.NET), либо быть свободным от контекста (выполняя фоновые операции). Если у вас имеется async-метод с частями, требующими контекста, и частями, свободными от контекста, рассмотрите возможность его разбиения на два (или более) async-метода. Такой подход помогает организовать код по уровням.

Дополнительная информация

В главе 1 рассматривается введение в асинхронное программирование.

2.8. Обработка исключений из методов async Task

Задача

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

Решение

Исключения можно перехватывать простой конструкцией try/catch, как вы бы сделали для синхронного кода:

async Task ThrowExceptionAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1));

  throw new InvalidOperationException("Test");

}

 

async Task TestAsync()

{

  try

  {

    await ThrowExceptionAsync();

  }

  catch (InvalidOperationException)

  {

  }

}

Исключения, выданные из методов asyncTask, помещаются в возвращаемый объект Task. Они выдаются только при использовании await с возвращаемым объектом Task:

async Task ThrowExceptionAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1));

  throw new InvalidOperationException("Test");

}

 

async Task TestAsync()

{

  // Исключение выдается методом и помещается в задачу.

  Task task = ThrowExceptionAsync();

  try

  {

    // Здесь исключение будет выдано повторно.

    await task;

  }

  catch (InvalidOperationException)

  {

    // Здесь исключение правильно перехватывается.

  }

}

Пояснение

Когда в методе asyncTask выдается исключение, это исключение сохраняется и включается в возвращаемый объект Task. Так как методы asyncvoid не имеют объекта Task для размещения исключения, для них используется другое поведение; перехват исключений из методов asyncvoid рассматривается в рецепте 2.9.

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

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

Возможны ситуации (например, с Task.WhenAll), в которых Task может содержать несколько исключений, а await повторно выдает только первое из них. За примером обработки всех исключений обращайтесь к рецепту 2.4.

Дополнительная информация

В рецепте 2.4 рассматривается ожидание для нескольких задач.

В рецепте 2.9 рассматриваются методы перехвата исключений из методов asyncvoid.

В рецепте 7.2 рассматриваются исключения модульного тестирования, выданные из методов asyncTask.

2.9. Обработка исключений из методов async void

Задача

Имеется метод asyncvoid. Требуется обработать исключения, распространенные из этого метода.

Решение

Хорошего решения не существует. Если возможно, измените метод так, чтобы он возвращал Task вместо void. В некоторых ситуациях это невозможно; например, представьте, что нужно провести модульное тестирование реализации ICommand (которая должна возвращать void). В этом случае необходимо предоставить перегруженную версию вашего метода Execute, которая возвращает Task:

sealed class MyAsyncCommand : ICommand

{

  async void ICommand.Execute(object parameter)

  {

    await Execute(parameter);

  }

 

  public async Task Execute(object parameter)

  {

    ... // Здесь размещается асинхронная реализация команды.

  }

 

  ... // Другие составляющие (CanExecute и т.д.)

}

Лучше избегать распространения исключений из методов asyncvoid. Если же вы должны использовать метод asyncvoid, рассмотрите возможность упаковки всего кода в блок try и прямой обработки исключений.

Существует и другой возможный способ обработки исключений из методов asyncvoid. Когда метод asyncvoid распространяет исключение, это исключение выдается в контексте SynchronizationContext, активном на момент начала выполнения метода asyncvoid. Если среда выполнения предоставляет SynchronizationContext, то обычно она предоставляет механизм обработки этих высокоуровневых исключений на глобальном уровне. Например, WPF предоставляет Application.DispatcherUnhandledException, Universal Windows — Application.UnhandledException, а ASP.NET — UseExceptionHandler.

Также возможно обрабатывать исключения из методов asyncvoid посредством управления SynchronizationContext. Написать собственный вариант SynchronizationContext непросто, но можно воспользоваться типом AsyncContext из бесплатной вспомогательной NuGet-библиотеки Nito.AsyncEx. Тип AsyncContext особенно полезен для приложений, не имеющих встроенного объекта SynchronizationContext (например, консольных приложений и служб Win32). В следующем примере AsyncContext используется для запуска и обработки исключений из метода asyncvoid:

static class Program

{

  static void Main(string[] args)

  {

    try

    {

      AsyncContext.Run(() => MainAsync(args));

    }

    catch (Exception ex)

    {

      Console.Error.WriteLine(ex);

    }

  }

 

  // ПЛОХОЙ КОД!!!

  // В реальных приложениях не используйте метод async void

  // без крайней необходимости.

  static async void MainAsync(string[] args)

  {

    ...

  }

}

Пояснение

Одна из причин, по которым стоит отдать предпочтение async Task перед методами asyncvoid, заключается в том, что методы, возвращающие Task, проще тестировать. Как минимум перегрузка методов, возвращающих void, методами, возвращающими Task, предоставит поверхность API, удобную для тестирования.

Если нужно предоставить ваш собственный тип SynchronizationContext  (например, AsyncContext), будьте внимательны и не устанавливайте этот контекст SynchronizationContext в потоках, которые вам не принадлежат. Как правило, этот тип не должен размещаться в потоках, в которых он уже есть (например, UI-потоках или классических потоках запросов ASP.NET); также не стоит размещать SynchronizationContext в потоках из пула потоков. Главный поток консольного приложения принадлежит вам, как и все потоки, которые вы самостоятельно создаете вручную.

lemur.tiff

Тип AsyncContext находится в пакете Nito.AsyncEx.

Дополнительная информация

В рецепте 2.8 рассматривается обработка исключений с методами asyncTask.

В рецепте 7.3 рассматривается модульное тестирование методов asyncvoid.

2.10. Создание ValueTask

Задача

Требуется создать метод, возвращающий ValueTask<T>.

Решение

ValueTask<T> используется как возвращаемый тип в ситуациях, в которых обычно может быть возвращен синхронный результат, а асинхронное поведение встречается реже. В общем случае в коде приложения следует использовать в качестве возвращаемого типа Task<T>, а не ValueTask<T>. Рассматривать использование ValueTask<T> в качестве возвращаемого типа следует только после профилирования, которое показывает, что это приведет к повышению быстродействия. Впрочем, возможны ситуации, в которых требуется реализовать метод, возвращающий ValueTask<T>. Одна из таких ситуаций встречается при использовании интерфейса IAsyncDisposable, метод DisposeAsync которого возвращает ValueTask. За более подробным пояснением асинхронного освобождения ресурсов обращайтесь к рецепту 11.6.

Простейший способ реализации метода, возвращающего ValueTask<T>, основан на использовании async и await, как и обычный async-метод:

public async ValueTask<int> MethodAsync()

{

  await Task.Delay(100); // Асинхронная работа.

  return 13;

}

Нередко метод, возвращающий ValueTask<T>, способен немедленно вернуть значение; в таких случаях можно применить оптимизацию для этого сценария с использованием конструктора ValueTask<T>, а затем передавать управление медленному асинхронному методу только при необходимости:

public ValueTask<int> MethodAsync()

{

  if (CanBehaveSynchronously)

    return new ValueTask<int>(13);

  return new ValueTask<int>(SlowMethodAsync());

}

 

private Task<int> SlowMethodAsync();

Аналогичный подход возможен для ValueTask без параметризации. Здесь конструктор по умолчанию ValueTask используется для возвращения успешно завершенного объекта ValueTask. В следующем примере показана реализация IAsyncDisposable, которая выполняет свою логику асинхронного освобождения однократно; при будущих вызовах метод DisposeAsync завершается успешно и синхронно:

private Func<Task> _disposeLogic;

 

public ValueTask DisposeAsync()

{

  if (_disposeLogic == null)

    return default;

 

  // Примечание: этот простой пример не является потокобезопасным;

  //  если сразу несколько потоков вызовут DisposeAsync,

  //  логика может быть выполнена более одного раза.

  Func<Task> logic = _disposeLogic;

  _disposeLogic = null;

  return new ValueTask(logic());

}

Пояснение

Большинство методов должно возвращать Task<T>, поскольку при потреб­лении Task<T> возникает меньше скрытых ловушек, чем при потреблении ValueTask<T>. Подробности см. в рецепте 2.11.

Чаще при реализации интерфейсов, использующих ValueTask или Value­Task<T>, можно просто применять async и await. Более сложные реализации нужны тогда, когда вы собираетесь использовать ValueTask<T> самостоятельно.

Решения, рассмотренные в этом рецепте, соответствуют более простым и распространенным подходам к созданию экземпляров ValueTask<T> и ValueTask. Есть другой способ, более подходящий для сложных сценариев, в которых выделение ресурсов должно быть сведено к абсолютному минимуму. В более сложном решении вы кэшируете или помещаете в пул реализацию IValueTaskSource<T> и повторно используете ее для многих вызовов асинхронных методов. За вводным описанием сложного сценария обращайтесь к документации Microsoft по типу ManualResetValueTask­SourceCore<T>.

Дополнительная информация

В рецепте 2.11 рассматриваются ограничения при потреблении типов ValueTask<T> и ValueTask.

В рецепте 11.6 рассматривается асинхронное освобождение.

2.11. Потребление ValueTask

Задача

Требуется организовать потребление ValueTask<T>.

Решение

Самый простой и прямолинейный способ потребления ValueTask<T> или ValueTask основан на await. В большинстве случаев это все, что вам необходимо сделать:

ValueTask<int> MethodAsync();

async Task ConsumingMethodAsync()

{

  int value = await MethodAsync();

}

Также можно выполнить await после выполнения конкурентной операции, как в случае с Task<T>:

ValueTask<int> MethodAsync();

 

async Task ConsumingMethodAsync()

{

  ValueTask<int> valueTask = MethodAsync();

  ... // Другая параллельная работа.

  int value = await valueTask;

}

Оба варианта подходят, потому что ValueTask ожидается только один раз. Это одно из ограничений ValueTask.

scorp.tiff

ValueTask или ValueTask<T> может ожидаться только один раз.

Чтобы сделать что-то более сложное, преобразуйте ValueTask<T> в Task<T> вызовом AsTask:

ValueTask<int> MethodAsync();

async Task ConsumingMethodAsync()

{

  Task<int> task = MethodAsync().AsTask();

  ... // Другая параллельная работа.

  int value = await task;

  int anotherValue = await task;

}

Многократное ожидание Task<T> абсолютно безопасно. Также возможны другие операции — например, асинхронное ожидание завершения нескольких операций (см. рецепт 2.4):

ValueTask<int> MethodAsync();

 

async Task ConsumingMethodAsync()

{

  Task<int> task1 = MethodAsync().AsTask();

  Task<int> task2 = MethodAsync().AsTask();

  int[] results = await Task.WhenAll(task1, task2);

}

Тем не менее для каждого ValueTask<T> можно вызвать AsTask только один раз. Самое распространенное решение — немедленно преобразовать его в Task<T>, а в дальнейшем игнорировать ValueTask<T>. Также замечу, что вы не можете одновременно использовать await и вызвать AsTask для одного ValueTask<T>.

В большинстве программ следует либо немедленно выполнить await для ValueTask<T>, либо преобразовать значение в Task<T>.

Пояснение

Другие свойства ValueTask<T> предназначены для нетривиального использования. Обычно они работают не так, как другие известные свойства; в частности, для ValueTask<T>.Result действуют более жесткие ограничения, чем для Task<T>.Result. Код, который синхронно получает результат от ValueTask<T>, может вызвать ValueTask<T>.Result или ValueTask<T>, GetAwaiter().GetResult(), но эти компоненты не должны вызываться до завершения ValueTask<T>. Синхронная загрузка результата из Task<T> блокирует вызывающий поток до завершения задачи; ValueTask<T> таких гарантий не дает.

scorp.tiff

Синхронное получение результатов от ValueTask или ValueTask<T> может быть выполнено только один раз, после завершения ValueTask, и это значение ValueTask уже не может использоваться для ожидания или преобразования в задачу.

Рискуя повториться, все же скажу: когда ваш код вызывает метод, возвращающий ValueTask или ValueTask<T>, он должен либо немедленно выполнить await для этого ValueTask, либо немедленно вызвать AsTask для преобразования в Task. Возможно, эта простая рекомендация не исчерпывает все нетривиальные сценарии, но большинству приложений этого будет вполне достаточно.

Дополнительная информация

В рецепте 2.10 рассматривается возвращение значений ValueTask<T> и ValueTask из ваших методов.

В рецептах 2.4 и 2.5 рассматривается одновременное ожидание нескольких задач.

Назад: Глава 1. Конкурентность: общие сведения
Дальше: Глава 3. Асинхронные потоки