了解 F# 异步编程

22

我有点了解F#中异步编程的语法。例如:

let downloadUrl(url:string) = async { 
  let req = HttpWebRequest.Create(url)
  // Run operation asynchronously
  let! resp = req.AsyncGetResponse()
  let stream = resp.GetResponseStream()
  // Dispose 'StreamReader' when completed
  use reader = new StreamReader(stream)
  // Run asynchronously and then return the result
  return! reader.AsyncReadToEnd() }

在F#专家书籍(以及许多其他来源)中,他们说:

let! var = expr 简单地意味着“执行异步操作 expr 并在操作完成时将结果绑定到 var。然后继续通过执行计算体的其余部分来执行。”

我也知道,当执行异步操作时会创建一个新线程。我最初的理解是,在异步操作之后有两个并行的线程,一个执行 I/O,另一个同时继续执行异步体。

但在这个例子中,我感到困惑:

  let! resp = req.AsyncGetResponse()
  let stream = resp.GetResponseStream()
如果 resp 还没有开始,异步体中的线程想要 GetResponseStream 会发生什么?这可能会导致错误吗?
也许我的最初理解是错误的。F# 专家书中引用的句子实际上意味着“创建一个新线程,挂起当前线程,在新线程完成后唤醒主线程并继续”,但在这种情况下我不认为我们能节省任何时间。
在最初的理解中,时间是通过在一个异步块中有多个独立的IO操作,从而可以在彼此干扰的情况下同时完成而节省的。但是在这里,如果我没有得到响应,我就不能创建流;只有当我拥有流时,我才能开始读取流。哪里节约了时间?

我写的一篇博客可能会有所帮助:http://blogs.lessthandot.com/index.php/DesktopDev/MSTech/f-asynchronous-workflows - ChaosPandion
谢谢ChaosPandion!不过我的C#知识是基于C++和几个.Net集合的... - Yin Zhu
这就是为什么我把它发表为评论的原因。希望Tomas能够出现。 - ChaosPandion
4个回答

24
在这个例子中,"async" 不是关于并发或节省时间的问题,而是提供了一个良好的编程模型,避免了阻塞(也就是浪费)线程。
如果使用其他编程语言,通常有两种选择:
你可以使用同步方法,通常会阻塞。缺点是线程被消耗掉了,在等待磁盘或网络I/O等操作时不会执行任何有用的工作。优点是代码很简单(正常的代码)。
你可以使用回调异步调用,并在操作完成时获得通知。优点是你不会阻塞线程(这些线程可以被返回到线程池中,在操作完成后会使用新的线程池线程来回调你)。缺点是一个简单的代码块被分成许多回调方法或lambda,并且很快变得非常复杂,难以在回调之间维护状态/控制流/异常处理。
所以你左右为难;要么放弃简单的编程模型,要么浪费线程。
F#模型让你兼得两全;你不会阻塞线程,但是保持直接的编程模型。像let!这样的构造使你能够在异步块中“跳线程”,因此在像下面这样的代码中:
Blah1()
let! x = AsyncOp()
Blah2()
Blah1 可能在 ThreadPool 线程 #13 上运行,但后来 AsyncOp 会将该线程释放回 ThreadPool。当 AsyncOp 完成时,代码的其余部分将在可用的线程上重新启动(可能是 ThreadPool 线程 #20),将 x 绑定到结果,然后运行 Blah2。在简单的客户端应用程序中,这很少有影响(除了确保您不阻塞 UI 线程),但在进行 I/O 的服务器应用程序中(其中线程通常是宝贵的资源 - 线程开销大,不能浪费它们来阻塞),非阻塞式 I/O 通常是实现应用程序扩展性的唯一方法。F#使您能够编写非阻塞 I/O,而不必使程序降级为一堆绕弯子的回调代码。

另请参见

使用异步工作流并行化的最佳实践

如何在 F# 中进行链接回调?

http://cs.hubfs.net/forums/thread/8262.aspx


10

我认为理解异步工作流最重要的事情是,它们与以F#(或C#)编写的普通代码一样是顺序执行的。你有一些let绑定按照通常的顺序进行评估和一些可能具有副作用的表达式。实际上,异步工作流通常看起来更像命令式代码。

异步工作流的第二个重要方面是它们是非阻塞的。这意味着您可以执行某些操作以某种非标准方式并且在执行过程中不会阻塞线程。(通常,在F#计算表达式中,let!总是表示存在一些 非标准行为,可能是在Maybe monad中产生失败而不生成结果,也可能是对于异步工作流的非阻塞执行)。

从技术上讲,非阻塞执行是通过注册某些回调函数来实现的,当操作完成时将触发该回调函数。一个相对简单的例子是等待一定时间的异步工作流 - 这可以使用Timer实现而不会阻塞任何线程(源自我书中第13章的示例,源代码在此处可用):

// Primitive that delays the workflow
let Sleep(time) = 
  // 'FromContinuations' is the basic primitive for creating workflows
  Async.FromContinuations(fun (cont, econt, ccont) ->
    // This code is called when workflow (this operation) is executed
    let tmr = new System.Timers.Timer(time, AutoReset=false)
    tmr.Elapsed.Add(fun _ -> 
      // Run the rest of the computation
      cont())
    tmr.Start() )

使用 F# 异步工作流还有其他几种实现并行或并发编程的方法,但这些只是在 F# 工作流或构建在其上的库中更复杂的用法——它们利用了先前描述的非阻塞行为。

  • 您可以使用 StartChild 在后台启动一个工作流程 - 该方法会给您一个正在运行的工作流程,您可以稍后使用 let! 在工作流程中等待完成,同时您可以继续做其他事情。这类似于 .NET 4.0 中的任务(Tasks),但它以异步方式运行,因此更适合 I/O 操作。

  • 您可以使用 Async.Parallel 创建多个工作流程,并等待它们全部完成(这对于数据并行操作非常棒)。这类似于 PLINQ,但是如果您执行一些 I/O 操作,则 async 更好。

  • 最后,您可以使用 MailboxProcessor,它允许您使用消息传递风格(Erlang 风格)编写并发应用程序。这是许多问题的线程的绝佳替代品。


2
我是MailboxProcessor的铁杆粉丝。一开始可能会有点棘手,但一旦你掌握了技巧,就能编写出令人惊叹的优雅代码。 - ChaosPandion

3
这并不是关于“节省时间”的问题。异步编程并不能让数据更快地到达。相反,它是为了简化并发的思维模型。
例如,在C#中,如果你想执行异步操作,你需要开始使用回调函数,并将本地状态传递给这些回调函数等等。对于像Expert F#中的一个简单操作,涉及两个异步操作,你需要编写三个看起来似乎分离的方法(初始化器和两个回调函数)。这掩盖了工作流的顺序性和概念上的线性特点:发出请求、读取流、打印结果。
相比之下,F#异步工作流代码使程序的排序非常清晰。通过查看一个代码块,您可以准确地知道发生了什么以及它们的顺序。您不需要追踪回调函数。
话虽如此,F#确实有机制可以帮助节省时间,如果有多个独立的异步操作正在进行中。例如,您可以同时启动多个异步工作流,它们将并行运行。但在单个异步工作流实例中,主要是为了简单、安全和可理解性:让您像处理C#风格的同步语句一样轻松地推断异步语句序列。

2
这是一个好问题。需要注意的是,async块中的多个语句不会并行运行。当异步请求挂起时,async块实际上会将处理器时间让给其他进程。因此,async块通常不会比等效的同步操作序列运行得更快,但它可以允许更多的工作完成。如果您想要并行运行多个语句,最好查看任务并行库(Task Parallel Library)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接