Асинхронное программирование – производительность async: понять расходы на async и await

Автор оригинала: Stephen Toub
  • Перевод

Это статья достаточно древняя, но не потерявшая актуальности. Когда разговор заходит об async/await, как правило, появляется ссылка на неё. Перевода на русский найти не смог, решил помочь кто не fluent.




Асинхронное программирование долгое время было царством самых опытных разработчиков с тягой к мазохизму – тех, кто имел достаточно свободного времени, склонность и психические способности размышлять об обратных вызовах (callback) из обратных вызовов в нелинейном потоке выполнения. С появлением Microsoft .NET Framework 4.5, C# и Visual Basic принесли асинхронность всем нам, так что простые смертные теперь могут писать асинхронные методы почти так же легко, как синхронные. Обратные вызовы больше не нужны. Больше не нужна явная передача (marshaling) кода из одного контекста синхронизации в другой. Больше не нужно беспокоиться как двигаются результаты выполнения или исключения. Нет необходимости в трюках, которые искажают средства языков программирования для удобства разработки асинхронного кода. Короче говоря, больше нет мороки и головной боли.


Конечно, несмотря на то, что теперь легко начать писать асинхронные методы (смотри статьи Эрика Липперта [Eric Lippert] и Мадса Торгерсена [Mads Torgersen] в этом выпуске MSDN Magazine [OCTOBER 2011]), чтобы делать это по-настоящему правильно, требуется понимание что происходит под капотом. Каждый раз, когда язык или библиотека поднимает уровень абстракции, который может использовать разработчик, это неизбежно сопровождается скрытыми затратами, снижающими производительность. Во многих случаях эти затраты ничтожны, так что ими можно пренебречь в большинстве случаев у большинства программистов. Однако, продвинутым разработчикам следует в полной мере понимать какие издержки присутствуют, чтобы предпринять необходимые меры и решить возможные проблемы, если они проявят себя. Это требуется при использовании средств асинхронного программирования в C# и Visual Basic.


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


Получить удобную модель мышления


Программисты уже в течение десятилетий используют высокоуровневые языки программирования C#, Visual Basic, F# и C++ для разработки производительных приложений. Этот опыт позволил программистам оценить затраты различных операций и получить знания о наилучших приемах разработки. Например, в большинстве случаев вызов синхронного метода сравнительно экономичен, особенно, если компилятор сможет встроить содержание вызванного метода прямо в точку вызова. Поэтому разработчики привыкли разбивать код на небольшие, удобные в сопровождении методы, без необходимости беспокоиться о негативных последствиях увеличения количества вызовов. Модель мышления этих программистов предназначена для оперирования вызовами методов.


С приходом асинхронных методов требуется новая модель мышления. C# и Visual Basic со своими компиляторами способны создать иллюзию, что асинхронный метод работает как его синхронный аналог, хотя внутри всё происходит совершенно не так. Компилятор генерирует за программиста огромное количество кода, очень похожего на стандартный шаблон, который разработчики писали для поддержки асинхронности во время óно, когда это необходимо было делать руками. Более того, код, который сгенерировал компилятор, содержит вызовы библиотечных функций .NET Framework, ещё уменьшая объем работы, который нужно выполнить программисту. Чтобы иметь правильную модель мышления и использовать её для принятия осознанных решений, важно понимать что компилятор генерирует за вас.


Больше размер методов, меньше вызовов


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


public static async Task SimpleBodyAsync() 
{
  Console.WriteLine("Hello, Async World!");
}

Декомпилятор промежуточного языка (IL) раскроет истинное содержание этой функции после компиляции, выводя что-то похожее на Figure 1. То, что было простым однострочником, превратилось в два метода, один из которых принадлежит вспомогательному классу машины состояний. Первый является методом-заглушкой, который имеет сигнатуру, аналогичную написанной программистом (у этого метода такое же имя, такая же область видимости, он принимает такие же параметры и возвращает такой же тип), но не содержит кода, написанного программистом. Он содержит только стандартный шаблон (boilerplate) для начальной настройки. Код начальной настройки инициализирует машину состояний, необходимую для представления асинхронного метода, и запускает её, используя вызов служебного метода MoveNext. Тип объекта машины состояний содержит переменную с состоянием выполнения асинхронного метода, позволяя при необходимости сохранять его при переходе между асинхронными точками ожидания. Также он содержит код, который написал программист, видоизменённый для обеспечения передачи результатов выполнения и исключений в возвращаемый объект Task; удержания текущей позиции в методе для того, чтобы выполнение могло продолжиться с этой позиции после возобновления, и т.д.


Figure 1 Шаблон асинхронного метода


[DebuggerStepThrough]     
public static Task SimpleBodyAsync() 
{
  <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0();
  d__.<>t__builder = AsyncTaskMethodBuilder.Create();
  d__.MoveNext();
  return d__.<>t__builder.Task;
}

[CompilerGenerated]
[StructLayout(LayoutKind.Sequential)]
private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine
{
  private int <>1__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public Action <>t__MoveNextDelegate;

  public void MoveNext() 
  {
    try 
    {
      if (this.<>1__state == -1) return;
      Console.WriteLine("Hello, Async World!");
    }
    catch (Exception e) 
    {
      this.<>1__state = -1;
      this.<>t__builder.SetException(e);
      return;
    }

    this.<>1__state = -1;
    this.<>t__builder.SetResult();
  }
  ...
}

Когда прикидываете сколько стоят вызовы асинхронных методов, вспоминайте этот шаблон. Блок try/catch в методе MoveNext нужен для предотвращения возможной попытки встраивания этого метода JIT компилятором, так что по меньшей мере мы получим затраты на вызов метода, в то время как при использовании синхронного метода, скорее всего, этого вызова не будет (при условии такого минималистичного содержания). Мы получим несколько вызовов процедур Framework (например, SetResult). А также несколько операций записи в поля объекта машины состояний. Конечно, нам необходимо сравнить все эти затраты с затратами на Console.WriteLine, которые вероятно будут преобладать (они включают затраты на блокировки, на ввод/вывод и т.п.) Обратите внимание на оптимизации, которые среда делает для вас. Например, объект машины состояний реализован как структура (struct). Эта структура будет упакована (boxed) в управляемой куче (heap), только если методу понадобится приостановить выполнение, ожидая окончания операции, а в этом простом методе этого не будет никогда. Так что шаблон этого асинхронного метода не будет требовать выделения памяти из кучи. Компилятор и среда выполнения постараются минимизировать количество операций выделения памяти.


Когда не нужно использовать Async


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


При создании асинхронных методов разработчики .NET Framework много времени потратили на оптимизацию количества операций по управлению памятью. Это необходимо по той причине, что при управлении памятью возникают наибольшие затраты в производительности асинхронной инфраструктуры. Операция выделения памяти для объекта обычно сравнительно не дорога. Выделение памяти для объектов похоже на наполнение продуктами тележки в супермаркете – вы не тратите ничего, когда кладёте их в тележку. Траты возникают, когда расплачиваетесь на кассе, вынимая кошелёк и отдавая приличные деньги. И если выделение памяти происходит легко, последующая сборка мусора может сильно ударить по производительности приложения. При запуске сборки мусора происходит сканирование и маркировка тех объектов, которые в данный момент размещены в памяти, но не имеют ссылок. Чем больше объектов размещено, тем больше занимает времени их маркировка. Кроме того, чем больше количество размещённых объектов большого размера, тем чаще требуется проводить сборку мусора. Этот аспект работы с памятью производит глобальное воздействие на систему: чем больше мусора производится асинхронными методами, тем медленнее работает приложение, даже если микротесты их производительности значительных затрат не демонстрируют.


Для асинхронных методов, которые приостанавливают своё выполнение (ожидая данных, которые ещё не готовы), среда должна создать объект типа Task, который будет возвращён из метода, так как этот объект служит уникальной ссылкой на вызов. Однако, часто вызовы асинхронных методов могут быть выполнены без приостановки. Тогда среда выполнения может вернуть из кеша завершённый ранее объект Task, который используется снова и снова без необходимости создания новых объектов Task. Правда, это разрешено только в определённых условиях, например, когда асинхронный метод возвращает не универсальный (non-generic) объект Task, Task, или когда универсальный Task специфицирован TResult ссылочного типа, а из метода возвращается null. Хотя список этих условий с течением времени расширяется, всё-таки лучше, если вы знаете как реализована выполняющаяся операция.

Рассмотрим реализацию такого типа как MemoryStream. MemoryStream унаследован от Stream, и переопределяет новые, реализованные в .NET 4.5 методы: ReadAsync, WriteAsync и FlushAsync, для того чтобы обеспечить специфичную для MemoryStream оптимизацию кода. Так как операция чтения выполняется из буфера, размещённого в памяти, то есть, фактически является копированием области памяти, наилучшая производительность будет, если ReadAsync будет выполняться в синхронном режиме. Реализация этого в асинхронном методе может выглядеть так:


public override async Task<int> ReadAsync(byte [] buffer, int offset, int count, CancellationToken cancellationToken)
{
  cancellationToken.ThrowIfCancellationRequested();
  return this.Read(buffer, offset, count);
}

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


byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
  await source.WriteAsync(buffer, 0, numRead);
}

Обратите внимание, что в приведённом примере ReadAsync исходного потока всегда вызывается с одним и тем же параметром длины буфера, а значит очень вероятно, что и возвращаемое значение (количество прочитанных байтов) также будет повторяться. За исключением некоторых редких обстоятельств реализация ReadAsync вряд ли будет использовать кешированный объект Task как возвращаемое значение, но это можете сделать вы.


Рассмотрим другой вариант реализации этого метода, представленный на Figure 2. Используя для этого метода преимущества присущих ему аспектов в стандартных сценариях, мы можем оптимизировать реализацию исключением операций выделения памяти, чего навряд ли можно ожидать от среды выполнения. Мы можем полностью устранить потери от выделения памяти, возвращая повторно тот же самый объект Task, который был использован в предыдущем вызове ReadAsync, если считано такое же количество байтов. И для такой низкоуровневой операции, которая, скорее всего, будет очень быстрой и будет вызываться многократно, эта оптимизация даст значительный эффект, особенно в количестве сборок мусора.


Figure 2 Оптимизация создания Task


private Task<int> m_lastTask;

public override Task<int> ReadAsync(byte [] buffer, int offset, int count, CancellationToken cancellationToken)
{
  if (cancellationToken.IsCancellationRequested) 
  {
    var tcs = new TaskCompletionSource<int>();
    tcs.SetCanceled();
    return tcs.Task;
  }

  try 
  {
      int numRead = this.Read(buffer, offset, count);
      return m_lastTask != null && numRead == m_lastTask.Result ?
        m_lastTask : (m_lastTask = Task.FromResult(numRead));
  }
  catch(Exception e) 
  {
    var tcs = new TaskCompletionSource<int>();
    tcs.SetException(e);
    return tcs.Task;
  }
}

Похожий способ оптимизации путём устранения ненужного создания объектов Task может быть использован в случае необходимости кеширования. Рассмотрим метод, который предназначен для получения содержания веб-страницы и его кеширования для последующих обращений. В виде асинхронного метода это может быть написано следующим образом (с использованием новой для .NET 4.5 библиотеки System.Net.Http.dll):


private static ConcurrentDictionary<string,string> s_urlToContents;

public static async Task<string> GetContentsAsync(string url)
{
  string contents;

  if (!s_urlToContents.TryGetValue(url, out contents))
  {
    var response = await new HttpClient().GetAsync(url);
    contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
    s_urlToContents.TryAdd(url, contents);
  }
  return contents;
}

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

Чтобы исключить эти расходы (если потребуется для достижения высокой производительности), вы можете переписать метод как показано на Figure 3. Теперь у нас есть два метода: синхронный открытый метод и асинхронный закрытый, которому делегирует открытый. Коллекция (Dictionary) теперь кеширует создаваемые объекты Task, а не их содержимое, поэтому будущие попытки получить содержание страницы, которая уже была ранее успешно получена, могут быть выполнены простым обращением к коллекции для возвращения существующего объекта Task. Внутри можно получить преимущества использования методов ContinueWith объекта Task, что позволяет нам сохранять выполнившийся объект в коллекции – в том случае, если загрузка страницы была успешна. Конечно, этот код более сложен и требует больших усилий при разработке и поддержке, как обычно при оптимизации производительности: не хочется тратить время на его написание до тех пор, пока тестирование производительности не покажет, что эти усложнения приводят к её улучшению, впечатляющему и очевидному. Какие будут улучшения на самом деле зависит от способа применения. Вы можете взять пакет тестов, который имитирует обычные варианты использования, и оценить результаты, чтобы определить стоит ли игра свеч.


Figure 3 Кеширование задач вручную


private static ConcurrentDictionary<string,Task<string>> s_urlToContents;

public static Task<string> GetContentsAsync(string url) 
{
  Task<string> contents;
  if (!s_urlToContents.TryGetValue(url, out contents)) 
  {
      contents = GetContentsInternalAsync(url);
      contents.ContinueWith(delegate 
      {
        s_urlToContents.TryAdd(url, contents);
      }, CancellationToken.None,
        TaskContinuationOptions.OnlyOnRanToCompletion |
         TaskContinuatOptions.ExecuteSynchronously,
        TaskScheduler.Default);
  }
  return contents;
}

private static async Task<string> GetContentsInternalAsync(string url) 
{
  var response = await new HttpClient().GetAsync(url);
  return response.EnsureSuccessStatusCode().Content.ReadAsString();
}

Другой способ оптимизации, связанный с объектами Task – определить нужно ли вообще возвращать такой объект из асинхронного метода. И C#, и Visual Basic поддерживают асинхронные методы, которые возвращают пустое значение (void), и в них объекты Task вообще не создаются. Асинхронные методы в библиотеках должны всегда возвращать Task и Task, так как при разработке библиотеки вы не можете знать, что они не будут использоваться с ожиданием завершения. Тем не менее, при разработке приложений методы, возвращающие void, могут найти своё место. Основной причиной существования подобных методов является обеспечение существующих сред с управлением по событиям (event-driven), например, ASP.NET и Windows Presentation Foundation (WPF). С использованием async и await эти методы помогают легко реализовать обработчики кнопок, событий загрузки страниц и т.п. Если вы собираетесь использовать асинхронный метод с void, будьте осторожны с обработкой исключений: исключения из него будут всплывать в любой SynchronizationContext, который был активным в момент вызова метода.

Не забывайте о контексте


В .NET Framework есть множество различных контекстов: LogicalCallContext, SynchronizationContext, HostExecutionContext, SecurityContext, ExecutionContext и другие (их гигантское количество может заставить предположить, что создатели Framework были финансово мотивированы на создание новых контекстов, но я точно знаю, что это не так). Некоторые из этих контекстов сильно влияют на асинхронные методы, не только по функциональности, но также по производительности.


SynchronizationContext SynchronizationContext играет значительную роль для асинхронных методов. “Контекст синхронизации” – это только абстракция для обеспечения маршалинга вызова делегата со спецификой определённой библиотеки или среды. Например, WPF имеет DispatcherSynchronizationContext для представления потока пользовательского интерфейса (UI) для Dispatcher: отправка делегата в этот контекст синхронизации приводит к тому, что этот делегат устанавливается в очередь на выполнение Dispatcherом в его потоке. ASP.NET обеспечивает AspNetSynchronizationContext, который используется для того, чтобы асинхронные операции, участвующие в обработке ASP.NET запроса, гарантировано выполнялись последовательно и были привязаны к правильному состоянию HttpContext. Ну и т.п. В общем, всего в .NET Framework существует порядка 10 специализаций SynchronizationContext, часть открытых, часть внутренних.


Когда выполняется ожидание объектов Tasks или объектов других типов, для которых .NET Framework может это реализовать, ожидающие их объекты (например, TaskAwaiter) захватывают текущий SynchronizationContext в момент, когда ожидание (await) начинается. После завершения ожидания, если SynchronizationContext был захвачен, продолжение асинхронного метода отправляется в этот контекст синхронизации. Благодаря этому, программистам, пишущим асинхронные методы, которые вызываются из потока UI, нет необходимости вручную маршализировать вызовы обратно в поток UI для того, чтобы обновить элементы управления UI: такой маршалинг Framework выполняет автоматически.


К сожалению, этот маршалинг имеет свою цену. Для разработчиков приложений, использующих await для реализации своего потока управления, автоматический маршалинг – правильное решение. В библиотеках часто совсем другая история. Разработчикам приложений этот маршалинг в основном необходим, для того, чтобы код управлял контекстом, в котором выполняется, например, для доступа к элементам управления UI или для доступа к HttpContext, соответствующему нужному ASP.NET запросу. Однако, библиотеки в основном не обязаны удовлетворять такому требованию. В результате автоматический маршалинг часто приносит совершенно ненужные дополнительные расходы. Давайте ещё раз посмотрим на код, который копирует данные из одного потока в другой:


byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
  await source.WriteAsync(buffer, 0, numRead);
}

Если это копирование будет вызвано из потока UI, каждая операция чтения и записи будет заставлять возвращать выполнение назад в поток UI. В случае мегабайта данных в источнике и потоков, выполняющих чтение и запись асинхронно (то есть, большинства их реализаций), это означает порядка 500 переключений из фонового потока в поток UI. Для обработки этого поведения в типах Task и Task создан метод ConfigureAwait. Этот метод принимает параметр continueOnCapturedContext булевого типа, который управляет маршалингом. Если значение true (по умолчанию), await автоматически возвращает управление в захваченный SynchronizationContext. Если используется false, контекст синхронизации будет проигнорирован, и среда продолжит выполнение асинхронной операции в том потоке, где она была прервана. Реализация этой логики даст более эффективную версию кода копирования между потоками:

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
{
  await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false);
}

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


Помимо производительности есть ещё одна причина, из-за которой нужно использовать ConfigureAwait при разработке библиотек. Представьте, что метод CopyStreamToStreamAsync, реализованный с версией кода без ConfigureAwait, вызывается из потока UI в WPF, например, так:


private void button1_Click(object sender, EventArgs args)
{
  Stream src = …, dst = …;
  Task t = CopyStreamToStreamAsync(src, dst);
  t.Wait(); // deadlock!
}

В данном случае программист должен был написать button1_Click как асинхронный метод, в котором ожидается выполнение Task оператором await, а не использовать синхронный метод Wait этого объекта. Метод Wait нужно применять во многих других случаях, но практически всегда будет ошибкой использовать его для ожидания в потоке UI как показано здесь. Метод Wait не вернётся, пока Task не будет выполнен. В случае CopyStreamToStreamAsync его асинхронный поток пытается вернуть выполнение с отправкой данных в захваченный SynchronizationContext, и не может завершиться, пока не выполнятся такие отправки (потому что они необходимы для продолжения его работы). Но эти отправки в свою очередь не могут быть выполнены, потому что поток UI, который должен их обработать, заблокирован вызовом Wait. Это циклическая зависимость, приводящая к мёртвой блокировке (deadlock). Если CopyStreamToStreamAsync реализовать с ConfigureAwait(false), зависимости и блокировки не будет.


ExecutionContext ExecutionContext является важной частью .NET Framework, но всё-таки большинство программистов находятся в блаженном неведении о его существовании. ExecutionContext – дедушка контекстов, содержит в себе множество других контекстов типа SecurityContext и LogicalCallContext, и представляет всё, что должно автоматически передаваться между асинхронными точками в коде. Каждый раз, когда вы используете ThreadPool.QueueUserWorkItem, Task.Run, Delegate.BeginInvoke, Stream.BeginRead, WebClient.DownloadStringAsync или любую другую асинхронную операцию Framework, внутри происходит захват ExecutionContext при помощи ExecutionContext.Run (если возможно). Например, если код, вызывающий ThreadPool.QueueUserWorkItem, имперсонирует Windows удостоверение (identity), это же удостоверение должно быть имперсонировано при вызове назначенного делегата WaitCallback. А если код, который вызывает Task.Run сохранил данные в LogicalCallContext, эти данные должны быть доступны через этот же LogicalCallContext в назначенном делегате Action. ExecutionContext также передаётся между ожиданиями задач.


В Framework присутствует множество оптимизирующего кода, который позволяет не захватывать и не работать под захваченным ExecutionContext, когда в этом нет необходимости, а исполнение в таком режиме затратно. Но операции типа имперсонирования Windows удостоверения или сохранения данных в LogicalCallContext не дают применить эти оптимизации. Поэтому исключение таких операций (WindowsIdentity.Impersonate или CallContext.LogicalSetData) приводит к увеличению производительности при применении асинхронных методов и других способов исполнения в режиме асинхронности.


Освободитесь от сборки мусора


Асинхронные методы дают красивую иллюзию для локальных переменных. В синхронных методах C# и Visual Basic локальные переменные размещаются на стеке, и никакие выделения памяти из кучи не нужны. В асинхронных стек теряется во время приостановки в точке await. Для того, чтобы данные оставались доступными после возобновления, они должны сохраняться где-то в другом месте. Поэтому компиляторы C# и Visual Basic переводят («поднимают») локальные переменные в структуру машины состояний, которая впоследствии при первом await запаковывается (boxed) в кучу, чтобы переменные смогли пережить остановку.


Ранее в этой статье я упоминал как на затраты сборки мусора и её частоту влияют размеры размещённых объектов. Чем больше объектов, тем чаще выполняется сборка мусора. Значит, чем больше в асинхронных методах локальных переменных, которые будут подняты в кучу, тем чаще будет сборка мусора.


На момент написания этой статьи компиляторы C# и Visual Basic иногда поднимают в кучу больше данных, чем это необходимо на самом деле. Например, посмотрите на этот код


public static async Task FooAsync()
{
  var dto = DateTimeOffset.Now;
  var dt  = dto.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

Значение из переменной dto больше не считывается после точки await, поэтому сохранять его нет необходимости. Но тип машины состояний, сгенерированный компилятором, всё-таки сохраняет ссылку на dto:


Figure 4 Подъём локальных переменных


[StructLayout(LayoutKind.Sequential), CompilerGenerated]
private struct <FooAsync>d__0 : <>t__IStateMachine
{
  private int <>1__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public Action <>t__MoveNextDelegate;

  public DateTimeOffset <dto>5__1;
  public DateTime <dt>5__2;
  private object <>t__stack;
  private object <>t__awaiter;

  public void MoveNext();

  [DebuggerHidden]
  public void <>t__SetMoveNextDelegate(Action param0);
}

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


public static async Task FooAsync()
{
  var dt = DateTimeOffset.Now.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

Кроме того, сборщик мусора .NET (GC) является сборщиком с поколениями, это означает, что он разбивает объекты на группы, называемые поколениями: новые объекты относятся к поколению 0, а объекты, которые пережили сборки мусора, переходят в следующее поколение (.NET GC сейчас поддерживает поколения 0, 1 и 2). Это обеспечивает более быстрый процесс сборки, так как позволяет GC чаще собирать объекты только из части пространства объектов. Эта идея основана на теории, что объекты, которые созданы недавно, быстрее становятся не нужны, в то время как объекты, существующие в течение продолжительного времени, будут использоваться дольше. Если объект пережил поколение 0, он будет существовать, создавая нагрузку на систему, ещё некоторое время. А это означает, что мы должны быть уверены, что объекты становятся доступными для сборки мусора сразу же, как только перестают использоваться.


После подъёма в поля класса локальные переменные остаются в течение всего времени выполнения асинхронного метода (пока ожидаемый объект соответствующим образом поддерживает ссылку на делегат, которого нужно вызвать по окончании ожидаемой операции). В синхронных методах JIT компилятор может проследить, что локальные переменные больше не будут использованы впоследствии, и в некоторых случаях может помочь сборщику мусора не рассматривать эти переменные как корни стека, делая объекты по этим ссылкам доступными для сборки, если на них больше нет ссылок. В асинхронных методах ссылки на эти локальные переменные продолжают действовать, и объекты существуют дольше, чем в случае их представления настоящими локальными переменными. Если вы заметите, что объекты остаются живыми намного дольше, чем используются, попробуйте обнулять их локальные переменные, когда заканчиваете работу с ними. Ещё раз повторю, это стоит делать только в случае реальной проблемы с производительностью, иначе код усложняется без необходимости. Кроме того, компиляторы C# и Visual Basic могут быть усовершенствованы в финальном релизе или позже, чтобы обеспечить поддержку разработчиков в таких сценариях, что сделает такой код устаревшим.


Избегайте сложности


Компиляторы C# и Visual Basic весьма впечатляют, если рассмотреть где они разрешают применять awaits: практически везде. Выражения с await могут использоваться как часть больших выражений, позволяя вам ожидать экземпляры Task в коде, где вы можете применить любое другое выражение, возвращающее значение. Например, посмотрите на код, который возвращает сумму результатов трёх задач:

public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c)
{
  return Sum(await a, await b, await c);
}

private static int Sum(int a, int b, int c)
{
  return a + b + c;
}

Компилятор C# разрешает вам использовать выражение “await b” как аргумент функции Sum. Но так как здесь несколько await, чьи результаты передаются как параметры в Sum, то из-за порядка правил вычисления и способа реализации async компилятором, компилятор вынужден «сбросить» временные результаты первых двух await. Как вы уже видели, локальные переменные защищаются при переходе через await путём подъёма в поля класса машины состояний. Но в случаях, подобных этому, когда значения располагаются на стеке выполнения CLR, эти значения не поднимаются, а сбрасываются в один временный объект, на который в машине состояний устанавливается ссылка. Когда завершается ожидание первой задачи и начинается ожидание второй, компилятор генерирует код для упаковки первого результата и сохранения упакованного объекта в одном поле машины состояний <>t__stack. Когда завершается ожидание второй задачи и начинается ожидание третьей, компилятор генерирует код, который создаёт Tuple<int, int> из значений первых двух результатов и сохраняет его в том же поле <>__stack. Это означает, что в зависимости от того, как напишете свой код, вы можете получить совершенно различные структуры размещения объектов в памяти. Например, вы можете написать SumAsync так:


public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c)
{
  int ra = await a;
  int rb = await b;
  int rc = await c;
  return Sum(ra, rb, rc);
}

В этом случае компилятор создаст в классе машины состояний три дополнительных поля, чтобы сохранить ra, rb и rc, и сбрасывания не произойдёт. То есть, у вас есть компромисс: класс машины состояний большего размера с меньшим количеством операций по выделению памяти или уменьшенный класс с большим количеством операций. Общий объём выделенной памяти будет больше в варианте со сбрасыванием, так как каждый объект при размещении требует своих дополнительных затрат памяти, но в конечном итоге тестирование производительности может показать, что такой вариант лучше. В общем, вы не должны думать о таких способах микрооптимизации до тех пор, пока не будете уверены, что операции выделения памяти являются источниками несчастий, но в любом случае полезно знать почему возникают эти операции.


Конечно, в приведённых примерах вероятно есть намного большие расходы, которых вы должны опасаться и предвидеть их появление. Этот код не может вызвать Sum до тех пор, пока все три await не будут завершены, и никакой работы не выполняется между ними. Каждый из них требует обработки, поэтому чем меньше await вам нужно обработать, тем лучше. Будет полезно собрать все три await в один, ожидая выполнения всех задач в Task.WhenAll:


public static async Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c)
{
  int [] results = await Task.WhenAll(a, b, c);
  return Sum(results[0], results[1], results[2]);
}

Метод Task.WhenAll возвратит Task<TResult[]>, которая не будет завершена, пока все переданные задачи не будут завершены, а это будет намного эффективнее, чем ждать выполнения каждой задачи по очереди. Он собирает результаты от каждой задачи и сохраняет их в массиве. Если не хотите получать массив, вы можете явно привязать к не универсальному методу WhenAll, который работает с Task вместо Task. Для наивысшей производительности вы можете применить гибридный подход, где вы сначала проверяете все ли задачи уже завершились успешно, и если это так, получаете их результаты, а если нет, то ожидаете методом WhenAll тех, которые не успели завершиться. Это позволяет избежать операций выделения памяти при вызове WhenAll, когда в нём нет необходимости, например, размещения массива params, который должен быть передан в этот метод. И как отмечено ранее, мы хотим, чтобы эта библиотечная функция не выполняла маршалинг в контекст. Решение на Figure 5

Figure 5 Применение нескольких способов оптимизации


public static Task<int> SumAsync(Task<int> a, Task<int> b, Task<int> c)
{
  return (a.Status == TaskStatus.RanToCompletion &&
          b.Status == TaskStatus.RanToCompletion &&
          c.Status == TaskStatus.RanToCompletion) ?
    Task.FromResult(Sum(a.Result, b.Result, c.Result)) :
    SumAsyncInternal(a, b, c);
}

private static async Task<int> SumAsyncInternal(Task<int> a, Task<int> b, Task<int> c)
{
  await Task.WhenAll((Task)a, b, c).ConfigureAwait(false);
  return Sum(a.Result, b.Result, c.Result);
}

Асинхронность и производительность


Асинхронные методы являются мощным средством разработки, позволяющим легче писать масштабируемые библиотеки и приложения с малым временем отклика. Важно помнить, что асинхронность не даёт оптимизации производительности для отдельной операции. Превращение синхронной операции в асинхронную всегда приведёт к ухудшению производительности этой операции, поскольку помимо исполнения всей логики синхронной операции добавляются дополнительные ограничения и аспекты. Причиной, по которой вы можете использовать асинхронность, является производительность в комплексе приложения: как ваша система работает в целом, когда всё выполняется в асинхронном режиме, так вы можете совмещать операции ввода/вывода и достичь более эффективной загрузки системы при использовании важных ресурсов, только когда они реально нужны для выполнения. Реализация асинхронных методов в .NET Framework хорошо оптимизирована и часто в конечном итоге обеспечивает производительность не хуже правильно написанного кода, использующего устоявшиеся шаблоны и гораздо более объёмного. В любой момент, когда вам понадобится применить асинхронность в .NET Framework, асинхронные методы будут хорошим выбором. Но вам, как разработчику, необходимо знать что для вас делает Framework, чтобы быть уверенным, что окончательный результат хорош настолько, насколько возможно.

  • +24
  • 15,9k
  • 5
Поделиться публикацией

Комментарии 5

    +1
    async/await, безусловно, имеет свою цену, но применяется он в 90% случаев для операций ввода-вывода (например, запрос к БД) длительность которых, как правило, на порядки превышает все возможные задержки вызванные созданием/удалением дополнительных объектов необходимых для async/await, но при этом выигрыш в производительности от того, что мы можем обойтись меньшим количеством потоков для обработки большого количества входящих запросов и легкость с которой можно этого достичь говорит, что: “всегда используйте async/await (для ввода/вывода), и не заморачивайтесь”. Если, таки, хочется “пооптимизировать” рассмотрите новый ValueTask (которого еще не было во времена написания оригинала этой статьи)
      +1
      но применяется он в 90% случаев для операций ввода-вывода

      Вот, мой любимый баг в .Net Core github.com/dotnet/corefx/issues/31557
      ReadAsync из файла в 8 раз медленнее Read
        0
        Я думаю проблема тестируемого кода в том, что чтение производится очень маленькими фрагментами. Значительные задержки при операциях файлового ввода/вывода наблюдаются только в момент заполнения/сброса буфера, который очевидно имеет больший объем, чем массив используемый в операциях чтения из стрима. Получается, что подавляющее количество операций завершаются синхронно, поскольку происходит копирование из памяти в память, а не передача данных между памятью и устройством ввода вывода. Для этих синхронно выполненных операций мы и получаем основной оверхед из-за использования task'ов.
        PS Собственно об этом же говорится в комментариях на SO, с чем автор вопроса в конце согласился
        0
        Спасибо за хороший комментарий — сделал новый перевод
        0
        Еще интересно, какова стоимость использования async/await на других платформах, и во что там генерируется подобный код.

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

        Самое читаемое