Немного заметок про 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 было много разговоров насчёт вырезанияasync
void
методов из финального дизайна, но не сложилось.
Ещё один нюанс - из-за существования 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
‘ам).