Ключевые слова async и await, введённые в C# 5.0, значительно упрощают асинхронное программирование. Они также скрывают за собой некоторые сложности, которые, если вы потеряете бдительность, могут добавить проблем в ваш код. Описанные ниже практики пригодятся вам, если вы создаёте асинхронный код для .NET приложений.
Используйте async /await только для тех мест, которые могут длиться «долго»
Здесь всё просто. Создание
Task
и других структур для управления асинхронными операциями добавляет некоторые накладные расходы. Если ваша операция действительно продолжительна, например выполнение IO запроса, тогда эти расходы в основном не будут заметны. А в том случае, если ваша операция коротка или займёт несколько циклов процессора, тогда возможно будет лучше выполнять эту операцию синхронно.В целом, команда, работавшая над .NET Framework, проделала неплохую работу по выбору функциональсти, которая должна быть асинхронной. Так, если метод фреймворка заканчивается на
Async
и возвращает задачу, тогда, скорее всего вы должны использовать его асинхронно.Предпочитайте async/await вместо Task
Написание асинхронного кода, используя
async/await
, намного упрощает и сам процесс создания кода, и его чтение, нежели использование задач Task
.public Task<Data> GetDataAsync()
{
return MyWebService.FetchDataAsync()
.ContinueWith(t => new Data (t.Result));
}
public async Task<Data> GetDataAsync()
{
var result = await MyWebService.FetchDataAsync();
return new Data (result);
}
В терминах производительности, оба метода, представленные выше, имеют небольшие накладные расходы, но они несколько по-разному масштабируются при увеличении количества задач в них:
-
Task
строит цепочку продолжений, которая увеличивается в соответствии с количеством задач, связанных последовательно, и состояние системы управляется через замыкания, найденные компилятором. Async/await
строит машину состояний, которая не использует дополнительных ресурсов при добавлении новых шагов. Однако компилятор может определить больше переменных для сохранение в стеки машины состояний, в зависимости от вашего кода (и компилятора). В статье на MSDN отлично расписаны детали происходящего.
В большинстве сценариев
async/await
будет использовать меньше ресурсов и выполняться быстрее, чем задачи Task
.Используйте уже выполненную пустую статическую задачу для условного кода
Иногда вы хотите запустить задачу только при каком-то условии. К сожалению,
await
вызовет NullReferenceException
, если получит null
вместо задачи, а обработка этого сделает ваш код менее читабельным.public async Task<Data> GetDataAsync(bool getLatestData)
{
Task<WebData> task = null;
if (getLatestData)
task = MyWebService.FetchDataAsync();
// здесь выполним другую работу
// и не забудем проверить на null
WebData result = null;
if (task != null)
result = await task;
return new Data (result);
}
Один из способов немного упростить код – использовать пустую задачу, которая уже выполнена. Полученный код будет чище:
public async Task<Data> GetDataAsync(bool getLatestData)
{
var task = getLatestData ? MyWebService.FetchDataAsync() : Empty<WebData>.Task;
// здесь выполним другую работу
// task всегда не null
return new Data (await task);
}
Убедитесь, что задача является статической и создана как завершённая. Например:
public static class Empty<T>
{
public static Task<T> Task { get { return _task; } }
private static readonly Task<T> _task = System.Threading.Tasks.Task.FromResult(default(T));
}
Производительность: предпочитайте кэшировать сами задачи, нежели их данные
Существую некоторые накладные расходы при создании задач. Если вы кэшируете ваши результаты, но потом конвертируете их обратно в задачи, вы, возможно, создаете дополнительные объекты задач.
public Task<byte[]> GetContentsOfUrl(string url)
{
byte[] bytes;
if (_cache.TryGetValue(url, out bytes))
// дополнительная задача создаётся здесь
return Task<byte[]>.Factory.StartNew(() => bytes);
bytes = MyWebService.GetContentsAsync(url)
.ContinueWith(t => { _cache.Add(url, t.Result); return t.Result; );
}
// это не потокобезоспасно (не копируйте себе этот код как есть)
private static Dictionary<string, byte[]> _cache = new Dictionary<string, byte[]>();
Вместо этого будет лучше копировать в кэш сами задачи. В этом случае использующий их код может ждать уже выполненную задачу. В Task Parallel Library присутствуют оптимизации для того, чтобы код ожидающий выполнения уже завершённой задачи выполнялся быстрее.
public Task<byte[]> GetContentsOfUrl(string url)
{
Task<byte[]> bytes;
if (!_cache.TryGetValue(url, out bytes))
{
bytes = MyWebService.GetContentsAsync(url);
_cache.Add(url, bytes);
}
return bytes;
}
// это не потокобезоспасно (не копируйте себе этот код как есть)
private static Dictionary<string, Task<byte[]>> _cache = new Dictionary<string, Task<byte[]>>();
Производительность: понимайте, как await сохраняет состояние
Когда вы используете
async/await
, компилятор создаёт машину состояний, которая хранит переменные и стек. Например:public static async Task FooAsync()
{
var data = await MyWebService.GetDataAsync();
var otherData = await MyWebService.GetOtherDataAsync();
Console.WriteLine("{0} = "1", data, otherdata);
}
Это создаст объект состояния с несколькими переменными. Смотрите, как компилятор сохранит переменные метода:
[StructLayout(LayoutKind.Sequential), CompilerGenerated]
private struct <FooAsync>d__0 : <>t__IStateMachine {
private int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public Action <>t__MoveNextDelegate;
public Data <data>5__1;
public OtherData <otherData>5__2;
private object <>t__stack;
private object <>t__awaiter;
public void MoveNext();
[DebuggerHidden]
public void <>t__SetMoveNextDelegate(Action param0);
}
Замечание 1. Если вы декларируете переменную, она сохранится в объекте, хранящем состояние. Это может привести к тому, что объекты будут оставаться в памяти дольше, чем вы бы могли ожидать.
Замечание 2. Но если вы не станете декларировать переменную, а использовать значение
Async
вызова вместе с await
, переменная попадёт во внутренний стек:public static async Task FooAsync()
{
var data = MyWebService.GetDataAsync();
var otherData = MyWebService.GetOtherDataAsync();
// промежуточные результаты попадут во внутренний стек и
// добавятся дополнительные переключения контекстов между await-ами
Console.WriteLine("{0} = "1", await data, await otherdata);
}
Вы не должны слишком сильно волноваться по этому поводу до тех пор, пока вы не видите проблем производительности. Если вы всё-таки решили углубиться в оптимизацию, на MSDN есть хорошая статья по этому поводу: Async Performance: Understanding the Costs of Async and Await.
Стабильность: async/await – это не Task.Wait
Машина состояний, генерируемая
async/await
– это не то же самое, что Task.ContinueWith/Wait
. В общем случае вы можете заменить реализацию с Task
на await
, но могут возникнуть некоторые проблемы производительности и стабильности. Давайте посмотрим подробнее.Стабильность: знайте свой контекст синхронизации
Код .NET всегда исполняется в некотором контексте. Этот контекст определяет текущего пользователя и другие значения, требуемые фреймворком. В некоторых контекстах выполнения, код работает в контексте синхронизации, который управляет выполнением задач и другой асинхронной работы.
По-умолчанию, после
await
код продолжит работать в контексте, в котором он был запущен. Это удобно, потому что в основном вы захотите, чтобы контекст безопасности был восстановлен, и вы хотите, чтобы ваш код после await имел доступ к объектам Windows UI, если он уже имел доступ к ним при старте. Заметим, что Task.Factory.StartNew
– не осуществляет восстановление контекста.Некоторые контексты синхронизации не поддерживают повторный вход в них и являются однопоточными. Это означает, что только одна единица работы может выполняться в этом контексте одновременно. Примером этого может быть поток Windows UI или контекст ASP.NET.
В таких однопоточных контекстах синхронизации довольно легко получить deadlock. Если вы создадите задачу в однопоточном контексте, и потом будете ждать в этом же контексте, ваш код, который ждёт, будет блокировать выполнение фоновой задачи.
public ActionResult ActionAsync()
{
// DEADLOCK: это блокирует асинхронную задачу
// которая ждёт, когда она сможет выполняться в этом контексте
var data = GetDataAsync().Result;
return View(data);
}
private async Task<string> GetDataAsync()
{
// простой вызов асинхронного метода
var result = await MyWebService.GetDataAsync();
return result.ToString();
}
Стабильность: не используйте
Wait
, чтобы дождаться окончания задачи прямо здесьКак основное правило – если вы создаёте асинхронный код, будьте осторожны c использованием
Wait
. (c await
всё несколько лучше.)Не используйте
Wait
для задач в однопоточных контекстах синхронизации, таких как:- Потоки UI
- Контекст ASP.NET
Хорошая новость заключается в том, что фреймворк позволяет вам возвращать
Task
в определённых случаях, и сам фреймворк будет ожидать выполнения задачи. Доверье ему этот процесс:public async Task<ActionResult> ActionAsync()
{
// этот метод использует async/await и возвращает Task
var data = await GetDataAsync();
return View(data);
}
Если вы создаёте асинхронные библиотеки, ваши пользователи должны будут писать асинхронный код. Раньше это было проблемой, так как написание асинхронного кода было утомительным и уязвимым для ошибок, но с появлением
async/await
большая часть сложности теперь обрабатывается компилятором. А ваш код получает большую надёжность, и вы теперь с меньше вероятностью будете вынуждены бороться с нюансами ThreadPool
.Стабильность: рассмотрите использование
ConfigureAwait
, если вы создаёте библиотекуЕсли вы обязаны ожидать выполнения задачи в одном из этих контекстов, вы можете использовать
ConfigureAwait
, чтобы сказать системе, что она не должна выполнять фоновую задачу в вашем контексте. Недостатком этого является то, что фоновая задача не будет иметь доступа к тому же самому контексту синхронизации, так что вы потеряете доступ к Windows UI или HttpContext
(хотя ваш контекст безопасности всё равно будет у вас).Если вы создаёте «библиотечную» функцию, которая возвращает
Task
, вы, скорее всего, не знаете, как она будет вызываться. Так что, возможно, будет безопаснее добавить ConfigureAwait(false)
к вашей задаче перед тем как её вернуть. private async Task<string> GetDataAsync()
{
// ConfigureAwait(false) говорит системе, чтобы она
// позволила оставшемуся коду выполняться в любом контексте
var result = await MyWebService.GetDataAsync().ConfigureAwait(false);
return result.ToString();
}
Стабильность: понимайте, как ведут себя исключения
Когда смотришь на асинхронный код, тяжело иногда сказать, что же случается с исключениями. Будет ли оно передано вызывающей функции, или тому коду, который ждёт выполнения задачи?
Правила в этом случае довольно прямолинейны, но всё равно иногда трудно ответить на вопрос, просто глядя на код.
Некоторые примеры:
- Исключения, вызванные из самого async/await метода, будут отправлены коду, ожидающему выполнения задачи (awaiter).
public async Task<Data> GetContentsOfUrl(string url) { // это исключение будет вызвано на коде, ожидающем // выполнения этой задачи if (url == null) throw new ArgumentNullException(); var data = await MyWebService.GetContentsOfUrl(); return data.DoStuffToIt(); }
- Исключения, вызванные из делегата задачи
Task
, тоже будут отправлены коду, ожидающему выполнения задачи (awaiter).
public Task<Data> GetContentsOfUrl(string url) { return Task<Data>.Factory.StartNew(() => { // это исключение будет вызвано на коде, ожидающем // выполнения этой задачи if (url == null) throw new ArgumentNullException(); var data = await MyWebService.GetContentsOfUrl(); return data.DoStuffToIt(); } }
- Исключения, вызванные во время создания Task, будут отправлены коду, который вызывал этот метод (caller) (что, в общем, очевидно):
public Task<Data> GetContentsOfUrl(string url) { // это исключение будет вызвано на вызывающем коде if (url == null) throw new ArgumentNullException(); return Task<Data>.Factory.StartNew(() => { var data = await MyWebService.GetContentsOfUrl(); return data.DoStuffToIt(); } }
Последний пример является одной из причин, почему я предпочитаю
async/await
вместо создания цепочек задач посредством Task
.Дополнительные ссылки (на английском)
- MSDN: Async/Await FAQ
- Об оптимизации await “быстрый путь”
- MSDN: Await, and UI, and deadlocks! Oh my!
- MSDN: Async Performance: Understanding the Costs of Async and Await