F#中的异步和并行设计模式(二):用事件触发来报告进度
在这篇文章中,我们将着眼于一个常用的异步设计模式,我叫它用事件触发来报告进度。在这篇文章后面,我们将使用这种设计模式从推特上抽样读取帖子流。
这是F#异步编程基础技术系列的第二部分,这里有些例子的代码是摘自F# JAOO 教程。
· 第一部分描述了F# 通过支持轻量级交互是一种怎样的并行和交互式语言,并且介绍了 并行CPU异步处理和并行I/O异步处理模式。
· 第二部分就是这篇文章。
· 第三部分描述了F# 中的轻量级代理、交互式代理、隔离式代理。
模式3:用事件触发来报告进度
让我们先来看看设计模式的本质的一个实例。下面,我们定义一个对象来协调一组asyncs的并行执行。每个工作在它结束时就报告结果,而不是等待结果集。
下面用黄色高亮将设计模式的本质标记出来了:
· 当前“同步的内容”是从对象开始方法的GUI线程抓取的。这是个句柄,它能让我在GUI上下文中运行代码及触发事件。这里定义了一个私有帮助函数来触发任意的F# 事件。严格来说,这不是必须的,但可以让代码更整洁。
· 定义一个或多个事件。这些事件以属性形式发布,如果这个对象将被.Net的其他语言引用的话就标记[<CLIEvent>]。
· 在有后台工作被启动的情况下,通过指定一个异步工作流来定义执行后台工作。Async.Start启动一个工作流的实例(当然Async.StartWithContinuations也会经常被作为替换来使用,本文下面的例子会用到)。这些事件会在后台工作执行的进展中适时地被触发。
下面是方法的示例在这篇文章中,我们将着眼于一个常用的异步设计模式,我叫它用事件触发来报告进度。在这篇文章后面,我们将使用这种设计模式从推特上抽样读取帖子流.
type AsyncWorker<'T>(jobs: seq<Async<'T>>) =
// This declares an F# event that we can raise
let jobCompleted =new Event<int * 'T>()
// Start an instance of the work
member x.Start() =
// Capture the synchronization context to allow us to raise events back on the GUI thread
let syncContext = SynchronizationContext.CaptureCurrent()
// Mark up the jobs with numbers
let jobs = jobs |> Seq.mapi (fun i job-> (job,i+1))
let work =
Async.Parallel
[ for (job,jobNumber) in jobs->
async {let! result = job
syncContext.RaiseEvent jobCompleted (jobNumber,result)
return result } ]
Async.Start(work |> Async.Ignore)
/// Raised when a particular job completes
member x.JobCompleted = jobCompleted.Publish
这里的代码使用两个在System.Threading.SynchronizationContext命名空间的帮助扩展方法,在这系列的文章中,我们会经常使用到。下面是方法的示例:
type SynchronizationContext with
/// A standard helper extension method to raise an event on the GUI thread
member syncContext.RaiseEvent (event: Event<_>) args =
syncContext.Post((fun _-> event.Trigger args),state=null)
/// A standard helper extension method to capture the current synchronization context.
/// If none is present, use a context that executes work in the thread pool.
static member CaptureCurrent () =
match SynchronizationContext.Current with
|null-> new SynchronizationContext()
| ctxt-> ctxt
现在你能用这个组件来监督CPU密集的异步线程集合的执行:
let rec fib i =if i <2 then 1 else fib (i-1) + fib (i-2)
let worker =
new AsyncWorker<_>( [ for i in 1 .. 100-> async {return fib (i %40) } ] )
worker.JobCompleted.Add(fun (jobNumber, result)->
printfn "job %d completed with result %A" jobNumber result)
worker.Start()
当运行时,每个工作完成后,程序都会报告进度:
job 1 completed with result 1
job 2 completed with result 2
...
job 39 completed with result 102334155
job 77 completed with result 39088169
job 79 completed with result 102334155
当然从后台进程获取结果报告有许多方法。 百分之九十的情况下,最简单的方法就如上面展示的那样:通过在GUI(或者ASP.NET页面加载)线程上触发.NET事件来报告结果。 这个技术完全隐藏了后台线程的使用,采用完全标准的任何.NET程序员都熟悉的.NET习俗。这确保了用来实现你并行编程的这项技术被适当的封装了。
报告I/O异步处理的进度
用事件触发来报告进度模式当然也能同I/O异步一同使用。例如,下面一系列的I/O任务:
open System.IO
open System.Net
open Microsoft.FSharp.Control.WebExtensions
/// Fetch the contents of a web page, asynchronously.
let httpAsync(url:string) =
async { let req = WebRequest.Create(url)
use! resp = req.AsyncGetResponse()
use stream = resp.GetResponseStream()
use reader = new StreamReader(stream)
let text = reader.ReadToEnd()
return text }
let urls =
[ "http://www.live.com";
"http://news.live.com";
"http://www.yahoo.com";
"http://news.yahoo.com";
"http://www.google.com";
"http://news.google.com"; ]
let jobs = [for urlin urls -> httpAsync url ]
let worker =new AsyncWorker<_>(jobs)
worker.JobCompleted.Add(fun (jobNumber, result)->
printfn "job %d completed with result %A" jobNumber result.Length)
worker.Start()
当代码运行,会逐步地报告结果,显示每个网页的长度:
job 5 completed with result 8521
job 6 completed with result 155767
job 3 completed with result 117778
job 1 completed with result 16490
job 4 completed with result 175186
job 2 completed with result 70362
一些工作会报告多种不同的事件
在这种设计模式中,我们用一个对象来封装和监控并行组合的异步线程的执行,其中的一个理由是它让通过进一步的事件来丰富(有更多)监督进度的API变得更简单。例如, 下面代码会在所有工作都完成以后,或者在任何一个工作中检测到错误时,或者在整个程序完成前被成功取消时触发额外的事件。下面高亮部分显示了事件的定义、事件的触发和事件的发布。
open System
open System.Threading
open System.IO
open Microsoft.FSharp.Control.WebExtensions
type AsyncWorker<'T>(jobs: seq<Async<'T>>) =
// Each of these lines declares an F# event that we can raise
let allCompleted = new Event<'T[]>()
let error = new Event<System.Exception>()
let canceled = new Event<System.OperationCanceledException>()
let jobCompleted = new Event<int * 'T>()
let cancellationCapability =new CancellationTokenSource()
/// Start an instance of the work
member x.Start() =
// Capture the synchronization context to allow us to raise events back on the GUI thread
let syncContext = SynchronizationContext.CaptureCurrent()
// Mark up the jobs with numbers
let jobs = jobs |> Seq.mapi (fun i job -> (job,i+1))
let work =
Async.Parallel
[ for (job,jobNumber) in jobs ->
async { let! result = job
syncContext.RaiseEvent jobCompleted (jobNumber,result)
return result } ]
Async.StartWithContinuations
( work,
(fun res->raiseEventOnGuiThread allCompleted res),
(fun exn->raiseEventOnGuiThread error exn),
(fun exn->raiseEventOnGuiThread canceled exn ),
cancellationCapability.Token)
member x.CancelAsync() =
cancellationCapability.Cancel()
/// Raised when a particular job completes
member x.JobCompleted = jobCompleted.Publish
/// Raised when all jobs complete
member x.AllCompleted = allCompleted.Publish
/// Raised when the composition is cancelled successfully
member x.Canceled = canceled.Publish
/// Raised when the composition exhibits an error
member x.Error = error.Publish
我们可以用通常的方法来使用这些额外的事件,例如:
let worker =new AsyncWorker<_>(jobs)
worker.JobCompleted.Add(fun (jobNumber, result)->
printfn "job %d completed with result %A" jobNumber result.Length)
worker.AllCompleted.Add(fun results->
printfn "all done, results = %A" results )
worker.Start()
这个监督异步工作流能支持取消操作,就像上面例子列出的。
Tweet Tweet, Tweet Tweet(一个WordPress的插件)
用事件触发来报告进度模式可以应用到几乎任何的后台处理组件来进行结果报告。下面的例子,我们将用这个模式来封装从后台读取的来自推特的文章数据流(参考推特 API页面)。这里例子需要一个推特的账号和密码。这个例子中只有一个事件被触发,当然这个例子可以扩展成其他情况下触发其他的事件。
F# JAOO 教程包含了这个例子的一个版本。
// F# Twitter Feed Sample using F# Async Programming and Event processing
#r"System.Web.dll"
#r"System.Windows.Forms.dll"
#r"System.Xml.dll"
open System
open System.Globalization
open System.IO
open System.Net
open System.Web
open System.Threading
open Microsoft.FSharp.Control.WebExtensions
/// A component which listens to tweets in the background and raises an
/// event each time a tweet is observed
type TwitterStreamSample(userName:string, password:string) =
let tweetEvent = new Event<_>()
let streamSampleUrl ="http://stream.twitter.com/1/statuses/sample.xml?delimited=length"
/// The cancellation condition
letmutable group =new CancellationTokenSource()
/// Start listening to a stream of tweets
member this.StartListening() =
/// The background process
// Capture the synchronization context to allow us to raise events back on the GUI thread
let syncContext = SynchronizationContext.CaptureCurrent()
let listener (syncContext: SynchronizationContext) =
async { let credentials = NetworkCredential(userName, password)
let req = WebRequest.Create(streamSampleUrl, Credentials=credentials)
use! resp = req.AsyncGetResponse()
use stream = resp.GetResponseStream()
use reader = new StreamReader(stream)
let atEnd = reader.EndOfStream
let rec loop() =
async {
let atEnd = reader.EndOfStream
if not atEnd then
let sizeLine = reader.ReadLine()
if String.IsNullOrEmpty sizeLine then return! loop() else
let size = int sizeLine
let buffer = Array.zeroCreate size
let _numRead = reader.ReadBlock(buffer,0,size)
let text =new System.String(buffer)
syncContext.RaiseEvent tweetEvent text
return! loop()
}
return! loop() }
Async.Start(listener, group.Token)
/// Stop listening to a stream of tweets
member this.StopListening() =
group.Cancel();
group <- new CancellationTokenSource()
/// Raised when the XML for a tweet arrives
member this.NewTweet = tweetEvent.Publish
每次从推特的标准采样信息流产生一个信息时会触发一个事件,然后提供该帖子的内容。我们可以用以下代码来监听这个数据流:
let userName ="..." // set Twitter user name here
let password ="..." // set Twitter user name here
let twitterStream =new TwitterStreamSample(userName, password)
twitterStream.NewTweet
|> Event.add (fun s-> printfn"%A" s)
twitterStream.StartListening()
twitterStream.StopListening()
当这段代码运行时,将打印出一个的原始XML信息流(非常地快)。参考推特API
来了解这信息流是怎样采样的。
如果你想解析这些信息,这里有一些示例代码实现了类似的功能(当然也请注意推特 API页面上的指导,例如,如果想建立一个高可靠性的系统,这些信息必须在处理前经常的被保存或进行排队)。
#r"System.Xml.dll"
#r"System.Xml.Linq.dll"
open System.Xml
open System.Xml.Linq
let xn (s:string) = XName.op_Implicit s
/// The results of the parsed tweet
type UserStatus =
{ UserName : string
ProfileImage : string
Status : string
StatusDate : DateTime }
/// Attempt to parse a tweet
let parseTweet (xml: string) =
let document = XDocument.Parse xml
let node = document.Root
if node.Element(xn"user") <>nullthen
Some { UserName = node.Element(xn"user").Element(xn"screen_name").Value;
ProfileImage = node.Element(xn"user").Element(xn"profile_image_url").Value;
Status = node.Element(xn"text").Value |> HttpUtility.HtmlDecode;
StatusDate = node.Element(xn"created_at").Value |> (fun msg->
DateTime.ParseExact(msg,"ddd MMM dd HH:mm:ss +0000 yyyy",
CultureInfo.CurrentCulture)); }
else
None
然后组合子编程能被用来对该数据流进行管道化处理:
twitterStream.NewTweet
|> Event.choose parseTweet
|> Event.add (fun s-> printfn"%A" s)
twitterStream.StartListening()
再从该数据流中进行统计:
let addToMultiMap key x multiMap =
let prev =match Map.tryFind key multiMapwith None -> [] | Some v-> v
Map.add x.UserName (x::prev) multiMap
/// An event which triggers on every 'n' triggers of the input event
let every n (ev:IEvent<_>) =
let out =new Event<_>()
let count = ref0
ev.Add (fun arg-> incr count;if !count % n =0then out.Trigger arg)
out.Publish
twitterStream.NewTweet
|> Event.choose parseTweet
// Build up the table of tweets indexed by user
|> Event.scan (fun z x-> addToMultiMap x.UserName x z) Map.empty
// Take every 20’th index
|> every 20
// Listen and display the average of #tweets/user
|> Event.add (fun s->
let avg = s |> Seq.averageBy (fun (KeyValue(_,d))-> float d.Length)
printfn "#users = %d, avg tweets = %g" s.Count avg)
twitterStream.StartListening()
这里的示例数据流根据用户来进行索引,显示每个用户的平均发言量,每成功解析20个发言就进行一次结果报告 #users = 19, avg tweets = 1.05263 #users = 39, avg tweets = 1.02564 #users = 59, avg tweets = 1.01695 #users = 79, avg tweets = 1.01266 #users = 99, avg tweets = 1.0101 #users = 118, avg tweets = 1.01695 #users = 138, avg tweets = 1.01449 #users = 158, avg tweets = 1.01266 #users = 178, avg tweets = 1.01124 #users = 198, avg tweets = 1.0101 #users = 218, avg tweets = 1.00917 #users = 237, avg tweets = 1.01266 #users = 257, avg tweets = 1.01167 #users = 277, avg tweets = 1.01083 #users = 297, avg tweets = 1.0101 #users = 317, avg tweets = 1.00946 #users = 337, avg tweets = 1.0089 #users = 357, avg tweets = 1.0084 #users = 377, avg tweets = 1.00796 #users = 396, avg tweets = 1.0101 #users = 416, avg tweets = 1.00962 #users = 435, avg tweets = 1.01149 #users = 455, avg tweets = 1.01099 #users = 474, avg tweets = 1.01266 #users = 494, avg tweets = 1.01215 #users = 514, avg tweets = 1.01167 #users = 534, avg tweets = 1.01124 #users = 554, avg tweets = 1.01083 #users = 574, avg tweets = 1.01045 #users = 594, avg tweets = 1.0101
通过稍微不同的解析,我们可以根据推特提供的采样信息流来显示那些发言超过一次的用户(包括他们的最后一次发言)。这里将在F# Interactive上交互式地执行,并且使用上篇文章的F# Interactive 数据表格片段试图:
open System.Drawing
open System.Windows.Forms
let form = new Form(Visible = true, Text = "A Simple F# Form", TopMost = true, Size = Size(600,600))
let data = new DataGridView(Dock = DockStyle.Fill, Text = "F# Programming is Fun!",
Font = new Font("Lucida Console",12.0f),
ForeColor = Color.DarkBlue)
form.Controls.Add(data)
data.DataSource <- [| (10,10,10) |]
data.Columns.[0].Width <- 200
data.Columns.[2].Width <- 500
twitterStream.NewTweet
|> Event.choose parseTweet
// Build up the table of tweets indexed by user
|> Event.scan (fun z x -> addToMultiMap x.UserName x z) Map.empty
// Take every 20’th index
|> every 20
// Listen and display those with more than one tweet
|> Event.add (fun s ->
let moreThanOneMessage = s |> Seq.filter (fun (KeyValue(_,d)) -> d.Length > 1)
data.DataSource <-
moreThanOneMessage
|> Seq.map (fun (KeyValue(user,d)) -> (user, d.Length, d.Head.Status))
|> Seq.filter (fun (_,n,_) -> n > 1)
|> Seq.sortBy (fun (_,n,_) -> -n)
|> Seq.toArray)
twitterStream.StartListening()
这里是一些示例结果:
注意:在上面的例子中,我们使用I/O中断来读取推特信息流。这么做有两个充足的理由:推特信息流是非常活跃的(有时候可能会有信息残留J),我们也可以假设在这许多的推特信息流中有许多未完成的链接,在这种情况只有一个链接,而在任何情况下推特都会限制每个账户监听采样信息流的次数。在下一篇文章中,我们将展示怎么做无中断的读取这种格式的XML信息流。
F# 做并行处理,C#/VB做GUI
用事件触发来报告进度模式在下面这种情况下非常有用,F#程序员实现基于某些输入的后台计算组件,C#或VB程序员使用这些组件。在这种情况下,事件的声明必须用[<CLIEvent>]标记来强调它们作为.NET事件出现(对C#或VB程序员而言)。在上面第二个例子中,你将使用 /// Raised when a particular job completes [<CLIEvent>] member x.JobCompleted = jobCompleted.Publish /// Raised when all jobs complete [<CLIEvent>] member x.AllCompleted = allCompleted.Publish /// Raised when the composition is cancelled successfully [<CLIEvent>] member x.Canceled = canceled.Publish /// Raised when the composition exhibits an error [<CLIEvent>] member x.Error = error.Publish
该模式的限制
用事件触发来报告进度模式是假设在以下情况下的:一个并行处理组件运行在一个GUI应用程序(如Windows Forms)、服务端应用程序(如ASP.NET)或者一些可以触发事件后发回给监听端的程序。这里可以通过调整这个模式来用其他方式触发事件,例如, 发送一个消息到MailBoxProcessor或简单地记录它们。然而要注意的是这个模式还有一个假设:已经有一些主线程或者监听端准备好在任意时刻监听这些事件并且合理地对它们进行排队。
用事件触发来报告进度模式也假设这个封装的对象有能力获取GUI线程的同步内容,正常情况是隐式地(就如上面的例子)。 这通常是一个合理的假设。另外,这些同步内容也可以作为一个显式的参数给出,虽然这不是一个非常普遍的.NET编程习惯。
对那些熟悉IObservable接口(.NET4.0中新增)的程序员,可以考虑用TwitterStreamSample类型来实现这个接口。 然而对于事件的根源,没必要了解那么多。例如, 在将来TwitterStreamSample或许会提供多个事件:当错误发生时报告自动重新连接,或报告中断与延迟。在这情况下,简单地触发.NET事件就足够了,部分地确保你的对象对大部分.NET程序员 来说是熟悉的。在F# 中,所有声明的IEvent<_>值都是自动实现IObservable接口的,所以能直接的用于可观察的组合程序。
结论
用事件触发来报告进度是一种用来封装后台并行处理,同时能够报告结果和进度的强大和优雅的方法。
从表面看,AsyncWorker对象就像单线程一样地效率。 假使你输入的asyncs是隔离的,这意味着组件不会使你程序的其余部分处于多线程的竞争条件下。所有的Javascript框架、ASP.NET框架和GUI框架(如Windows Forms)的用户知道这些框架的单线程形式既是一种祝福也是一种诅咒——你获得了简单(没有数据冲突),但是在.NET编程中并行和异步编程比较困难,I/O和繁重的CPU计算被卸载到了后台线程。 上面的设计模式让你从两边都做到最好:你获得了独立的、能协作的、能“交流的”后台处理组件(包括那些并行处理和I/O),同时又达到了使你大部分代码像单线程GUI编程那样简单。这些组件能被泛型化和重复使用,就像上面展示的那样。这使得它们适合于独立的单元测试。
在以后的文章中,我们将讨论另外的关于F# 异步的并行和交互式编程的一些设计话题,其中包括:
Ø 定义轻量级异步代理
Ø 用异步创建.NET任务
Ø 用异步创建.NET APM模式
Ø 异步取消
原文链接:http://blogs.msdn.com/b/dsyme/archive/2010/01/10/async-and-parallel-design-patterns-in-f-reporting-progress-with-events-plus-twitter-sample.aspx
你也可以联系F#QQ群:61436709