Попробовал тут активно использовать 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‘ам).