Привет! Представляю вашему вниманию перевод статьи «Async in C# and F# Asynchronous gotchas in C#» автора Tomas Petricek.
Еще в феврале я присутствовал на ежегодном саммите MVP — мероприятии, организованном Microsoft для MVP. Я воспользовался этой возможностью, чтобы посетить также Бостон и Нью-Йорк, сделать два выступления про F# и записать лекцию Channel 9 о провайдерах типов. Несмотря на другие мероприятия (такие как посещения пабов, общение с другими людьми про F# и долгий сон по утрам), мне также удалось провести несколько обсуждений.
Одним обсуждением (из тех, что не под NDA) была беседа Async Clinic о новых ключевых словах в C# 5.0 — async и await. Люциан (Lucian) и Стивен (Stephen) говорили о распространенных проблемах, с которыми сталкиваются разработчики C# при написании асинхронных программ. В этом посте я рассмотрю некоторые проблемы с точки зрения F#. Разговор был довольно оживленным, и кто-то описал реакцию аудитории F # следующим образом:
(Когда MVP, пишущие на F#, видят примеры кода C#, они хихикают, как девочки)
Почему так происходит? Оказывается, что многие из распространенных ошибок невозможны (или гораздо менее вероятны) при использовании асинхронной модели F# (которая появилась в версии F# 1.9.2.7, выпущенной в 2007 году и поставлявшейся с Visual Studio 2008).
Подводный камень #1: Async не работает асинхронно
Давайте сразу перейдем к первому сложному аспекту модели асинхронного программирования на C #. Посмотрите на следующий пример и попытайтесь представить, в каком порядке будут напечатаны строки (я не смог найти точный код, показанный на выступлении, но я помню, как Люциан демонстрировал нечто подобное):
async Task WorkThenWait()
{
Thread.Sleep(1000);
Console.WriteLine("work");
await Task.Delay(1000);
}
void Demo()
{
var child = WorkThenWait();
Console.WriteLine("started");
child.Wait();
Console.WriteLine("completed");
}
Если вы думаете, что будет напечатано «started», «work» и «completed», — вы ошибаетесь. Код печатает «work», «started» и «completed», попробуйте сами! Автор хотел начать работу (вызвав WorkThenWait), а затем дождаться выполнения задачи. Проблема в том, что WorkThenWait начинается с выполнения каких-либо тяжелых вычислений (здесь Thread.Sleep), и только после этого использует await.
В C# первая часть кода в async-методе выполняется синхронно (в потоке вызывающей стороны). Вы можете исправить это, например, добавив в начале await Task.Yield().
Соответствующий код F#
В F# это не проблема. При написании асинхронного кода на F# весь код внутри блока async {… }отложен и запускается позже (когда вы явно запускаете его). Приведенный выше код C# соответствует следующему в F#:
let workThenWait() =
Thread.Sleep(1000)
printfn "work done"
async { do! Async.Sleep(1000) }
let demo() =
let work = workThenWait() |> Async.StartAsTask
printfn "started"
work.Wait()
printfn "completed"
Очевидно, что функция workThenWait не выполняет работу ( Thread.Sleep) как часть асинхронных вычислений, и что она будет выполняться при вызове функции (а не при запуске асинхронного рабочего процесса). Обычным шаблоном в F# является обёртывание всего тела функции в async. В F# вы бы написали следующее, что и работает, как ожидалось:
let workThenWait() = async
{
Thread.Sleep(1000)
printfn "work done"
do! Async.Sleep(1000)
}
Подводный камень #2: Игнорирование результатов
Вот еще одна проблема в модели асинхронного программирования на C# (эта статья взята непосредственно из слайдов Люциана). Угадайте, что произойдёт, когда вы запустите следующий асинхронный метод:
async Task Handler()
{
Console.WriteLine("Before");
Task.Delay(1000);
Console.WriteLine("After");
}
Вы ожидаете, что он напечатает «Before», подождёт 1 секунду, а затем напечатает «After»? Неправильно! Будут напечатаны оба сообщения сразу, без промежуточной задержки. Проблема состоит в том, что Task.Delay возвращает Task, а мы забыли подождать, пока она не завершится (используя await).
Соответствующий код F#
Опять-таки, вероятно, вы не столкнулись бы с этим в F#. Вы вполне можете написать код, который вызывает Async.Sleep и игнорирует возвращаемый Async:
let handler() = async
{
printfn "Before"
Async.Sleep(1000)
printfn "After"
}
Если вы вставите этот код в Visual Studio, MonoDevelop или Try F #, вы тут же получите предупреждение:
warning FS0020: This expression should have type unit, but has type Async‹unit›. Use ignore to discard the result of the expression, or let to bind the result to a name.
предупреждение FS0020: Это выражение должно иметь тип unit, но имеет тип Async‹unit›. Используйте, ignore, чтобы отбросить результат выражения или let, чтобы связать результат с именем.
Вы по-прежнему можете скомпилировать код и запустить его, но, если вы прочитаете предупреждение, то увидите, что выражение возвращает Async и вам нужно дождаться его результата, используя do!:
let handler() = async
{
printfn "Before"
do! Async.Sleep(1000)
printfn "After"
}
Подводный камень #3: Асинхронные методы, которые возвращают void
Довольно много времени в разговоре было посвящено асинхронным void-методам. Если вы пишете async void Foo() {… }, то компилятор C# генерирует метод, который возвращает void. Но под капотом он создает и запускает задачу. Это означает, что вы не можете предугадать, когда работа действительно будет выполнена.
В выступлении прозвучала такая рекомендация по использованию шаблона async void:
(Ради всего святого, прекратите использовать async void!)
Справедливости ради, нужно заметить, что асинхронные void-методы могут быть полезны при написании обработчиков событий. Обработчики событий должны возвращать void, и они часто начинают некоторую работу, которая продолжается в фоновом режиме. Но я не думаю, что это действительно полезно в мире MVVM, (хотя, безусловно, делает хорошие демо на конференциях).
Позвольте мне продемонстрировать проблему с помощью фрагмента из статьи MSDN Magazine об асинхронном программировании на C#:
async void ThrowExceptionAsync()
{
throw new InvalidOperationException();
}
public void CallThrowExceptionAsync()
{
try
{
ThrowExceptionAsync();
}
catch (Exception)
{
Console.WriteLine("Failed");
}
}
Думаете, этот код напечатает «Failed»? Я надеюсь, вы уже поняли стиль этой статьи…
Действительно, исключение не будет обработано, поскольку после запуска работы происходит немедленный выход из ThrowExceptionAsync, а исключение будет возбуждено где-то в фоновом потоке.
Соответствующий код F#
Так что, если вам не нужно использовать функции языка программирования, то, вероятно, лучше не включать эту функцию в первую очередь. F# не позволяет вам писать функции async void — если вы переносите тело функции в блок async {… }, тип возвращаемого значения будет Async. Если вы используете аннотации типов и требуете unit, вы получите несоответствие типов (type mismatch).
Вы можете написать код, который соответствует вышеупомянутому коду C#, используя Async.Start:
let throwExceptionAsync() = async {
raise <| new InvalidOperationException() }
let callThrowExceptionAsync() =
try
throwExceptionAsync()
|> Async.Start
with e ->
printfn "Failed"
Здесь исключение также не будет обработано. Но происходящее более очевидно, потому что мы должны написать Async.Start явно. Если мы этого не сделаем, мы получим предупреждение о том, что функция возвращает Async и мы игнорируем результат (так же, как в предыдущем разделе «Игнорирование результатов»).
Подводный камень #4: Асинхронные лямбда-функции, которые возвращают void
Ситуация ещё более усложняется, когда вы передаете асинхронную лямбда-функцию какому-либо методу в качестве делегата. В этом случае компилятор C # выводит тип метода из типа делегата. Если вы используете делегат Action (или аналогичный), то компилятор создает асинхронную void-функцию, которая запускает работу и возвращает void. Если вы используете делегат Func, компилятор генерирует функцию, которая возвращает Task.
Вот образец из слайдов Люциана. Когда завершится следующий (совершенно корректный) код — через одну секунду (после того, как все задачи завершили ожидание) или немедленно?
Parallel.For(0, 10, async i =>
{
await Task.Delay(1000);
});
Вы не сможете ответить на этот вопрос, если вы не знаете, что для For есть только такие перегрузки, которые принимают делегаты Action — и, таким образом, лямбда-функция всегда будет компилироваться как async void. Это также означает, что добавление какой-то (возможно, полезной) нагрузки будет ломающим изменением (breaking change).
Соответствующий код F#
Язык F# не имеет специальных «асинхронных лямбда-функций», но вы вполне можете написать лямбда-функцию, которая возвращает асинхронные вычисления. Такая функция будет возвращать тип Async, поэтому она не может быть передана в качестве аргумента методам, которые ожидают возвращающий void делегат. Следующий код не компилируется:
Parallel.For(0, 10, fun i -> async {
do! Async.Sleep(1000)
})
Сообщение об ошибке просто говорит о том, что тип функции int -> Asyncне совместим с делегатом Action(в F# должно быть int -> unit):
error FS0041: No overloads match for method For. The available overloads are shown below (or in the Error List window).
ошибка FS0041: не найдены перегрузки для метода For. Доступные перегрузки показаны ниже (или в окне списка ошибок).
Чтобы получить то же поведение, что и в приведенном выше коде C#, мы должны явно начать работу. Если вы хотите запустить асинхронную последовательность в фоновом режиме, это легко делается с помощью Async.Start (который принимает асинхронное вычисление, возвращающее unit, планирует его и возвращает unit):
Parallel.For(0, 10, fun i -> Async.Start(async {
do! Async.Sleep(1000)
}))
Вы, конечно, можете написать это, но увидеть, что происходит, довольно легко. Также нетрудно заметить, что мы тратим ресурсы впустую, так как особенность Parallel.For в том, что он выполняет вычисления с интенсивным использованием процессора (которые обычно являются синхронными функциями) параллельно.
Подводный камень #5: Вложенность задач
Я думаю, что Лукиан включил этот камень просто чтобы проверить умственные способности людей в аудитории, но вот он. Вопрос в том, подождёт ли следующий код 1 секунду между двумя выводами на консоль?
Console.WriteLine("Before");
await Task.Factory.StartNew(
async () => { await Task.Delay(1000); });
Console.WriteLine("After");
Совершенно неожиданно, но между этими выводами нет задержки. Как это возможно? Метод StartNew принимает делегат и возвращает Task где T — тип, возвращаемый делегатом. В нашем случае делегат возвращает Task, поэтому в результате мы получаем Task. await ожидает только завершения внешней задачи (которая немедленно возвращает внутреннюю задачу), при этом внутренняя задача игнорируется.
В C# это можно исправить, используя Task.Run вместо StartNew (или удалив async/await в лямбда-функции).
Можно ли написать что-то подобное в F #? Мы можем создать задачу, которая будет возвращать Async, используя функцию Task.Factory.StartNew и лямбда-функцию, которая возвращает асинхронный блок. Чтобы дождаться выполнения задачи, нам нужно будет преобразовать ее в асинхронное выполнение, используя Async.AwaitTask. Это означает, что мы получим Async<Async>:
async {
do! Task.Factory.StartNew(fun () -> async {
do! Async.Sleep(1000) }) |> Async.AwaitTask }
Опять-таки, этот код не компилируется. Проблема в том, что ключевое слово do! требует с правой стороны Async, но в действительности получает Async<Async>. Другими словами, мы не можем просто игнорировать результат. Нам нужно что-то с этим сделать явно
(для воспроизведения поведения C# можно использовать Async.Ignore). Сообщение об ошибке, возможно, не такое понятное, как предыдущие, но даёт общее представление:
error FS0001: This expression was expected to have type Async‹unit› but here has type unit
ошибка FS0001: Ожидается выражение типа Async‹unit›, присутствует тип unit
Подводный камень #6: Асинхронность не работает
Вот еще один проблемный фрагмент кода со слайда Люциана. На этот раз проблема довольно проста. Следующий фрагмент определяет асинхронный метод FooAsync и вызывает его из Handler, но код не выполняется асинхронно:
async Task FooAsync()
{
await Task.Delay(1000);
}
void Handler()
{
FooAsync().Wait();
}
Определить проблему несложно — мы вызываем FooAsync().Wait(). Это означает, что мы создаем задачу, а затем, используя Wait, блокируем программу до её завершения. Проблему решает простое удаление Wait, потому что мы просто хотим запустить задачу.
Этот же код можно написать на F#, но асинхронные рабочие процессы не используют задачи .NET (изначально предназначенные для вычислений с привязкой к ЦП), а вместо этого используют тип F# Async, который не укомплектован Wait. Это означает, что вы должны написать:
let fooAsync() = async {
do! Async.Sleep(1000) }
let handler() =
fooAsync() |> Async.RunSynchronously
Конечно, такой код можно написать и случайно, но, если вы столкнулись с проблемой неработающей асинхронности , вы легко заметите, что код вызывает RunSynchronously, поэтому работа выполняется — как и следует из названия — синхронно .
Резюме
В этой статье я рассмотрел шесть случаев, в которых модель асинхронного программирования в C# ведёт себя неожиданным образом. Большинство из них основавыются на беседе Люциана и Стивена на саммите MVP, поэтому спасибо им обоим за интересный список распространённых ловушек!
Для F# я пытался найти ближайшие соответствующие фрагменты кода, используя асинхронные рабочие процессы. В большинстве случаев компилятор F# выдает предупреждение или ошибку — либо модель программирования не имеет (прямого) способа выразить тот же код. Я думаю, это подтверждает утверждение, которое я сделал в предыдущем посте блога: «модель программирования F# определенно кажется более подходящей для функциональных (декларативных) языков программирования. Я также думаю, что она облегчает рассуждения о том, что происходит».
Наконец, эту статью не следует понимать как разрушительную критику асинхронности в C# :-). Я полностью понимаю, почему дизайн C# следует тем принципам, которым он следует — для C# имеет смысл использовать Task (вместо отдельных Async), что влечёт за собой ряд последствий. И я могу понять причины других решений — это, вероятно, лучший способ интеграции асинхронного программирования в C#. Но в то же время я думаю, что F# справляется лучше — отчасти из-за способности к компоновке, но, что более важно, из-за крутых дополнений, таких как агенты F#. Кроме того, у асинхронности в F# тоже есть свои проблемы (самая распространенная ошибка — хвостовые рекурсивные функции должны использоваться return! вместо do!, чтобы избегать утечек), но это тема отдельной статьи для блога.
P.S. От переводчика. Статья написана в 2013 году, но она показалась мне достаточно интересной и актуальной, чтобы перевести её на русский. Это мой первый пост на Хабре, поэтому не пинайте сильно.
Автор: Miamy