Хабрахабр

[Из песочницы] Асинхронность в C# и F#. Подводные камни асинхронности в C #

Привет, Хабр! Представляю вашему вниманию перевод статьи «Async in C# and F# Asynchronous gotchas in C#» автора Tomas Petricek.

Еще в феврале я присутствовал на ежегодном саммите MVP — мероприятии, организованном Microsoft для MVP. Я воспользовался этой возможностью, чтобы посетить также Бостон и Нью-Йорк, сделать два выступления про F# и записать лекцию Channel 9 о провайдерах типов. Несмотря на другие мероприятия (такие как посещения пабов, общение с другими людьми про F# и долгий сон по утрам), мне также удалось провести несколько обсуждений.
image

Одним обсуждением (из тех, что не под NDA) была беседа Async Clinic о новых ключевых словах в C# 5.0 — async и await. Люциан (Lucian) и Стивен (Stephen) говорили о распространенных проблемах, с которыми сталкиваются разработчики C# при написании асинхронных программ. В этом посте я рассмотрю некоторые проблемы с точки зрения F#. Разговор был довольно оживленным, и кто-то описал реакцию аудитории F # следующим образом:

image
(Когда 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&#8249unit&#8250. Use ignore to discard the result of the expression, or let to bind the result to a name.

предупреждение FS0020: Это выражение должно иметь тип unit, но имеет тип Async&#8249unit&#8250. Используйте, 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:

image
(Ради всего святого, прекратите использовать 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. Другими словами, мы не можем просто игнорировать результат. Нам нужно что-то с этим сделать явно
(для воспроизведения поведения C# можно использовать Async.Ignore). Сообщение об ошибке, возможно, не такое понятное, как предыдущие, но даёт общее представление:

error FS0001: This expression was expected to have type Async&#8249unit&#8250 but here has type unit

ошибка FS0001: Ожидается выражение типа Async&#8249unit&#8250, присутствует тип 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 году, но она показалась мне достаточно интересной и актуальной, чтобы перевести её на русский. Это мой первый пост на Хабре, поэтому не пинайте сильно.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть