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

OSzone.net » Microsoft » Разработка приложений » Другое » Асинхронное программирование: Влияние async и await на производительность
Автор: Стефен Тауб
Иcточник: MSDN Magazine
Опубликована: 13.03.2012

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

Конечно, хотя теперь легко приступить к написанию асинхронных методов (см. статьи Эрика Липперта и Мэдса Торгерсена в этом номере), делать это по-настоящему хорошо все равно можно только при понимании того, что происходит «за кулисами». Всякий раз, когда в языке или инфраструктуре создается более высокий уровень абстракции, на котором может программировать средний разработчик, этот уровень неизбежно влечет за собой скрытые издержки, влияющие на производительность. Во многих случаях такие издержки пренебрежимо малы, и огромное число разработчиков в большинстве ситуаций может и должно их игнорировать. Однако более продвинутым разработчикам по-прежнему полезно хорошо понимать, каковы эти издержки, чтобы предпринимать необходимые меры и избегать этих издержек, если они становятся заметными. Так обстоит дело и с асинхронными методами в C# и Visual Basic.

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

Выработка правильной умозрительной модели

Десятилетиями разработчики использовали высокоуровневые языки вроде C#, Visual Basic, F# и C++ для создания эффективных приложений. Из этого опыта у разработчиков сложилось четкое представление об издержках различных операций, и это знание было оформлено в соответствующие рекомендации по разработке. Например, в большинстве случаев использования вызов синхронного метода обходится сравнительно недорого и еще дешевле, если компилятор может встроить вызываемого (callee) по месту вызова (call site). Таким образом, разработчики научились разбивать код на небольшие, простые в сопровождении методы, которые, в целом, не требуют беспокоиться о каких либо отрицательных последствиях при увеличении частоты их вызова. У таких разработчиков сложилась умозрительная модель того, что подразумевается под вызовом метода.

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

«Не болтайте лишнего»

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

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

Декомпилятор промежуточного языка (IL) раскроет истинную природу этой функции после компиляции, сгенерировав вывод, аналогичный тому, что показано на рис. 1. Однострочное выражение превратилось в два метода, один из которых существует во вспомогательном классе конечного автомата. У интерфейсного метода (stub method) та же базовая сигнатура, что и у написанного разработчиком метода (этот метод называется так же, имеет ту же область видимости, принимает те же параметры и сохраняет тот же возвращаемый тип), но он не содержит никакого кода, написанного разработчиком. Вместо этого он содержит стереотипный подготовительный код (setup boilerplate). Этот код инициализирует конечный автомат, используемый для представления асинхронного метода, и запускает его вызовом дополнительного метода MoveNext в конечном автомате. Этот тип конечного автомата хранит состояние асинхронного метода, позволяя при необходимости сохранять его между await-точками. Кроме того, он содержит тело метода, написанное разработчиком, но перестроенное так, чтобы результаты и исключения можно было передавать в возвращаемый Task, запоминать текущую позицию в методе для возобновления выполнения в этом месте после await и т. д.

Рис. 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-компилятору подставить его в место вызова, поэтому мы теперь имеем, как минимум, издержки вызова метода там, где в случае синхронности их не было бы (при таком малом теле метода). Мы также получаем множество вызовов процедур из инфраструктуры .NET Framework (например, SetResult). И еще массу операций записи в поля типа конечного автомата. Конечно, все это нужно взвесить против издержек Console.WriteLine, которые, по-видимому, будут доминировать над всеми остальными издержками (он захватывает блокировки, выполняет ввод-вывод и др.). Более того, обратите внимание на оптимизации, выполняемые за вас инфраструктурой. Например, тип конечного автомата является структурой (struct). Эта struct будет упаковываться и передаваться (boxed) в кучу, только если этому методу когда-либо потребуется приостанавливать свое исполнение из-за ожидания выполнения экземпляра, а в этом простом методе такого никогда не случится. Сам по себе стереотипный код данного асинхронного метода не вызовет никаких операций создания объектов. Ну а компилятор и исполняющая среда в тесном взаимодействии минимизируют количество таких операций в инфраструктуре.

.NET Framework пытается генерировать эффективные асинхронные реализации асинхронных методов.

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

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

Проектируя асинхронные методы, разработчики .NET Framework потратили много времени на минимизацию операций создания объектов. Дело в том, что такие операции влекут за собой самые большие издержки в инфраструктуре асинхронных методов. Сам по себе акт создания объекта обычно обходится недорого. Создание объектов аналогично наполнению товарами корзины покупателя в том смысле, что вы не тратите особых усилий, помещая их в корзину; но когда дело доходит до оплаты, вы должны достать кошелек и выложить за товары приличную сумму. Хотя операции создания обычно вызывают небольшие издержки, инициируемый ими сбор мусора может оказаться стопором для производительности приложения. Сбор мусора включает сканирование некоторой части объектов, созданных на данный момент, и поиск тех из них, на которые больше нет ссылок. Чем больше объектов создано, тем дольше будет проходить эта процедура. Более того, чем крупнее созданные объекты и чем больше их количество, тем чаще сбор мусора. Таким образом, операции создания объектов оказывают глобальное воздействие на систему: чем больше мусора генерируется асинхронными методами, тем медленнее выполняется программа в целом, даже если микротесты самих асинхронных методов не показывают значимых издержек.

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

Рассмотрим реализацию типа вроде MemoryStream. MemoryStream наследует от Stream, а значит, может переопределять новые методы ReadAsync, WriteAsync и FlushAsync класса Streams в .NET 4.5, чтобы предоставить реализации, оптимизированные именно для 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 будут на самом деле выполняться синхронно. Теперь посмотрим на стандартный шаблон использования потоков данных (streams), например на операцию копирования:

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

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

Разработчики .NET Framework потратили много времени на минимизацию операций создания объектов.

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

Рис. 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;
  }
}

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

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<string>, представляющего эту операцию скачивания, будут пренебрежимо малы в сравнении с издержками, относящимися к сети. Однако в случаях, где вызовы могут быть обслужены из кеша, эти издержки будут не столь малыми — объект создается только для того, чтобы обернуть доступные данные и вернуть их вызвавшему.

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

Рис. 3. Самостоятельное кеширование объектов Task

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 = GetContentsAsync(url);
      contents.ContinueWith(delegate {
        s_urlToContents.TryAdd(url, contents);
      }, CancellationToken.None,
        TaskContinuationOptions.OnlyOnRanToCompletion |
          TaskContinuatOptions.ExecuteSynchronously,
        TaskScheduler.Default);
  }
  return contents;
}

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

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

Вам следует провести набор тестов, отражающих типичные шаблоны применения.

Учитывайте контекст

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

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

При ожидании на объектах Task и других ожидаемых (awaitable) типах, предоставляемых .NET Framework, ждущие (awaiters) возврата этих типов (например, TaskAwaiter) захватывают текущий SynchronizationContext на момент инициации ожидания через await. Если текущий SynchronizationContext был захвачен, по завершении выполнения ожидаемого объекта в этот контекст передается продолжение (continuation), которое представляет оставшуюся часть асинхронного метода. Благодаря этому разработчику, пишущему асинхронный метод, который вызывается из UI-потока, не нужно вручную выполнять маршалинг вызовов обратно в UI-поток, чтобы модифицировать содержимое UI-элементов: маршалинг осуществляется автоматически самой инфраструктурой .NET 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-поток. В случае мегабайта исходных данных и объектов Stream, выполняющих операции чтения и записи асинхронно (что делается чаще всего), это означает свыше 500 переходов из фоновых потоков в UI-поток. Для решения этой проблемы типы Task и Task<TResult> предоставляют метод ConfigureAwait. Он принимает булев параметр continueOnCapturedContext, управляющий поведением маршалинга. Если он равен true (по умолчанию), await-выражение будет автоматически завершаться в захваченном SynchronizationContext. А если указывается false, то SynchronizationContext игнорируется и инфраструктура пытается продолжить выполнение с того места, где оно было прервано предыдущей асинхронной операцией. Включив этот код копирования потоков данных, вы получите более эффективную версию:

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 в библиотечном коде. Допустим, предыдущий код без ConfigureAwait находился в методе CopyStreamToStreamAsync, который вызвали из WPF UI-потока следующим образом:

private void button1_Click(object sender, EventArgs args) {
  Stream src = ..., dst = ...;
  Task t = CopyStreamToStreamAsync(src, dst);
  t.Wait(); // взаимоблокировка!
}

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

У метода Wait есть важные области применения, но он почти никогда не годится для ожидания в UI-потоке.

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

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

Минимизируем частоту сбора мусора

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

Чем крупнее создаваемые объекты, тем чаще требуется сбор мусора.

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

На момент написания этой статьи компиляторы 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, как показано на рис. 4.

Рис. 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 сборщик мусора (garbage collector, GC) учитывает поколения объектов (generational collector), а это означает, что он разделяет объекты на группы, называемые поколениями: на верхнем уровне новые объекты создаются в поколении 0, а затем все объекты, пережившие сбор мусора, переводятся в другое поколение (.NET GC в настоящее время использует поколения 0, 1 и 2). Это ускоряет операции сбора мусора, позволяя GC часто собирать только некое подмножество известного пространства объектов. В основе лежит концепция того, что надобность в только что созданных объектах быстро отпадает, а объекты, существующие длительное время, пока останутся. То есть, если объект переживает поколение 0, он скорее всего будет существовать еще какое-то время, продолжая в течение этого срока расходовать системные ресурсы. А значит, на самом деле требуется обеспечить, чтобы объекты становились доступными сбору мусора, как только необходимость в них исчезает.

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

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

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

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. Однако здесь в Sum передаются как параметры результаты нескольких await-выражений; из-за правил оценки и того, как async реализовано в компиляторе, конкретно этот пример требует от компилятора временно сохранять результаты первых двух await-выражений. Как вы уже видели, локальные переменные сохраняются между точками ожидания поднятием в поля класса конечного автомата. Но в случаях, подобных этому, значения находятся в CLR-стеке оценки (CLR evaluation stack), откуда они не поднимаются в конечный автомат; вместо этого они записываются в один временный объект, а затем конечный автомат ссылается на них. Когда ожидание на первой задаче завершается и происходит переход ко второй, компилятор генерирует код, упаковывающий первый результат и сохраняющий упакованный объект в единое поле <>t__stack конечного автомата. По окончании ожидания на второй задаче и переходе к ожиданию на третьей компилятор генерирует код, который создает Tuple<int,int> из первых двух значений, сохраняя этот Tuple в том же поле <>__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);
}

Чем меньше await-выражений вам приходится обрабатывать, тем лучше.

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

Конечно, в предыдущих примерах кроются куда большие издержки, о которых вы должны знать и которые нужно учитывать. Этот код не может вызвать Sum, пока не завершатся все три await-выражения, и между этими await-выражениями никакой работы не выполняется. Каждое из этих 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<TResult>. Для максимальной производительности вы могли бы также использовать гибридный подход, где сначала проверяется, все ли задачи успешно завершились, и, если да, их результаты получаются индивидуально; но в ином случае вы ждали бы с помощью WhenAll еще не завершенные задачи. Это избавило бы вас от любых операций создания, связанных с вызовом WhenAll, когда в них нет нужды, например от создания массива params, передаваемого в метод. И как уже говорилось, желательно, чтобы эта библиотечная функция также подавляла маршалинг контекста. Такое решение показано на рис. 5.

Рис. 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, вы должны выбирать асинхронные методы. Тем не менее, как разработчику вам будет полезно понимать все, что делает .NET Framework в ваших интересах в этих асинхронных методах, чтобы при необходимости вы могли добиться максимально эффективного результата.


Ссылка: http://www.oszone.net/17356/