C#和F#中的异步。C#中的异步陷阱

哈Ha!我提请您注意Tomas Petricek的文章“ C#中的异步和C#中的F#异步陷阱”的翻译。



早在2月,我参加了年度MVP峰会,该峰会由Microsoft为MVP举办。我也借此机会访问了波士顿和纽约,在F#上进行了两次演讲,并记录了Channel 9关于类型提供者的演讲。尽管进行了其他活动(例如,参观酒吧,与其他人聊天并讨论F#,以及早上打ps睡),但我也能进行一些讨论。



图片



讨论(不是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:异步不能异步工作



让我们直接跳到C#异步编程模型的第一个棘手的方面。看下面的示例,尝试想象以什么顺序打印行(我找不到讲话中显示的确切代码,但我记得Lucian演示了类似的内容):



  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");
  }


如果您认为将打印“开始”,“工作”和“完成”,则您是错误的。该代码将打印“工作”,“开始”和“完成”,请自己尝试!作者想开始工作(通过调用WorkThenWait),然后等待任务完成。问题在于,WorkThenWait首先进行大量的计算(此处为Thread.Sleep),然后才使用await。



在C#中,异步方法中的第一段代码是同步运行的(在调用者的线程上)。您可以解决此问题,例如,通过在开头添加await Task.Yield()。



对应的F#代码



在F#中,这不是问题。在F#中编写异步代码时,异步{…}块中的所有代码都将延迟并稍后运行(在显式运行时)。上面的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#中的常见模式是将函数的整个主体包装为异步形式。在F#中,您将编写以下代码,该代码可以正常工作:



let workThenWait() = async
{ 
    Thread.Sleep(1000)
    printfn "work done"
    do! Async.Sleep(1000) 
}
  


陷阱2:忽略结果



这是C#异步编程模型的另一个问题(本文直接摘自Lucian的幻灯片)。猜猜当您运行以下异步方法时会发生什么:



async Task Handler() 
{
   Console.WriteLine("Before");
   Task.Delay(1000);
   Console.WriteLine("After");
}
 


您希望它打印“之前”,等待1秒钟,然后打印“之后”吗?错误!两条消息都将立即打印,而不会出现中间延迟。问题在于Task.Delay返回一个Task,而我们忘记等待它完成(使用await)。



对应的F#代码



同样,您可能不会在F#中遇到此问题。您可能会编写调用Async.Sleep并忽略返回的Async的代码:



let handler() = async
{
    printfn "Before"
    Async.Sleep(1000)
    printfn "After" 
}
 


如果将此代码粘贴到Visual Studio,MonoDevelop或Try F#中,您将立即收到警告:



警告FS0020:此表达式应具有类型单位,但具有类型Async ‹unit›。使用ignore放弃表达式的结果,或让结果绑定到名称。


警告FS0020:此表达式必须为unit类型,但为Async ‹unit›类型。使用ignore放弃表达式的结果,或让结果与名称关联。




您仍然可以编译代码并运行它,但是如果阅读警告,您将看到该表达式返回Async,您需要使用do!等待其结果:



let handler() = async 
{
   printfn "Before"
   do! Async.Sleep(1000)
   printfn "After" 
}
 


陷阱3:返回void的异步方法



大量的讨论都是针对异步void方法的。如果编写异步void Foo(){…},则C#编译器将生成一个返回void的方法。但是在后台,它创建并运行任务。这意味着您无法预测何时实际完成工作。



在演讲中,对使用异步无效模式提出了以下建议:(



图片

为天而生,请停止使用异步无效!)



公平地说,应该注意,异步无效方法可以在编写事件处理程序时很有用。事件处理程序必须返回void,并且它们通常会开始一些在后台继续的工作。但是我认为它在MVVM世界中并不是真正有用的(尽管它在会议上确实做得很好)。



让我用有关C#异步编程MSDN杂志文章中的一个片段来演示该问题



async void ThrowExceptionAsync() 
{
    throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() 
{
    try 
    {
        ThrowExceptionAsync();
    } 
    catch (Exception) 
    {
        Console.WriteLine("Failed");
    }
}
 


您是否认为此代码将显示“失败”?我希望您已经理解了本文的样式...

的确,不会处理异常,因为在开始工作之后,ThrowExceptionAsync将立即退出,并且该异常将被抛出在后台线程中。



对应的F#代码



因此,如果您不需要使用编程语言的功能,那么最好一开始就不要包含该功能。F#不允许您编写异步void函数-如果将函数的主体包装在异步{…}块中,则返回类型将为异步。如果使用类型注释并需要一个单位,则会出现类型不匹配的情况。



您可以使用Async.Start编写与上述C#代码匹配的代码:



let throwExceptionAsync() = async {
    raise <| new InvalidOperationException()  }

let callThrowExceptionAsync() = 
  try
     throwExceptionAsync()
     |> Async.Start
   with e ->
     printfn "Failed"


此处也不会处理该异常。但是发生的事情更加明显,因为我们必须显式地编写Async.Start。如果不这样做,则会收到一条警告,提示该函数正在返回异步,并且我们将忽略结果(就像上一节“忽略结果”中一样)。



陷阱4:返回空值的异步lambda函数



当您将异步lambda函数作为委托传递给方法时,情况变得更加复杂。在这种情况下,C#编译器从委托的类型推断出方法的类型。如果使用Action委托(或类似委托),则编译器将创建一个异步void函数,该函数启动作业并返回void。如果使用Func委托,则编译器将生成一个返回Task的函数。



这是Lucian幻灯片的示例。下一个(完全正确)的代码什么时候结束-一秒(在所有任务完成等待之后)还是立即完成?



Parallel.For(0, 10, async i => 
{
    await Task.Delay(1000);
});


除非您知道For For Accept动作委托只有重载,否则您将无法回答此问题-因此lambda始终将作为异步void进行编译。这也意味着增加一些负载(可能是有效负载)将是一项重大变化。



对应的F#代码



F#没有特殊的“异步lambda函数”,但是您可以编写一个返回异步计算的lambda函数。这样的函数将返回Async,因此不能将其作为参数传递给需要返回空值的委托的方法。以下代码无法编译:



Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000) 
})


错误消息只是说函数类型int-> Async与Action委托不兼容(在F#中,它应该是int-> unit):



错误FS0041:方法For没有重载匹配。可用的过载如下所示(或在“错误列表”窗口中)。


错误FS0041:找不到For方法的重载。可用的过载显示在下面(或在错误列表框中)。




为了获得与上述C#代码相同的行为,我们必须显式启动。如果要在后台运行异步序列,可以使用Async.Start轻松完成(使用异步计算返回一个单位,对其进行调度并返回一个单位):



Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000) 
}))


您当然可以编写此代码,但是很容易看到发生了什么。还很容易看出我们在浪费资源,因为Parallel.For的独特之处在于它并行执行CPU密集型计算(通常是同步功能)。



陷阱5:嵌套任务



我认为Lucian包括了这块石头,只是为了测试观众中人们的智力,但这就是事实。问题是,下面的代码是否会在控制台的两个插针之间等待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(或通过在lambda函数中删除async / await)来解决此问题。



你能在F#中写这样的东西吗?我们可以使用Task.Factory.StartNew函数和返回异步块的lambda函数创建一个将返回Async的任务。为了等待任务完成,我们需要使用Async.AwaitTask将其转换为异步执行。这意味着我们将获得Async <Async>:



async {
  do! Task.Factory.StartNew(fun () -> async { 
    do! Async.Sleep(1000) }) |> Async.AwaitTask }


同样,此代码不会编译。问题是该做!需要右侧的Async,但实际上收到Async <Async>。换句话说,我们不能仅仅忽略结果。我们需要对此做一些明确的事情

(您可以使用Async.Ignore来重现C#行为)。该错误消息可能不如先前的消息清晰,但给出了一个大致的思路:



错误FS0001:该表达式的类型应为Async ‹unit›,但此处的类型为unit


错误FS0001:预期异步表达式'unit',存在单元类型


陷阱6:异步无效



这是Lucian幻灯片中另一个有问题的代码。这次,问题很简单。以下代码段定义了异步FooAsync方法并从Handler调用它,但是代码不是异步执行的:



async Task FooAsync() 
{
    await Task.Delay(1000);
}
void Handler() 
{
    FooAsync().Wait();
}


找出问题很容易-我们称之为FooAsync()。Wait()。这意味着我们创建一个任务,然后使用“等待”来阻止程序,直到程序完成。只需删除Wait就可以解决问题,因为我们只想启动任务。



您可以在F#中编写相同的代码,但是异步工作流不使用.NET任务(最初是为CPU绑定计算而设计的),而是使用F#异步类型,该类型未与Wait捆绑在一起。这意味着您必须编写:



let fooAsync() = async {
    do! Async.Sleep(1000) }
let handler() = 
    fooAsync() |> Async.RunSynchronously


当然,这样的代码可能是偶然编写的,但是如果您遇到异步中断的问题,您会很容易注意到该代码调用RunSynchronously,因此工作是同步完成的-顾名思义-



概要



在本文中,我研究了六种情况,其中C#中的异步编程模型的行为异常。他们中的大多数都是基于Lucian和Stephen在MVP峰会上的谈话,因此,感谢他们俩提供了一个有趣的常见陷阱!



对于F#,我尝试使用异步工作流找到最接近的相关代码段。在大多数情况下,F#编译器将发出警告或错误-或编程模型没有(直接)方式来表达相同的代码。我认为这证实了我在之前的博客文章中发表的声明:“ F#编程模型显然似乎更适合于功能(声明性)编程语言。我还认为,这样可以更轻松地推断发生了什么。”



最后,本文不应理解为对C#:-)中异步的破坏性批评。我完全理解为什么C#的设计遵循其遵循的原理-对于C#,使用Task(而不是单独的Async)是有意义的,这会带来很多后果。而且我可以理解其他解决方案的原因-这可能是在C#中集成异步编程的最佳方法。但是同时,我认为F#的工作更好-部分是由于其合成能力,更重要的是因为F#agent之类的出色附加组件。此外,F#中的异步也有问题(最常见的错误是应该使用return尾部递归函数,而不是do !,以避免泄漏),但这是另一篇博客文章的主题。



PS来自翻译。该文章写于2013年,但在我看来,它很有趣且相关,足以翻译成俄语。这是我在哈布雷(Habré)上的第一篇文章,所以不要用力踢。



All Articles