Асинхронность в C# и F#. Подводные камни асинхронности в C #

в 11:48, , рубрики: C#, F#, асинхронность, перевод, Программирование

Привет! Представляю вашему вниманию перевод статьи «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<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 году, но она показалась мне достаточно интересной и актуальной, чтобы перевести её на русский. Это мой первый пост на Хабре, поэтому не пинайте сильно.

Автор: Miamy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js