Файл: Асинхронноепрограммирование.pdf

ВУЗ: Не указан

Категория: Не указан

Дисциплина: Не указана

Добавлен: 10.11.2023

Просмотров: 137

Скачиваний: 3

ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.

101
Реализация асинхронных методов в компоненте...
И снова задача упрощается благодаря методам расширения, предо- ставляемым .NET. В простых случаях метод
AsAsyncOperation делает именно то, что нужно, – преобразует
Task в
IAsyncOperation
Соответственно
AsAsyncAction преобразует
Task в
IAsyncAction
Кстати говоря, методы
AsTask и AsAsyncOperation знают друг о друге и могут определить, написаны ли владелец и потреби- тель WinMD-метода на .NET. Если это так, то исходный объект
Task возвращается непосредственно, что повышает произво- дительность.
Но если требуется отмена или информирование о ходе выполне- ния, то метода
AsAsyncOperation недостаточно. TAP-методы требу- ют задания параметра типа
CancellationToken или
IProgress
, поэтому метод расширения класса
Task бессилен. Для преобразова- ния из одной модели в другую придется прибегнуть к более сложно- му средству – методу
AsyncInfo.Run public IAsyncOperation GetTheIntAsync()
{
return AsyncInfo.Run(cancellationToken =>
GetTheIntTaskAsync(cancellationToken));
}
private async Task GetTheIntTaskAsync(CancellationToken ct)
{
AsyncInfo.Run принимает делегат, который позволяет передать лямбда-выражению объект
CancellationToken
,
IProgress
или тот и другой. Затем эти объекты можно передать TAP-методу. Метод
AsAsyncOperation можно рассматривать как синоним простейшего перегруженного варианта метода
AsyncInfo.Run
, для которого деле- гат не принимает никаких параметров.

1   2   3   4   5   6   7   8   9

ГЛАВА 14.
Подробно
о преобразовании
асинхронного кода,
осуществляемом
компилятором
Механизм async реализован в компиляторе C# при поддержке со стороны библиотек базовых классов .NET. В саму исполняющую среду не пришлось вносить никаких изменений. Это означает, что ключевое слово await реализовано путем преобразования к виду, который мы могли бы написать и сами в предыдущих версиях C#. Для изучения генерируемого кода можно воспользоваться декомпилятором, например .NET Reflector.
Это не только интересно, но и полезно для отладки, анализа произ- водительности и других видов диагностики асинхронного кода.
Метод-заглушка
Метод, помеченный ключевым словом async, подменяется заглушкой.
При вызове async-метода работает именно эта заглушка. Рассмотрим для примера следующий простой async-метод:
public async Task AlexsMethod()
{
int foo = 3;
await Task.Delay(500);
return foo;
}
Сгенерированный компилятором метод-заглушка выглядит сле- дующим образом:

103
public Task AlexsMethod()
{
d__0 stateMachine = new d__0();
stateMachine.<>4__this = this;
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<d__0>(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
Я немного изменил имена переменных, чтобы было проще понять код.
В разделе «Async, сигнатуры методов и интерфейсы» выше мы ви- дели, что ключевое слово async не оказывает влияние на то, как метод используется извне. Это следует из того факта, что сигнатура мето- да-заглушки совпадает с сигнатурой исходного метода, но без слова async
Обратите внимание, что в заглушке нет и следов моего ориги- нального кода. Большую часть заглушки составляет инициализа- ция полей структуры с именем
d__0
. Эта структура представляет собой конечный автомат, в котором и производится вся трудная работа. Заглушка вызывает метод
Start
, а затем возвращает объект
Task
. Чтобы понять, что происходит, нам придется заглянуть внутрь самой структуры stateMachine
Структура конечного автомата
Компилятор генерирует структуру, которая играет роль конечного автомата и содержит весь код моего оригинального метода. Делается это для того, чтобы получить объект, способный представить состо- яние метода, который можно было бы сохранить, когда программа дойдет до await
. Напомню, что при достижении await сохраняется вся информация о том, в каком месте метода находится программа, чтобы при возобновлении ее можно было восстановить.
Компилятор мог бы, конечно, сохранить все локальные перемен- ные метода в момент приостановки, но для этого потребовалось бы сгенерировать очень много кода. Лучше поступить по-другому – пре- образовать все локальные переменные вашего метода в переменные- члены некоторого типа, тогда при сохранении экземпляра этого типа автоматически будут сохранены и все локальные переменные. Вот для этого и предназначена сгенерированная структура.
Структура конечного автомата

104
Глава 14. Подробно о преобразовании...
Конечный автомат объявлен как структура, а не класс, из сооб- ражений производительности. Благодаря этому при синхронном завершении асинхронного метода не приходится выделять па- мять для объекта из кучи. К сожалению, из-за того, что конечный автомат – структура, рассуждать о нем становится сложнее.
Конечный автомат генерируется в виде структуры, вложенной в тип, содержащий асинхронный метод. Так проще понять, из какого метода он был сгенерирован, но основная причина в том, чтобы пре- доставить автомату доступ к закрытым членам вашего типа.
Рассмотрим, какая структура конечного автомата (
d__0
)
сгенерирована для нашего примера. Сначала – перемен- ные-члены:
public int <>1__state;
public int 5__1;
public AlexsClass <>4__this;
public AsyncTaskMethodBuilder <>t__builder;
private object <>t__stack;
private TaskAwaiter <>u__$awaiter2;
Имена всех переменных содержат угловые скобки, показываю- щие, что имена сгенерированы компилятором. Это нужно для того, чтобы сгенерированный компилятором код не вступал в конфликт с пользовательским, – ведь в корректной программе на C# имена переменных не могут содержать угловых скобок. В данном случае это не так важно.
В первой переменной,
<>1__state
, сохраняется номер достигну- того оператора await
. Пока не встретился никакой await
, значение этой переменной равно
-1
. Все операторы await в оригинальном ме- тоде пронумерованы, и в момент приостановки в переменную state заносится номер await
, после которого нужно будет возобновить ис- полнение.
Следующая переменная,
5__1
, служит для хранения значе- ния моей оригинальной переменной foo
. Как мы скоро увидим, все обращения к foo заменены обращениями к этой переменной-члену.
Далее следует переменная
<>4__this
. Она встречается только в конечных автоматах для нестатических асинхронных методов и со- держит объект, от имени которого этот метод вызывался. В каком- то смысле this
– это просто еще одна локальная переменная метода, только используется она специальным образом – для доступа к дру- гим членам того же объекта. В процессе преобразования async-метода

105
Метод MoveNext ее необходимо сохранить и использовать явно, потому что код ориги- нального объекта перенесен в структуру конечного автомата.
AsyncTaskMethodBuilder
– это вспомогательный тип, в котором инкапсулирована логика, общая для всех конечных автоматов. Имен- но этот тип создает объект
Task
, возвращаемый заглушкой. На самом деле, он очень похож на класс
TaskCompletionSource в том смысле, что создает задачу-марионетку, которую сможет сделать завершенной позже. Отличие от
TaskCompletionSource заключается в том, что
AsyncTaskMethodBuilder оптимизирован для async-методов и ради повышения производительности является структурой, а не классом.
Async-методы, возвращающие void, пользуются вспомогатель- ным типом
AsyncVoidMethodBuilder, а async-методы, возвра- щающие
Task, – универсальным вариантом типа, AsyncTas kMethodBuilder.
Переменная
<>t__stack применяется для тех операторов await
, которые входят в более сложное выражение. Промежуточный язык
.NET (IL) является стековым, поэтому сложные выражения строятся из небольших команд, которые манипулируют стеком значений. Если await встречается в середине такого сложного выражения, то находя- щиеся в стеке значения помещаются в эту переменную, причем если значений несколько, то она на самом деле является кортежем
Tuple
Наконец, в переменной
TaskAwaiter хранится временный объект, который помогает оператору await подписаться на уведомление о за- вершении задачи
Task
Метод MoveNext
В конечном автомате всегда имеется метод
MoveNext
, в котором на- ходится весь наш оригинальный код. Этот метод вызывается как при первом входе в наш метод, так и при возобновлении после await
Даже в случае простейшего async-метода код
MoveNext на удивление сложен, поэтому я попытаюсь описать преобразование в виде после- довательности шагов. Кроме того, я опускаю малосущественные де- тали, поэтому во многих местах мое описание не вполне точно.
Этот метод назван
MoveNext из-за сходства с методами
MoveNext, которые генерировались блоками итераторов в пре- дыдущих версиях C#. Эти блоки позволяют реализовать интер- фейс
IEnumerable в одном методе с помощью ключевого слова yield return. Применяемый для этой цели конечный автомат во многом напоминает асинхронный автомат, только проще.

106
Глава 14. Подробно о преобразовании...
Наш код
Первым делом необходимо скопировать наш код в метод
MoveNext
Напомним, что все обращения к локальным переменным следует за- менить обращениями к переменным-членам конечного автомата. На месте await я пока оставлю пропуск, который заполню позже.
5__1 = 3;
Task t = Task.Delay(500);
Здесь будет код, относящийся к await t
return 5__1;
Преобразование предложений return
в код завершения
Каждое предложение return в оригинальном коде следует преобра- зовать в код, завершающий задачу
Task
, возвращенную методом-за- глушкой. На самом деле, метод
MoveNext возвращает void
, поэтому предложение return foo; вообще недопустимо.
<>t__builder.SetResult(5__1);
return;
Разумеется, сделав задачу завершенной, мы выходим из
MoveNext с помощью return;
Переход в нужное место метода
Поскольку
MoveNext вызывается как для возобновления после каждого await
, так и при первом входе в метод, мы должны уметь переходить в нужное место метода. Для этого генерируется примерно такой же IL-код, как для предложения switch
, как если бы мы ветвились по переменной state switch (<>1__state)
{
case -1: // При первом входе в метод
5__1 = 3;
Task t = Task.Delay(500);
Здесь будет код, относящийся к await t
case 0: // Есть только один await, его номер равен 0
<>t__builder.SetResult(5__1);
return;
}

107
Метод MoveNext
Приостановка метода в месте встречи await
Именно здесь мы используем объект
TaskAwaiter для подписки на уведомление о завершении задачи
Task
, которую мы ждем. Чтобы возобновиться с нужного места, необходимо изменить переменную state
. Всё подготовив, мы возвращаем управление, освобождая по- ток для других дел, как и полагается приличному асинхронному ме- тоду.
5__1 = 3;
<>u__$awaiter2 = Task.Delay(500).GetAwaiter();
<>1__state = 0;
<>t__builder.AwaitUnsafeOnCompleted(<>u__$awaiter2, this);
return;
case 0:
В процедуре подписки на уведомление участвует также объект
AsyncTaskMethodBuilder
, и в целом она весьма сложна. Именно здесь реализуются дополнительные возможности await
, в том числе запоминание контекста синхронизации, который нужно будет вос- становить при возобновлении. Но конечный результат понятен. Ког- да задача
Task завершится, метод
MoveNext будет вызван снова.
Возобновление после await
По завершении ожидаемой задачи мы оказываемся в нужном месте метода
MoveNext
, но перед исполнением оригинального кода долж- ны еще получить результат задачи. В данном примере используется неуниверсальный класс
Task
, поэтому в переменную читать нечего.
Тем не менее задача могла завершиться ошибкой, и в таком случае мы должны возбудить исключение. Всё это делает метод
GetResult объекта
TaskAwaiter case 0:
<>u__$awaiter2.GetResult();
<>t__builder.SetResult(5__1);
Синхронное завершение
Напомню, что если await используется для ожидания задачи, кото- рая уже завершилась синхронно, то не следует приостанавливать и

108
Глава 14. Подробно о преобразовании...
возобновлять метод. Для этого мы должны перед возвратом прове- рить, является ли задача
Task завершенной. Если да, то мы просто переходим в нужное место с помощью предложения goto case
<>u__$awaiter2 = Task.Delay(500).GetAwaiter();
if (<>u__$awaiter2.IsCompleted)
{
goto case 0;
}
<>1__state = 0;
Сгенерированный компилятором код хорош тем, что его никто не должен сопровождать, поэтому употреблять goto можно сколько душе угодно. Раньше я даже не знал о существовании конструкции goto case, и, наверное, это к лучшему.
Перехват исключений
Если при исполнении нашего async-метода возникло и не было пере- хвачено в блоке try-catch исключение, то его должен перехватить сгенерированный компилятором код. Это нужно для того, чтобы пе- ревести возвращенный объект
Task в состояние «ошибка», не позво- лив исключению покинуть метод. Напомню, что метод
MoveNext мо- жет быть вызван как из того места, где вызывался наш оригинальный async-метод, так и из завершившейся ожидаемой задачи, возможно, через контекст синхронизации. Ни в том, ни в другом случае про- грамма не ожидает исключений.
try
{
... Весь метод
}
catch (Exception e)
{
<>t__builder.SetException(<>t__ex);
return;
}
Более сложный код
Мой пример был очень прост. Метод
MoveNext становится гораздо сложнее при наличии в оригинальном коде следующих конструк- ций:

109
• блоков try-catch-fi nally
;
• ветвлений
(
if и switch
);
• циклов;
• операторов await в середине выражения.
Компилятор корректно преобразует все эти конструкции, поэтому программисту не приходится задумываться о сложности кода.
Призываю вас воспользоваться декомпилятором и посмотреть, как выглядит метод
MoveNext для какого-нибудь из ваших собственных async-методов. Попробуйте найти места, которые я упростил в своем описании, и понять, как преобразуется более сложный код.
Разработка типов, допускающих
ожидание
Тип
Task допускает ожидание, то есть к нему можно приме- нить оператор await
. В разделе «Интерфейсы IAsyncAction и
IAsyncOperation» мы видели, что есть и другие допускающие ожидание типы, например тип WinRT
IAsyncAction
. На самом деле, можно и самостоятельно написать такого рода типы, хотя вряд ли в этом возникнет необходимость.
Чтобы тип допускал ожидание, он должен предоставлять средст- ва, используемые в рассмотренном выше методе
MoveNext
. Прежде всего, в нем должен быть определен метод
GetAwaiter
:
class MyAwaitableClass
{
public AlexsAwaiter GetAwaiter()
{
GetAwaiter может быть и методом расширения, что дает дополни- тельную гибкость. Например, в интерфейсе
IAsyncAction нет метода
GetAwaiter
, потому что он входит в WinRT, а в WinRT нет понятия типа, допускающего ожидание.
IAsyncAction наделяется такой воз- можностью только благодаря методу расширения
GetAwaiter, пре- доставляемому .NET.
Далее, тип, возвращаемый методом
GetAwaiter
, должен обладает определенными свойствами, чтобы класс
MyAwaitableClass мог счи- таться допускающим ожидание. Минимальные требования таковы:
• он должен реализовывать интерфейс
INotifyCompletion, то есть содержать метод void OnCompleted(Action handler)
, который подписывается на уведомление о завершении;
Разработка типов, допускающих ожидание

110
Глава 14. Подробно о преобразовании...
• он должен содержать свойство bool IsCompleted { get; }
, которое служит для проверки синхронного завершения;
• он должен содержать метод
T GetResult()
, который возвра- щает результат операции и возбуждает исключения.
Тип
T
, возвращаемый методом
GetResult
,
может быть void
, как то имеет место в случае
Task
. Но может быть и настоящим типом, как в случае
Task
. И лишь во втором случае компилятор позволит ис- пользовать await как выражение, например в правой части оператора присваивания.
Вот как мог бы выглядеть тип
AlexsAwaiter
:
class AlexsAwaiter : INotifyCompletion
{
public bool IsCompleted
{
get
{
}
}
public void OnCompleted(Action continuation)
{
}
public void GetResult()
{
}
}
Важно помнить о существовании класса
TaskCompletionSource и о том, что обычно гораздо лучше воспользоваться им, когда требуется преобразовать нечто асинхронное в нечто допускающее ожидание. В классе
Task есть немало полезных функций, и пренебречь ими было бы непростительным легкомыслием.
Взаимодействие с отладчиком
Возможно, вы думаете, что после того как компилятор так перело- патил ваш код, встроенный в Visual Studio отладчик не сможет ра- зобраться в том, что происходит. Но на самом деле с отладкой всё в порядке. Достигается это в основном за счет того, что компилятор связывает строки написанного вами исходного кода с соответствую-

111
Взаимодействие с отладчиком щими им частями сгенерированного метода
MoveNext
. Это соответст- вие хранится в PDB-файле и гарантирует правильную работу следу- ющих функций отладчика:
• расстановка точек прерывания;
• пошаговое исполнение строк, не содержащих await
;
• просмотр строки, в которой было возбуждено исключение.
Однако если внимательнее присмотреться к коду, остановившись в точке прерывания после оператора await в async-методе, то можно заметить некоторые признаки, свидетельствующие о том, что компи- лятор действительно преобразовал код.
• В нескольких местах имя текущего метода отображается как
MoveNext
. В окне трассировки стека оно заменяется именем оригинального метода, но Intellitrace этого не делает.
• В окне трассировки стека видны кадры, отражающие инфра- структуру TPL, за которыми следует строка [Resuming Async
Method] и имя вашего метода.
Но настоящее волшебство творится в режиме пошагового исполнения кода. Отладчик Visual Studio предпринимает героические усилия, чтобы корректно перешагнуть (команда Step Over – F10) через оператор await
, несмотря на то, что метод продолжается через неопределенное время в заранее неизвестном потоке. Следы необходимой для этого инфраструктуры можно наблюдать в классе
AsyncTaskMethodBuilder
, где имеется свойство
ObjectIdForDebug- ger
. Отладчик умеет также выходить из async-метода (команда Step
Out – Shift+F11), оказываясь при этом в точке после await
, где ждет завершения задачи.

ГЛАВА 15.
Производительность
асинхронного кода
Решая воспользоваться асинхронным кодом, вы, вероятно, заду- мывались о производительности. Каким бы ни был побудительный мотив – отзывчивость пользовательского интерфейса, пропускная способность сервера или распараллеливание с помощью акторов – необходима уверенность, что изменение действительно оправдает себя.
Рассуждая о производительности асинхронного кода, следует сравнить его с имеющимися в конкретной ситуации альтернативами.
В этой главе мы будем рассматривать:
• ситуации, когда имеется длительная операция, которую по- тенциально можно выполнить асинхронно;
• ситуации, когда не существует длительной операции и воз- можностей асинхронного исполнения не видно;
• сравнение асинхронного кода с обычным, блокирующим про- грамму на время выполнения длительной операции;
• сравнение механизма async с асинхронным кодом, написанным вручную.
Мы также обсудим некоторые оптимизации, полезные в случае, когда оказывается, что сопряженные с механизмом async накладные расходы приводят к проблемам с производительностью приложе- ния.
1   2   3   4   5   6   7   8   9

Измерение накладных расходов
механизма async
На реализацию механизма async неизбежно затрачивается дополни- тельное по сравнению с синхронным кодом время, а переключения

113
Async и блокирующая длительная операция между потоками увеличивают задержку. Невозможно точно изме- рить накладные расходы на реализацию асинхронности. Производи- тельность приложения зависит от того, чем занимаются потоки, от поведения кэша и от других непредсказуемых факторов. Кроме того, есть различие между использованием процессора и дополнительной задержкой, поскольку время операции в асинхронной системе может возрастать и без потребления ЦП – из-за того, что запрос ожидает своей очереди. Поэтому мой анализ дает лишь порядок величины.
В качестве эталона для сравнения я возьму стоимость обычного вы- зова метода. На моем ноутбуке за одну секунду эталонный метод можно вызвать примерно 100 миллионов раз.
Async и блокирующая
длительная операция
Обычно к механизму async прибегают, когда имеется длительная операция, которую можно выполнить асинхронно, освободив тем са- мым ресурсы. В программах с пользовательским интерфейсом асин- хронность позволяет обеспечить отзывчивость интерфейса (если, конечно, операция не выполняется мгновенно). В серверном коде компромисс не столь очевиден, так как мы должны выбирать между памятью, занятой заблокированными потоками, и дополнительным процессорным временем, затрачиваемым на выполнение асинхрон- ных методов.
Накладные расходы на действительно асинхронное выполнение async-метода всецело зависят от того, необходимо ли переключение потоков с помощью метода
SynchronizationContext.Post
. Если это так, то подавляющую долю накладных расходов составляет именно переключение потоков в момент возобновления метода. Это означа- ет, что текущий контекст синхронизации играет очень важную роль.
Я замерял накладные расходы, выполняя метод, который не делает ничего, кроме await Task.Yield
, то есть всегда завершается асинх- ронно:
async Task AlexsMethod()
{
await Task.Yield();
}

114
Глава 15. Производительность асинхронного кода
Таблица 15.1. Накладные расходы на исполнение и возобновление async-метода
SynchronizationContext
Стоимость (по сравнению с пустым
методом)
Post не нужен
100
Пул потоков
100
Windows forms
1000
WPF
1000
ASP.NET
1000
Придется ли платить за переключение потоков, зависит как от кон- текста синхронизации вызывающего потока, так и от контекста синх- ронизации потока, в котором завершилась задача.
• Если эти потоки совпадают, то вызывать метод
Post исход- ного контекста
SynchronizationContext не нужно, и метод можно возобновить в потоке, где завершилась задача, синх- ронно – как часть процедуры завершения.
• Если в вызывающем потоке был контекст синхронизации, но не тот, что в потоке, где произошло завершение, то требуется вызвать метод
Post
, что приведет к высоким накладным рас- ходам, отраженным в таблице. То же самое имеет место, когда в потоке завершения нет контекста синхронизации.
• Если в вызывающем потоке не было контекста синхрониза- ции, как, например, в консольном приложении, то ситуация определяется контекстом синхронизации потока завершения.
Если он существует, то .NET предполагает, что этот поток ва- жен и планирует возобновление метода в потоке из пула. Если же контекста синхронизации в потоке завершения нет или это поток, взятый из пула, то метод возобновляется в том же пото- ке, синхронно.
В действительности пул потоков в .NET работает настолько быст- ро, что накладные расходы на переключение потоков даже не отразились на порядке величины сравнительно с возобновлени- ем метода в том же потоке. Таким образом, о контексте синхро- низации потока завершения можно вообще не думать.
Эти правила означают, что цепочка async-методов приведет к од- ному дорогостоящему переключению потоков – при возобновлении


115
Async и блокирующая длительная операция метода с наибольшим уровнем вложенности. После этого контекст синхронизации уже не меняется, и возобновление остальных методов обходится дешево. Переключение потоков в контексте пользователь- ского интерфейса оказывается одной из самых дорогих операций.
Однако в приложении с пользовательским интерфейсом синхронное выполнение длительных операций выглядит настолько безобразно, что выбора всё равно нет. Если сетевой запрос занимает 500 мс, то имеет смысл пожертвовать еще одну миллисекунду на обеспечение отзывчивости интерфейса.
К сожалению, WPF часто пересоздает объекты
Synchroniza- tionContext, поэтому в контексте WPF метод Post вызывается при возобновлении каждого вложенного async-метода. Приложе- ния для Windows Forms и Windows 8 этой болезнью не страдают.
В серверном коде, например в приложениях ASP.NET, выбор ком- промисса требует более тщательного анализа. Имеет ли смысл пере- ходить на асинхронный код, зависит прежде всего от того, хватает ли серверу оперативной памяти, потому что именно памятью приходит- ся расплачиваться за использование большого числа потоков. Есть целый ряд факторов, из-за которых синхронное приложение может потреблять память быстрее, чем процессорное время.
• Вызываются длительные операции, занимающие относитель- но много времени.
• Длительные операции распараллеливаются за счет использо- вания дополнительных потоков.
• Есть много запросов, которые требуют запуска длительных операций и не могут быть обслужены из кэша в памяти.
• Для порождения ответа не требуется много процессорного времени.
Единственный способ уяснить, как обстоит дело, – замерять потреб- ление памяти сервером. Если это действительно проблема и память выделяется чрезмерно большому количеству потоков, то переход к асинхронному выполнению может оказаться неплохим решением.
При этом будет потребляться немного больше процессорного време- ни, но если серверу не хватает памяти, а процессорных мощностей в избытке, то с этим легко смириться.
Напомню, что хотя async-методы всегда потребляют больше про- цессорного времени, чем синхронные, разница на самом деле совсем невелика и может оказаться незаметна на фоне других задач, решае- мых приложением.

116
Глава 15. Производительность асинхронного кода
Оптимизация асинхронного кода
для длительной операции
Если async-метод действительно работает асинхронно, то, как мы ви- дели, львиная доля накладных расходов приходится на обращение к методу
Post вызывающего контекста
SynchronizationContext
, в результате которого происходит переключение потоков. В разделе
«Когда не следует использовать SynchronizationContext» мы гово- рили, что метод
Confi gureAwait позволяет подавить вызов
Post и тем самым не платить за переключение потоков, когда без него мож- но обойтись. Если ваш код вызывается из потока пользовательского интерфейса в WPF, то такое решение позволит избежать повторных вызовов
Post
Источником накладных расходов при написании async-мето- дов служит также контекст исполнения вызывающего потока –
ExecutionContext
. В разделе «Контекст» мы видели, что .NET запо- минает и восстанавливает
ExecutionContext при каждом await
. Если вы не используете
ExecutionContext
, то запоминание и восстанов- ление контекста по умолчанию хорошо оптимизировано и обходится очень дешево. В противном случае процедура становится куда более дорогой. Поэтому для повышения производительности старайтесь не пользоваться контекстами
CallContext
,
LogicalCallContext или олицетворением.
Async-методы и написанный
вручную асинхронный код
В старой программе с пользовательским интерфейсом проблема от- зывчивости, вероятно, уже решена за счет какой-то ручной асинхрон- ной техники. Способов несколько, в том числе:
• создание нового потока;
• использование метода
ThreadPool.QueueUserWorkItem для выполнения длительной операции в фоновом потоке;
• использование класса
BackgroundWorker
;
• ручное использование асинхронного API.
При любом из этих подходов необходим хотя бы один возврат в поток пользовательского интерфейса для представления результата пользователю, то есть то же самое, что async-метод делает автома- тически. Иногда это осуществляется неявно (например, с помощью


117
Async и блокирование без длительной операции события
RunWorkerCompleted в классе
BackgroundWorker
), а иногда требуется явно вызывать метод
BeginInvoke
По скорости все эти подходы различаются незначительно, ис- ключение составляет создание нового потока, которое выполняет- ся гораздо медленнее. Механизм async как минимум не уступает в скорости любому из перечисленных решений, если удается избежать использования
ExecutionContext
. На самом деле, в моих измерениях async оказывался даже на несколько процентов быстрее. И так как наличествует небольшой выигрыш в скорости, а код выглядит понятнее, то я предпочитаю async любой другой технике.
Async и блокирование
без длительной операции
Весьма типична ситуация, когда некоторый метод иногда выполня- ется долго, но в 99 процентах случаев работает очень быстро. Приме- ром может служить кэшируемый сетевой запрос, когда большинство запросов обслуживаются из кэша. Использовать ли в таких случаях асинхронный код, часто зависит от накладных расходов в типичном случае, когда метод завершается синхронно, а не от расходов в одном проценте случаев, когда действительно требуется асинхронная сете- вая операция.
Напомню, что ключевое слово await не приостанавливает метод без необходимости, когда задача уже завершена. Метод, содержащий await
, в этом случае также завершается синхронно и возвращает уже завершенный объект
Task
. Следовательно, вся цепочка async-мето- дов отрабатывает синхронно.
Async-методы, даже когда они исполняются синхронно, неизбеж- но оказываются медленнее эквивалентных синхронных методов. И в данном случае мы не получаем никакого выигрыша от освобождения ресурсов. Так называемый async-метод не является асинхронным, а так называемый блокирующий метод ничего не блокирует. Тем не менее преимущества, которые асинхронность дает в одном проценте случаев, когда запрос нельзя обслужить из кэша, могут быть настоль- ко велики, что написание асинхронного кода оправдано.
Всё зависит от того, насколько асинхронный код медленнее обыч- ного, когда тот и другой синхронно возвращают результат из кэша.
Но точное измерение затруднительно, так как зависит от слишком многих факторов. Я обнаружил, что вызов пустого async-метода в
10 раз медленнее вызова пустого синхронного метода.

118
Глава 15. Производительность асинхронного кода
Да, асинхронный код медленнее, но напомню, что это лишь наклад- ные расходы. Почти всегда они поглощаются реальной работой. На- пример, поиск в словаре
Dictionary
также обхо- дится примерно в 10 раз медленнее, чем вызов пустого метода.
Оптимизация асинхронного кода
без длительной операции
Накладные расходы на вызов синхронно завершающегося async-ме- тода, которые примерно в 10 раз превышают стоимость вызова пус- того синхронного метода, проистекают из нескольких источников.
Большая их часть неизбежна – например, исполнение сгенерирован- ного компилятором кода, вызовы каркаса и невозможность различ- ных оптимизаций из-за способа обработки исключений, возникаю- щих в async-методах.
Из тех расходов, которых можно избежать, основная часть прихо- дится на выделение памяти для объектов из кучи. Собственно выде- ление памяти – очень дешевая операция. Но когда таких объектов много, приходится чаще запускать сборщик мусора, а, объект, все еще используемый во время сборки мусора, обходится дорого.
Механизм async спроектирован так, чтобы память из кучи выде- лялась как можно реже. Именно поэтому конечной автомат является структурой, равно как и типы
AsyncTaskMethodBuilder
. Они пере- мещаются в кучу, только если async-метод приостанавливается.
Но
Task
– не структура, поэтому всегда выделяется из кучи. По этой причине в .NET заранее выделено несколько объектов
Task
, которые используются, когда async-метод завершается синхронно и возвращает одно из следующих типичных значений:
• неуниверсальный, успешно завершенный объект
Task
;
• объект типа
Task
, содержащий true или false
;
• объект типа
Task
, содержащий небольшое целое число;
• объект типа
Task
, содержащий null
Если вы разрабатываете кэш, который должен обладать очень вы- сокой производительностью, и ни один из этих случаев неприменим, то избежать выделения памяти из кучи можно путем кэширования завершенного объекта
Task
, а не просто значения. Впрочем, это редко бывает оправдано, поскольку вы, скорее всего, всё равно выделяете память для объектов из кучи в других местах программы.
В заключение отметим, что async-методы, завершающиеся синх- ронно, уже работают очень быстро и дальнейшая оптимизация зат-