Пользователь
0,0
рейтинг
12 декабря 2012 в 16:07

Разработка → Использование async и await в C# — лучшие практики перевод tutorial


Ключевые слова 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.

Дополнительные ссылки (на английском)
Перевод: Jon Wagner
@bitmap
карма
98,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (24)

  • +7
    Спасибо. Понятно и разложено по полочкам.

    Вообще я очень благодарен команде C# за async/await, а то последнее время код стал превращаться в что-то jQuery-подобное, когда линейный код выглядел как 20 вложенных анонимных функций.
    • 0
      AsyncEnumerator — «наше всё» уже 2+ года, тем более, что для его использования достаточно C# 2.0 и .Net Framework 2.0.
      А так, конечно, хочется уже и без «костылей» асинхронный код в линейном виде писать.
      • –1
        Вроде бы Рихтер приложил руку и к async/await. На моем опыте код становится в разы понятнее и лаконичнее.
  • +2
    Производительность: предпочитайте кэшировать сами задачи, нежели их данные
    Несколько капитанский совет получился. Я с трудом представляю себе метод, который кэширует данные, но при этом возвращает задачи. Он что, возвращает только уже выполненные задачи? Хотелось бы увидеть работающий пример плохого и неправильного кода.

    Производительность: понимайте, как await сохраняет состояние
    Замечание 1. Если вы декларируете переменную, она сохранится в объекте, хранящем состояние. Это может привести к тому, что объекты будут оставаться в памяти дольше, чем вы бы могли ожидать.
    Не может. Поля объекта, относящиеся к выходящим из области видимости переменным, обNULLяются автоматически.

    Замечание 2. Но если вы не станете декларировать переменную, а использовать значение Async вызова вместе с await, переменная попадёт во внутренний стек:

    Результат вызова await всегда попадает в поле — по другому компилятор просто не умеет.
    • 0
      Вы заблуждаетесь, это верно только если метод не использует async/await.
      А если использует, то вычисленные значения попадают не в локальную переменную метода (или что вы имели в виду под «полем»), а в стек стейт-машины в его поле:
      private object <>t__stack;
      


      Здесь описано как это работает, а в ILSpy вы можете посмотреть какой код генерирует компилятор (если отключите декомпиляцию async/await).
      • 0
        А если использует, то вычисленные значения попадают не в локальную переменную метода (или что вы имели в виду под «полем»), а в стек стейт-машины в его поле


        Вы издеваетесь или как? Именно это поле я и имел в виду. При чем тут локальная переменная метода?

        PS Проверил в ILSpy, нет там обNULLения, я ошибся. Странно, где же я его видел?
        • 0
          Ну я то думал, что внутренний стек == поле <>t__stack; и тогда не понятно, что же вы имели в виду?

          Однако, автор же, очевидно, просто имел в виду то, что если вы используете await в параметре, то значение будет храниться в этом поле, а если присваиваете результат await переменной — для неё компилятор сделает собственное поле в стейт-машине (что будет быстрее работать, нежели расширять Tuple-ы со значениями для параметров и класть их в t__stack в рантайме).
    • 0
      Значит у вас плохая фантазия. Кешировать данные — более очевидно чем таски. Ибо долго достаются именно данные, а не создаются таски.
      • 0
        Очевидно-то более, но я не представляю, как будет работать параллельное получение данных с кэшированием этих самых данных. Вот и хочется посмотреть на получившийся код того человека, который не догадался до кэширования тасков.
        • 0
          Я кажется понял о чем вы. Да, таски будут в любом случае, разница лишь в том что ляжет в кеш, сами таски или их результат.
  • +3
    В качестве сборника простых понятных примеров рекомендую вот эту вещь (код можно менять на лету).
  • +3
    мне понравился один комментарий к stackoverflow.com/questions/6023264/high-performance-tcp-server-in-c-sharp:
    Long story short: learn async or die trying…
  • 0
    «Лучшие практики» — это вообще что значит? Какой это язык?
    • +2
      Best practices — лучшие приёмы, секреты мастерства.
  • 0
    Предпочитайте async/await вместо Task

    Но внутри MyWebService.FetchDataAsync() будет return Task.Factory.StartNew(() =>…

    Как-то не получается вместо. Получается вместе. Может кто-нибудь прояснит этот момент?
    • 0
      Вы одновременно и правы и не совсем правы :)
      Вообще говоря async/await работает поверх awaitable типов, самый очевидный из которых Task, и в самом простом случае конечно async/await это _надстройка_ над Task.
      Но awaitить можно не только Task, а любой тип реализующий await контракт (наличие метода GetAwaiter возвращающего объект с property IsCompleted, методом OnCompleted(Action) и методом T GetResult())

      Это довольно важно в случае с overlapped IO, когда для ожидания результата IO не нужна отдельная нить( а Task работает именно с нитями через ThreadPool)
      • 0
        Task совершенно не обязательно работает с нитями.
        Задачу можно создать при помощи TaskCompletionSource, тогда она не будет требовать отдельной нити для выполнения.
        • 0
          Вы правы.
          Я почему то посмотрел на пример тов. RouR про Task.Factory.StartNew и забыл о такой возможности
  • +2
    ConfigureAwait(false) надо использовать в любом коде, который не нуждается в доступе к потоку UI, но запускается из потока UI, иначе управление будет постоянно возвращаться к нему и часть работы будет выполняться в потоке UI. Запустите профайлер Visual Studio и убедитесь в этом.

    Короче, я не использую ConfigureAwait(false) только в асинхронных обработчиках событий от UI.
  • 0
    Интересная статья, но машинный перевод удручает.
    Не переводите то что переводить не нужно. Reentrance, SynchronizationContext (это банально класс), SecurityContext (тоже)
    Машина состояний это в данном случае конечный автомат, контекст ASP.NET — это Request Context.

    "… по выбору функциональсти, которая должна быть асинхронной." — choosing methods that should be async. Тут странная замена методов на функциональность.

    "… я предпочитаю async/await вместо создания цепочек задач посредством Task." — I prefer async/await to setting up Task chains
    Вы как то легко вставляете слово «задач». Ведь тут речь не о цепочках задач, а о цепочках Task. Первое — это нечто абстрактное, а второе — технический прием.
    • 0
      Вот и я местами чтобы понять о чем речь переводил в голове дословно обратно на английский.
  • 0
    Используйте async /await только для тех мест, которые могут длиться «долго»

    Не совсем корректно. async/await подходит для IO-bound операций, т.е. работа с сетью, диском, где код ничего не делает, а сидит и ждет ответа на IO порт. Для CPU-bound задач, которые тоже могут длиться «долго» и потребляют ресурсы постоянно, разумнее использовать параллелизацию и фоновые потоки.
    • 0
      Фоновой поток вполне можно обернуть в задачу.
      • 0
        Само собой. Но использование одного только async/await для CPU-bound кода делу несильно поможет.

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