Немного заметок про C# async
Попробовал тут активно использовать C# 5.0 async/await в pet-проекте (если его можно так назвать), заодно протестировать поддержку async/await в нашем продукте (гадкий feature intersection всегда создаёт пространство для багов). В качестве испытуемого выступал старый добрый ADO.NET, публичный API которого в .NET 4.5 просто вдоль и поперёк усеян async-версиями различных методов. Это был интересный опыт, поэтому спешу поделиться результатами:
- Наконец-то асинхронный код выглядит практически так же прямолинейно, как и синхронный. Наконец-то асинхронные API выглядят практически так, как должны были выглядеть с начала времён, без всякого булшита типа
BeginSmth()/EndSmth()/IAsyncResult, без вывернутого наизнанку кода event-based async pattern (ещё и с CLR-событиями, которые с большим трудом подвергаются хоть какой-нибудь композиции).
Единственный вопрос, который меня беспокоит - как асинхронный код должен сосуществовать со всем остальным codebase’ом. Ведь с монадами/комонадами иногда возникают проблемы на месте стыков в остальным миром или другими монадами - асинхронную операцию где-нибудь может потребоваться дождаться синхронно (что убивает всю суть async), а из монады IO вообще нельзя выбраться по-честному. В некоторых, асинхронных по своей природе, частях приложений - UI-событиях, например - проблем быть не должно, так часто всё сработает по принципу fire and forget, никому не нужно дожидаться Task‘ов. В противном случае оборачивание всего в Task<T> начинает расползаться по типам возвращаемых значений, например, распространяясь на методы, в которых никаких длительных операций и нет, но им доводится обращаться другими методами, уже обернутыми в Task<T> по той или иной причине.
- Как только становится нужен механизм отмены асинхронных операций, код начинает обрастать лапшой из-за повсеместной явной передачи/хранения
CancellationToken‘ов. Из-за этого страдают и дизайнеры API, вынужденные предоставлять перегрузки методов с токеном отмены и без. На фоне этого явно выигрывает типAsync<T>из F# async, который можно с некоторым приближением считать аналогомTask<T>, содержащим внутри себяCancellationToken. Любое асинхронное вычисление может “вытащить” токен изAsync<T>и без проблем подписать свой код на событие запроса отмены, совершенно незаметно для пользователя. Можно просто начать пользоваться cancellation’ом когда это понадобится и не заботиться что токен протащен между всеми операциями.
Но самая главная проблема - используя C# async забыть передать токен очень легко, что я успешно и допустил много раз. Самое обидное, что забытым в одном из нескольких мест токеном отмены ты ничего не ломаешь, ну остановится метод чуть позже, не велика беда. Обнаружить продолбанные токены крайне сложно, если только постоянно не тестируешь отмену. В моей задаче некоторый код должен был исполняться в бесконечном цикле периодически опрашивая web-сервисы, поэтому cancellation я использовал постоянно чтобы загасить приложение и только это позволило быстро локализовать пропущенные токены. В первом черновике этого поста тут было предложение “Кстати, отлов таких пропущенных передач CancellationToken‘ов - неплохая задача для ide tool.”, но я писал пост так долго, что успел реализовать такую фичу в нашем продукте, выглядит очень приятно:

- Плохо когда в языке можно писать конструкции, которые не имеют особого смысла - если так можно написать, то кто-нибудь (на самом деле дофига кто) обязательно так и напишет. Выражения с
await‘ами на хвостовых позициях, например, бесполезны и создают лишние “пустые” колбэки:
async Task CannotBeInCancelledState(CancellationToken token) {
await Task.Run(
cancellationToken: token,
action: async () => { await smth; });
}
Это тоже неплохая задачка для ide tools, тем более не так давно я написал детектор хвостовых позиций в C#. Удивительно, но такие хвостовые await‘ы регулярно встречаются в коде, который мне попадается на глаза в блогпостах или сообщениях от знакомых, да и сам пару раз такие бестолковые await‘ы написал.
- Если вы разрабатываете асинхронную апишечку с поддержкой cancellation’а, то подумайте о том, какое поведение может ожидать вызывающая сторона в случае запроса отмены. Относящаяся к SQL Server часть ADO.NET, например, иногда не парится и бросает
SqlExceptionв случае отмены асинхронных операций (когда именно - я так не смог понять, возможно, на ранних этапах отправления sql-запроса на сервер) иTask‘а переходит в состояниеFaulted. В другие моменты внутри бросается таки концептуально правильныйOperationCancelledExeception, который предназначен для того чтобы пронестиCancellationToken(который остановил операцию) по стеку вверх пока исключение не перехватитTask‘а, привязанная к этому токену, и не переведёт себя в состояниеCancelled. Это оно так должно работать, но не в ADO.NET (я осознанно не закрываю ресурсы, это пример):
var source = new CancellationTokenSource();
var connection = new SqlConnection(connectionString);
await connection.OpenAsync(source.Token);
// создаём команду с тяжёлым запросом и запускаем её асинхронно
var command = new SqlCommand("select count(*) from [?]", connection);
var task = command.ExecuteScalarAsync(source.Token);
Thread.Sleep(100); // немного ждём
source.Cancel(); // и гасим токен
await task.ContinueWith(t => {
Console.WriteLine("Status: {0}", t.Status);
try {
Console.WriteLine("Result: {0}", t.Result);
}
catch (AggregateException exc) {
var cancelled =
exc.InnerExceptions.OfType<OperationCanceledException>().First();
Console.WriteLine("== source.Token? {0}",
cancelled.CancellationToken == source.Token);
Console.WriteLine("== CancellationToken.None? {0}",
cancelled.CancellationToken == CancellationToken.None);
}
});
Как вы можете догадываться, результатом будет:
Cancelled
== source.Token? False
== CancellationToken.None? True
Старайтесь сохранить оригинальный токен, это может положительно сказаться на нервных клетках потребителей вашего API, решивших проверять токены отмены идентичность.
-
Я совершенно не понимаю когда ожидать от async-метода или TPL-апишечки
AggregateException, а когда - нет, и должен ли я по этому поводу вообще париться. В моей задаче любое исключение просто журналируется и задача стартует заново через некоторое время, поэтому я не задавался глубоко этим вопросом. В Сети достаточно материала тематики исключений в асинхронном коде, но хочется знать чего ожидать без заглядывания в гугл. -
Очень хорошо, что сделали асинхронные лямбда-выражения и асинхронные анонимные делегаты, без них пришлось бы очень туго. Плохо, что теперь в языке есть противоречие - асинхронный код может быть анонимным, а код итераторов - только в методах. Можно было бы ввести модификатор
iterator(как в последнем VB.NET) и тем самым разрешить анонимные итераторы (тогда бы возникло другое противоречие - итераторы-методы можно было бы не аннотироватьiteratorиз соображений обратной совместимости, а лямбды было бы необходимо аннотировать). Видимо, анонимные итераторы не сделали чтобы не нарушать заявление Эрика Липперта, что анонимные методы и итераторы - это два сложных преобразования и незачем их разрешать смешивать вместе (async-преобразование, видимо, проще LOL). -
Ради Вселенной, не используете
async-методы, возвращающиеvoid, даже если абсолютно уверены, что никто не будет заинтересован в отслеживании окончания асинхронной операции. Такие методы разрешили в первую очередь из соображений обратной совместимости, чтобы асинхронные лямбды (думаю, что в первую очередь именно лямбды) можно было приводить к куче существующий типов делегатов видаEventHandler/Actionбез возвращаемых значений типаTask. Если же вы пишете async-метод и планируете его передавать в качестве таких типов-делегатов безTask, то лучше создатьvoid-перегрузку. Ходят слухи, что перед выпуском релиза .NET 4.5 было много разговоров насчёт вырезанияasyncvoidметодов из финального дизайна, но не сложилось.
Ещё один нюанс - из-за существования async void методов легко ошибиться при модульном тестировании async-кода, так как без возвращаемого типа Task ни один unit test фреймворк не может точно узнать, закончилось-ли на самом деле исполнение теста, и что совсем плохо - будет радостно сигналить пользователю зелёным тестом после первого await‘а (тоже отличная задача для ide tool, запланирована на следующую версию нашего продукта).
- Когда смотришь на цикл
while (await reader.ReadAsync()), то невольно задумываешься сколько поимеешь оверхеда от такой гранулярностиawait-per-итерация-цикла. Прелесть C#asyncв том, что на самом деле код всегоasync-метода может выполниться вовсе синхронно, на усмотрение того или иного API. Причём, в случае полностью синхронного исполнения,async-метод вообще никогда не будет превращаться в замыкание и выделять под себя память в куче, что просто замечательно (для замыканияasync-метода генерируется value-тип, который боксится при необходимости - первомawait‘е, который откажется выполниться синхронно). Это особо актуально системного API (WinRT) метро-составляющей Windows 8, в котором вообще все потенциально длительные операции сделаны асинхронными и совершенно не имеют обычных синхронных аналогов. Синхронностьawait‘ов позволяет иногда сводить отличие асинхронных методов от синхронных в плане производительности до пары-тройки выделений памяти на куче.
Однако есть и обратная сторона медали - в отличие от yield return итераторов, не всё тело async-метода является отложенным, а код как минимум до первого await‘а исполняется синхронно. Как вы можете догадываться, с описанной выше оптимизацией, становится практически непредсказуемым сколько длится синхронная составляющая исполнения async-метода, что может быть важно, когда вы вызываете async-метод без await‘а чтобы форкнуть исполнение, дожидаясь результата позже. Это лечится await Task.Yield(); в коде async-метода или оборачиванием в Task.Run() на стороне вызова. Однако опять же есть преимущества - например, нет проблем с отложенностью проверки входных аргументов как у итераторов.
Ещё одна приятная фича кодогенератора async-методов заключается в том, что по мере исполнения async-метода не требуемые более поля структуры-замыкания ссылочных типов заполняются ссылочным null. Это устраняет проблему, присутствующую в итераторах C# - итератор держит в замыкании все локальные переменные, даже если находится в таком состоянии, при котором обращений к тем или иным локальным переменным больше не могут произойти. Более того, итераторы всегда хранят в замыкании оригинальные значения параметров - они могут понадобиться, если итератору сделают второй раз GetEnumerator(). Таким образом, async-методы лишены проблемы удержания от сборки мусора своих локальных переменных (насколько я понял, компилятор C# не генерирует присвоений null при сборке с выключенными оптимизациями чтобы можно было отлаживаться).
-
Так же как C# 4.0
dynamic, всяasync-трансформация крепко прибита гвоздями (сотками, не меньше) к кучке типов из фреймворка, особенноSystem.Threading.Task. Мы собираем наш продукт компилятором C# 5.0, но не можем пользоватьсяasyncиз-за того, что продукт должен работать под .NET 3.5 (из-за поддержки VS вплоть до версии 2005 года) + у нас свой мудрёный подход к тредингу (свои пулы, диспатчеры и прочее). Хотелось бы видеть в языке гораздо менее завязанные на фреймворк фичи. -
Мой солюшен насчитывает всего лишь несколько тысяч строк, поэтому я не испытывал проблем с отладкой
async-лапши, но знакомые, использующиеasyncв больших проектах часто жалуются на потерю stack trace’а, не могут понять откуда пришли к тому или иному асинхронному вызову. Проблема концептуальная, но мне кажется что её можно было решить (научиться мгновенно собирать stack trace передawait‘ами, хотя бы в debug?). В остальном функциональность VS радует, всё дебажится без проблем (только step into иногда прыгает как сумасшедший поawait‘ам).