Асинхронные методы в предстоящих версиях Visual Basic и C# — отличный способ избавиться от обратных вызовов при использовании асинхронного программирования. В этой статье я подробно расскажу, что именно делает новое ключевое слово await, начиная от концептуального уровня и заканчивая уровнем «железа».
Последовательная композиция
Visual Basic и C# являются языками императивного программирования — и гордятся этим! Они превосходно позволяют выражать логику программирования в виде последовательности дискретных стадий, которые обрабатываются одна за другой. Большинство языковых конструкций уровня выражений являются управляющими структурами, дающими возможность самыми разнообразными способами указывать порядок выполнения дискретных стадий данного блока кода.
- Условные выражения вроде if и switch позволяют выбирать различные последовательные операции в зависимости от текущего состояния.
- Выражения циклов наподобие for, foreach и while позволяют многократно повторять выполнение определенного набора стадий.
- Такие выражения, как continue, throw и goto, позволяют передавать управление другим частям программы (вне локальной области видимости).
Формирование логики с помощью управляющих структур приводит к последовательной композиции, и они являются кровеносной системой императивного программирования. Это объясняет, почему вам предоставляется так много управляющих структур: последовательная композиция должна быть по-настоящему удобной и тщательно структурированной.
Непрерывное выполнение
В большинстве императивных языков, в том числе в текущих версиях Visual Basic и C#, выполнение методов (или функций, или процедур — обзовите их, как хотите) осуществляется непрерывно. Я подразумеваю под этим следующее. Как только поток начинает выполнять данный метод, он будет постоянно занят этим, пока метод не завершится. Да, иногда поток будет выполнять выражения в методах, вызываемых вашим блоком кода, но это просто часть процесса выполнения данного метода. Поток никогда не переключится на что-то другое, если только ваш метод не укажет ему сделать это.
Эта непрерывность иногда создает проблемы. Временами метод не может продолжить, пока не произойдет некое событие: завершится скачивание, доступ к файлу, вычисления в другом потоке или сработает таймер. В таких ситуациях полностью занят ничегонеделанием. Обычно в таких случаях говорят, что поток блокирован; метод, послуживший причиной этому, называют блокирующим.
Вот пример метода, вызывающего весьма серьезную блокировку:
static byte[] TryFetch(string url)
{
var client = new WebClient();
try
{
return client.DownloadData(url);
}
catch (WebException) { }
return null;
}
Поток, выполняющий этот метод, будет простаивать большую часть времени вызова client.DownloadData, фактически ничего не делая, а просто находясь в ожидании.
Это плохо, особенно когда потоки являются драгоценным ресурсом, а именно так зачастую и есть. В типичном промежуточном уровне обслуживание каждого запроса требует взаимодействия с внутренним сервером или другим сервисом. Если каждый запрос обрабатывается в своем потоке и эти потоки большую часть времени блокируются в ожидании промежуточных результатов, то одно лишь количество потоков, выделяемых для промежуточного уровня, может легко стать бутылочным горлышком.
Вероятно, самый драгоценный вид потоков — UI-поток: он всего один такой. Практически все UI-инфраструктуры являются однопоточными и требуют, чтобы все, связанное с UI (события, обновления, логика манипуляции UI), выполнялось в одном выделенном потоке. Если одна из UI-операций (например, обработчик события запускает скачивание по URL) входит в состояние ожидания, замораживается весь UI, потому что его поток занят ничегонеделанием.
Таким образом, нам нужно, чтобы несколько последовательных операций могли совместно использовать потоки. Для этого они должны время от времени «делать паузы», т. е. оставлять дыры (временные окна) в своем выполнении, в течение которых другие могли бы делать что-то в том же потоке. Другими словами, иногда они должны быть «прерываемыми». Это особенно удобно, если последовательные операции делают паузу, когда ничем не занимаются. И тут нас спасет асинхронное программирование!
Асинхронное программирование
В настоящее время, поскольку методы всегда непрерывные, вы должны разбивать прерываемые операции на несколько методов (например, до и после скачивания). Чтобы отыскать дыру в середине процесса выполнения метода, вы должны разъединить его на непрерывные части. Различные API могут помочь в этом деле, предлагая асинхронные (неблокирующие) версии длительно выполняемых методов, которые инициируют операцию (например, запускают скачивание), сохраняют передаваемый обратный вызов для возобновления выполнения по окончании, а затем немедленно возвращают управление вызвавшему коду. Но, чтобы вызывающий код мог предоставить обратный вызов, операции «после» нужно переработать в отдельные методы.
Вот как это делается для предыдущего метода TryFetch:
static void TryFetchAsync(string url, Action<byte[],
Exception> callback)
{
var client = new WebClient();
client.DownloadDataCompleted += (_, args) =>
{
if (args.Error == null) callback(args.Result, null);
else if (args.Error is WebException) callback(null, null);
else callback(null, args.Error);
};
client.DownloadDataAsync(new Uri(url));
}
Здесь вы видите несколько способов передачи обратных вызовов: метод DownloadDataAsync ожидает, что обработчик событий подписывается на событие DownloadDataCompleted, и именно так вы передаете часть метода «после». TryFetchAsync тоже приходится иметь дело с обратными вызовами вызвавших. Вместо того чтобы заниматься всем этим самостоятельно, вы используете более простой подход — обратный вызов принимается как параметр. Очень хорошо, что с помощью лямбда-выражения обработчик событий может просто получать параметр callback и напрямую использовать его; если бы вы попробовали применять именованный метод, вам пришлось бы придумать какой-то способ передачи делегата обратного вызова в обработчик событий. Сделайте паузу на секунду и представьте, что вам потребовалось бы писать без лямбда-выражений.
Но главное, на что здесь нужно обратить внимание, — насколько сильно изменяется поток управления. Вместо использования управляющих структур языка для выражения потока управления, вы их эмулируете:
- выражение return эмулируется вызовом обратного вызова;
- неявное распространение исключений эмулируется тоже вызовом обратного вызова;
- обработка исключений эмулируется с помощью проверки типа (type check).
Конечно, это очень простой пример. Когда применяются более сложные управляющие структуры, эмуляция усложняется в еще большей степени.
Подведем итог. Мы добились прерываемости, а значит, выполняющий поток может делать что-то другое, пока «ждет» завершения скачивания. Но потеряли простоту использования управляющих структур для выражения потока управления. И мы отказались от структурного императивного языка.
Асинхронные методы
Когда мы смотрим на проблему под этим углом, становится очевидным, чем вам помогут асинхронные методы в следующих версиях Visual Basic и C#: они позволят выражать прерываемый последовательный код.
Взгляните на асинхронную версию TryFetch с новым синтаксисом:
static async Task<byte[]> TryFetchAsync(string url)
{
var client = new WebClient();
try
{
return await client.DownloadDataTaskAsync(url);
}
catch (WebException) { }
return null;
}
Асинхронные методы позволяют делать паузу посреди вашего кода: вы можете не только использовать свои любимые управляющие структуры для выражения последовательной композиции, но и натыкать дыр в процессе выполнения с помощью выражений await — в этих дырах выполняющий поток волен делать другие вещи.
Хорошая аналогия, позволяющая лучше понять суть асинхронных методов, — кнопки «пауза» и «воспроизведение». Когда выполняющий поток добирается до выражения await, он нажимает кнопку «пауза» и выполнение метода приостанавливается. А когда задача, на которой вы ждали, завершается, он нажимает кнопку «воспроизведение» и выполнение метода возобновляется.
Перестройка кода компилятором
Когда нечто сложное выглядит простым, это обычно означает, что все самое интересное происходит «за кулисами», и именно так обстоит дело с асинхронными методами. Простота достигается за счет отличной абстракции, которая намного облегчает и написание, и чтение асинхронного кода. Пониманиетого, что происходит на внутреннем уровне не обязательно. Но если у вас есть такое понимание, это безусловно поможет вам стать более эффективным асинхронным программистом и полнее использовать соответствующие средства. И если вы читаете эту статью, то наверняка вам как минимум просто интересно. Поэтому нырнем поглубже: что именно делают async-методы (и await-выражения в них)?
Когда компилятор Visual Basic или C# встречает асинхронный метод, он весьма основательно перелопачивает его при компиляции: прерываемость метода не поддерживается нижележащей исполняющей средой напрямую и должна эмулироваться компилятором. Поэтому разбиением метода на части занимается сам компилятор. Однако он делает это иначе, чем это делали бы вы вручную.
Компилятор превращает ваши асинхронные методы в конечный автомат (state machine). Этот конечный автомат отслеживает, где вы находитесь в процессе исполнения и каково ваше локальное состояние. Оно может быть «выполняется» или «приостановлено». В состоянии «выполняется» процесс исполнения может достигнуть await, которое нажимает кнопку «пауза» и приостанавливает выполнение. В состоянии «приостановлено» что-то может нажать кнопку «воспроизведение», чтобы вернуться и возобновить выполнение.
Выражение await отвечает за такую настройку, чтобы кнопка «воспроизведение» нажималась, когда завершается задача, ожидаемая через await. Однако, прежде чем обсуждать это, давайте рассмотрим, что представляет собой сам конечный автомат и чем на самом деле являются кнопки паузы и воспроизведения.
Формирователи Task
Асинхронные методы создают задачи (tasks). Конкретнее, асинхронный метод возвращает экземпляр типа Task или Task<T> из пространства имен System.Threading.Tasks, и этот экземпляр генерируется автоматически. Пользовательский код не должен (и не может) предоставлять такие экземпляры. (Тут я немного покривил душой: асинхронные методы могут возвращать void, но мы пока проигнорируем этот факт.)
С точки зрения компилятора, создание задач — одна из простых частей его работы. В этом деле он опирается на предоставляемую инфраструктурой концепцию формирователя Task (Task builder), который находится в System.Runtime.CompilerServices (потому что не предназначен для прямого использования человеком). Например, существует тип вроде такого:
public class AsyncTaskMethodBuilder<TResult>
{
public Task<TResult> Task { get; }
public void SetResult(TResult result);
public void SetException(Exception exception);
}
Формирователь позволяет компилятору получить Task, а затем завершить Task с result или Exception. На рис. 1 схематично показано, как выглядит этот механизм для TryFetchAsync.
Рис. 1. Формирование Task
static Task<byte[]> TryFetchAsync(string url)
{
var __builder = new AsyncTaskMethodBuilder<byte[]>();
...
Action __moveNext = delegate
{
try
{
...
return;
...
__builder.SetResult(…);
...
}
catch (Exception exception)
{
__builder.SetException(exception);
}
};
__moveNext();
return __builder.Task;
}
Следите внимательно:
- сначала создается формирователь;
- затем создается делегат __moveNext. Этот делегат является кнопкой «воспроизведение». Назовем его делегатом возобновления (resumption delegate), и он содержит:
- оригинальный код из вашего async-метода (впросем, на данный момент мы это опускаем);
- выражения return, которые нажимают кнопку «пауза»;
- вызовы, завершающие работу формирователя с успешным результатом и соответствующие выражениям return оригинального кода;
- обертывающие блоки try/catch, которые завершают работу формирователя с любыми необрабатываемыми (escaped) исключениями;
- теперь нажимается кнопка «воспроизведение» — вызывается делегат возобновления. Он выполняется, пока не будет нажата кнопка «пауза»;
- вызвавшему коду возвращается Task.
Формирователи Task — особые вспомогательные типы, предназначенные для использования только компилятором. Однако их поведение несильно отличается от того, что происходит, когда вы напрямую используете типы TaskCompletionSource из Task Parallel Library (TPL).
До сих пор я создавал Task для возврата и кнопку «воспроизведение» (делегат возобновления) для вызова кем-либо, когда придет время возобновить выполнение. Нам все еще надо разобраться в том, как возобновляется выполнение и как await-выражение настраивается для решения этой задачи. Но, прежде чем собирать все воедино, рассмотрим, как используются задачи.
Ожидаемые и ждущие
Как вы убедились, на объектах Task можно ждать. Однако Visual Basic и C# за милую душу позволяют ждать и другие объекты, если они ожидаемые (awaitable), т. е. если они имеют определенную форму (shape), с применением которой может быть скомпилировано await-выражение. Чтобы быть ожидаемым, у объекта должен быть метод GetAwaiter, который в свою очередь возвращает ждущего (awaiter). В качестве примера Task<TResult> имеет метод GetAwaiter, возвращающий этот тип:
public struct TaskAwaiter<TResult>
{
public bool IsCompleted { get; }
public void OnCompleted(Action continuation);
public TResult GetResult();
}
Члены ждущего позволяют компилятору проверять, завершен ли ожидающий, регистрировать обратный вызов для него, если еще не завершен, и получать результат (или исключение), когда завершен.
Теперь мы начинаем понимать, что должно делать await, чтобы обеспечить приостановку и возобновление выполнения кода вокруг ожидаемого объекта. Например, await внутри нашего примера TryFetchAsync превратилось бы в нечто вроде:
__awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
if (!__awaiter1.IsCompleted) {
... // готовимся к возобновлению в Resume1
__awaiter1.OnCompleted(__moveNext);
return; // нажимаем кнопку "пауза"
}
Resume1:
... __awaiter1.GetResult()) ...
И вновь следите за тем, что происходит:
- получаем ждущего для задачи, возвращенной из DownloadDataTaskAsync;
- если ждущий не завершен, ему передается кнопка «пауза» (делегат возобновления) в качестве обратного вызова;
- когда ждущий возобновляет выполнение (в Resume1), мы получаем результат и используем его в коде, который расположен за ним.
Очевидно, что общий случай заключается в том, что ожидаемым является Task или Task<T>. Действительно, эти типы (уже присутствующие в Microsoft .NET Framework 4) тонко оптимизированы для такой роли. Однако есть веские причины на то, чтобы поддерживать в качестве ожидаемых и другие типы.
- Взаимодействие с другими технологиями Например, в F# есть тип Async<T>, приблизительно соответствующий Func<Task<T>>. Возможность ожидания Async<T> прямо из Visual Basic или C# помогает наводить мосты между асинхронным кодом, написанным на двух языках. Аналогично F# предоставляет функциональность, позволяющую сделать обратное: использовать Task непосредственно в асинхронном коде на F#.
- Реализация специальной семантикиСама TPL добавляет несколько простых примеров этого. В частности, вспомогательный статический метод Task.Yield возвращает ожидаемый объект, который заявляет (через IsCompleted), что он еще не завершен, но к выполнению немедленно планируется обратный вызов, передаваемый в его метод OnCompleted так, будто он фактически завершен. Это позволяет вам вводить принудительное планирование к выполнению и обходить оптимизацию компилятором, который пропускает эту стадию, если результат уже доступен. Это можно использовать для поиска «дыр» в выполняемом на данный момент коде и улучшать отзывчивость не простаивающего кода. Сами объекты Task не могут представлять завершенные задачи, для которых заявляется обратное, поэтому используется специальный ожидаемый тип (awaitable type).
Прежде чем углубиться в реализацию ожидаемого Task, давайте закончим с тем, как компилятор перестраивает асинхронный метод, и изучим подсистему, которая отслеживает состояние выполнения метода.
Конечный автомат
Чтобы сшить все вместе, нужно построить конечный автомат вокруг создания и использования типов Task. Вся пользовательская логика из оригинального метода в основном помещается в делегат возобновления, но локальные объявления повышаются до более высокого уровня, чтобы они смогли пережить несколько запусков. Более того, вводится переменная состояния для отслеживания того, как идут дела, а пользовательская логика в делегате возобновления обертывается в большой блок switch, где проверяется состояние и выполняется переход к соответствующей метке. Поэтому, когда бы ни было вызвано возобновление, возврат произойдет прямо туда, откуда было передано управление в прошлый раз. Все это показано на рис. 2.
Рис. 2. Создание конечного автомата
static Task<byte[]> TryFetchAsync(string url)
{
var __builder = new AsyncTaskMethodBuilder<byte[]>();
int __state = 0;
Action __moveNext = null;
TaskAwaiter<byte[]> __awaiter1;
WebClient client = null;
__moveNext = delegate
{
try
{
if (__state == 1) goto Resume1;
client = new WebClient();
try
{
__awaiter1 = client.DownloadDataTaskAsync(
url).GetAwaiter();
if (!__awaiter1.IsCompleted) {
__state = 1;
__awaiter1.OnCompleted(__moveNext);
return;
}
Resume1:
__builder.SetResult(__awaiter1.GetResult());
}
catch (WebException) { }
__builder.SetResult(null);
}
catch (Exception exception)
{
__builder.SetException(exception);
}
};
__moveNext();
return __builder.Task;
}
Совсем непросто! Уверен, вы спрашиваете себя, почему этот код намного объемнее и детальнее, чем вручную «асинхронизированная» версия. показанная ранее. На то есть несколько веских причин, включая эффективность (меньше операций выделения памяти в общем случае) и универсальность (этот код применим не только к типам Task, но и к пользовательским ожидаемым объектам). Однако главная причина такова: разбивать пользовательскую логику больше не надо — она просто дополняется переходами, возвратами и т. д.
Хотя пример слишком примитивен, чтобы по-настоящему оценить это, перестройка логики метода в семантически эквивалентный набор дискретных методов для каждой из его непрерывных частей логики между await-выражениями — штука очень хитроумная. Чем больше количество управляющих структур, в которые вложены await-выражения, тем хуже результат. Когда await-выражения окружены не только циклами с операторами continue и break, но и блоками try-finally и даже goto, добиться перестройки с высокой точностью становится гораздо труднее, если вообще возможно.
Вместо этого куда разумнее просто накладывать на оригинальный код пользователя другой уровень управляющих структур, перебрасывая управление (с помощью условных переходов) и возвращая (с помощью return), как того требует ситуация. Воспроизведение и пауза. В Microsoft систематически тестировали тождественность асинхронных методов их синхронным эквивалентам, и мы убедились, что это очень надежный подход. Нет лучшего способа оставить в целости синхронную семантику в мире асинхронного кода, чем в первую очередь сохранить код, описывающий эту семантику.
Детали
Представленное описание слегка идеализированное — в перестройке применяется больше трюков, чем вы могли бы предполагать. Ниже перечислено еще несколько подвохов, с которыми приходится иметь дело компилятору.
Выражения goto Перестройка в варианте на рис. 2 на самом деле не подлежит компиляции, так как выражения goto (по крайней мере вC#) не позволяют перейти на метки, захороненные во вложенных структурах. Сама по себе это не проблема, так как компилятор генерирует промежуточный IL-код, а не исходный код, и не обременяется вложением. Но даже в IL-коде нельзя перейти в середину блока try, как это делается в моем примере. На самом деле происходит переход к началу блока try, осуществляется нормальное вхождение в него, а затем переключение и вновь переход.
Блоки finally При возврате из делегата возобновления из-за await выполнять блоки finally пока нельзя. Их нужно сохранять до той поры, когда будут выполняться оригинальные выражения return из пользовательского кода. Для управления этим генерируется булев флаг, уведомляющий, надо ли выполнять блоки finally, и они дополняются его проверкой.
Порядок оценки Await-выражение необязательно является первым аргументом метода или оператора; оно может оказаться в середине. Чтобы сохранить порядок оценки, все предшествующие аргументы нужно оценивать и сохранять до await, а после await — восстанавливать.
Помимо всего этого, существует несколько ограничений, обойти которые нельзя. Например, await-выражения не могут находиться внутри блока catch или finally, так как нам не известен хороший способ восстановления корректного контекста исключения после await.
Ждущие выполнения задач
Ждущий (awaiter), используемый в сгенерированном компилятором коде для реализации await-выражения, имеет весьма большую свободу в том, как он планирует делегат возобновления, т. е. выполнение оставшейся части асинхронного метода. Однако потребность в реализации собственного ждущего может быть вызвана только в очень сложных и специфических случаях. Типы Task и так обладают высокой гибкостью в планировании, потому что подчиняются концепции контекста планирования, который сам является подключаемым.
Контекст планирования (scheduling context) — одна из тех концепций, которые, вероятно, выглядели бы получше, если бы мы разрабатывали их с самого начала. На данном этапе это сплав нескольких существующих концепций, и мы решили не вносить еще большую мешанину, пытаясь ввести поверх них новую, унифицирующую концепцию. Рассмотрим эту идею на концептуальном уровне, а затем обсудим ее реализацию.
Смысл планирования асинхронных обратных вызовов для ожидаемых задач заключается в том, что вы хотите продолжить выполнение «с того места, где были». Это «где» я и называю контекстом планирования. Этот контекст связан с концепцией потоков; в каждом потоке (в большинстве случаев) есть один такой контекст. При выполнении в потоке вы можете запросить контекст планирования, в котором он работает, а получив его, можете планировать выполнение в нем чего-либо.
Поэтому асинхронный метод должен делать следующее, когда он ждет выполнения задачи.
- В приостановленном состоянии Запрашивать контекст планирования потока, в котором он выполняется.
- При возобновлении Планировать выполнение делегата возобновления в этом контексте планирования.
Почему это важно? Рассмотрим UI-поток. У него есть свой контекст планирования, который планирует новую работу, передавая ее через очередь сообщений обратно UI-потоку. Это означает, что, если ваш код выполняется в UI-потоке и ждет завершения задачи, то, когда будет готов результат задачи, оставшаяся часть асинхронного метода будет снова выполняться в UI-потоке. Таким образом, все то, что можно делать только в UI-потоке (манипулировать UI), вы по-прежнему можете делать после await-выражения; у вас не будет странного переключения потоков посреди вашего кода.
Другие контексты планирования являются многопоточными; в частности, стандартный пул потоков представлен одним контекстом планирования. Когда в нем планируется новая работа, она может быть выполнена в любом потоке из пула. Таким образом, асинхронный метод, начавший работать в пуле потоков, там же ее и продолжит, хотя при этом не исключена его «переброска» между разными потоками.
На практике единой концепции для контекста планирования нет. Грубо говоря, SynchronizationContext потока действует как его контекст планирования. Поэтому, если у потока есть один из SynchronizationContext (существующая концепция, которая может быть реализована пользователем), он и будет использоваться. А если нет, тогда применяется TaskScheduler потока (аналогичная концепция, введенная TPL). Если у него нет ни того, ни другого, будет задействован TaskScheduler по умолчанию — он планирует возобновления в стандартном пуле потоков.
Конечно, все это планирование создает издержки и влияет на производительность. Обычно в пользовательских сценариях ими можно пренебречь. Но иногда — особенно в библиотечном коде — это может вызвать проблемы. Взгляните:
async Task<int> GetAreaAsync()
{
return await GetXAsync() * await GetYAsync();
}
Это приводит к планированию обратно в контекст планирования дважды — после каждого await — только для того, что выполнить операцию в «правильном» потоке. Но кого волнует, в каком потоке вы выполняете умножения? В итоге вы попусту тратите время (чаще всего), и есть приемы, позволяющие избежать этого: вы можете обернуть Task, на котором вы ждете, в ожидаемый объект, отличный от Task, которому известно, как отключить поведение «планирования обратно» (schedule-back behavior) и просто осуществить возобновление в том потоке, который выполнил задачу, избежав переключения контекста и задержки, связанной с планированием:
async Task<int> GetAreaAsync()
{
return await GetXAsync().ConfigureAwait(
continueOnCapturedContext: false)
* await GetYAsync().ConfigureAwait(
continueOnCapturedContext: false);
}
Конечно, это выглядит не столь изящно, но этот прием весьма неплох в библиотечном коде, который иначе пострадал бы от издержек планирования.
Приступайте к «асинхронизации»
Теперь у вас должно быть некоторое понимание внутреннего устройства асинхронных методов. Вероятно, самое полезное для вас заключается в следующем:
- компилятор сохраняет смысл ваших управляющих структур, сохраняя сами структуры;
- асинхронные методы не планируют новые потоки — они позволяют выполнять вычисления в существующих потоках;
- когда задачи, выполнения которых вы ждали, завершаются, они возвращают вас туда, «где вы были».