F#中的异步和并行设计模式(三):代理
在这个系列的第三部分,我们解释了F#中的轻量级代理的和交互式代理,并且看过了一些与之相关的典型的设计模式,包括内部隔离状态。
- 第二部分描述了一种通过触发事件提交部分结果的模式。
模式4——你的第一个代理
让我们看看你的第一个异步代理。
typeAgent<'T> = MailboxProcessor<'T>
let agent =
Agent.Start(fun inbox ->
async { while true do
let! msg = inbox.Receive()
printfn "got message '%s'" msg } )
这个代理循环地执行异步等待消息,然后打印出每一个接收到的消息。在这种情况下,每条消息都是一个字符串,这个代理的类型就是:
agent: Agent<string>
我们可以像下面示例一样发送消息给这个代理:
agent.Post"hello!"
然后适时地打印出:
got message 'hello!'
以及下面示例的多条消息打印:
fori in 1 .. 10000 do
agent.Post(sprintf "message %d" i)
这里的确会打印出10000条消息。
你可以将代理当作是隐藏了一个消息队列(或者通道),当消息到达时在队列里添加一个要执行的反应操作。一个代理通常拥有一个异步等待消息然后处理这些消息的异步循环,在上面的那个例子中,代理执行的代码是“while”循环。
很多读者都对代理很熟悉。Erlang当然也是建立在代理上的(有时称为进程)。就在不久前,从.NET演化出来的名为Axum的试验性语言突出显示了基于代理编程的重要性。Axum对F#在代理设计上产生了很大的影响,反之亦然。其它拥有轻量级线程的语言也强调了基于代理的分解和设计。
上面的示例是以类型缩写“Agent”开始的,它映射了F#类库中实现了内存代理的类“MailboxProcessor”。你也可以用那个长的名字,如果你想,但是我还是推荐使用更短的那个。
你最开始的100000个代理
代理都是轻量级的,因为他们都是基于F#异步编程的。例如,你可以在一个.NET进程中定义成百上千的代理,甚至更多。例如,让我们定义100000个简单的代理:
letagents =
[ for i in 0 .. 100000 ->
Agent.Start(fun inbox ->
async { while true do
let! msg = inbox.Receive()
if i % 10000 = 0 then
printfn "agent %d got message '%s'" i msg } ) ]
你可以如下例所示向每个代理发送一条消息:
for agentin agents do
agent.Post "ping!"
每处理完10000个代理的消息后就发出报告。这些代理集很快的处理完这些消息,在几秒钟之内。代理和在内存中的消息处理会非常快。
很明显,代理不直接映射到.NET线程——你不能在单独的应用程序中创建100000个线程(在32位系统中,甚至1000个就已经太多了)。相反,当一个代理正在等待一条消息的输入时,它表现为一个简单的回调,包括几个对象的分配和关闭此代理的一些引用。等消息被接收之后,这个代理的工作被安排好并通过线程池运行(默认情况下是.NET线程池)。
虽然罕有一个程序需要100000个代理,但是涵盖经常被使用的2000多个代理的精彩示例还是有的。我们将在下面看看一些这样的例子。
可升级的Web服务中的请求
异步代理的概念,其实是一种在F#代码中以许多不同设置出现的设计模式。在F#中,我们经常会用“agent”来代表任意的高速异步运算,但尤其是那些有循环的,或者处理消息的,或者产生结果的。
举个例子, 在后面的博客中,我们将会看到用F#建立可升级的TCP和HTTP服务器应用程序,使用EC2和Windows Azure将它们部署到云上去。在其中的一个例子中,我们将用一个股票报价服务器来接收连进来的TCP或HTTP链接并且给每个客户返回一个的股票报价数据流。每个客房每秒收到一个报价。这个服务最终以一个单独的URL或者REST API的形式发布。
在该实现中,每个被接受的客户请求即刻产生一个异步代理,其定义大致如下(在这种情况下,我们对AAPL股票重复的写些相同的价格,仅为描述的目的):
openSystem.Net.Sockets
/// serve up a stream of quotes
let serveQuoteStream (client: TcpClient) = async {
let stream = client.GetStream()
while true do
do! stream.AsyncWrite( "AAPL 200.38"B )
do! Async.Sleep 1000.0 // sleep one second
}
每个代理直到客户断开连接时才停止运行。因为代理是轻量级的,这个股票报价服务器可以单独的机器上支持成千上万的模拟客户(通过使用云主机还可以支持更多)。实际运行中的代理数量取决于被服务的客户数量。
上面的代码暗示着——当网络协议变成读写数据流的异步代理时,用F#异步网络协议编程将是多么简单的事情。我们将在后面的博客中关注更多的可升级的TCP/HTTP编程的示例。
代理和隔离状态(强制方式)
隔离是F#中代理编程成功的关键。隔离意味着“属于”指定代理的资源是存在的,并且不可被除此代理外的其它代理访问。这就意味着隔离状态避免了并发访问和数据冲突。
F#的语言结构使得使用词法作用域在异步代理中获得隔离状态变得简单。例如,下面的代码使用了一个字典来累积发送到代理的不同消息的数量。该内部的字典对异步代理来说是私有的,并且除了这个代理外没有谁有能力读写此字典。这就意味着这个字典的可变状态是隔离的。非并发,安全访问得到了保障。
type Agent<'T> = MailboxProcessor<'T>
open System.Collections.Generic
let agent =
Agent.Start(fun inbox ->
async { let strings = Dictionary<string,int>()
while true do
let! msg = inbox.Receive()
if strings.ContainsKey msg then
strings.[msg] <- strings.[msg] + 1
else
strings.[msg] <- 0
printfn "message '%s' now seen '%d' times" msg strings.[msg] } )
隔离状态在F#中是一项基本技术,并不局限于代理。 例如,下面的代码展示了隔离使用,同时使用一个数据流读取器和一个ResizeArray(在F#的System.Collections.Generics.List命名空间中,此名称是最合适的,请注意,在.NET的System.IO.File.ReadAllLines类库中存在着与此函数功能相同的函数)
let readAllLines (file:string) =
use reader = new System.IO.StreamReader(file)
let lines = ResizeArray<_>()
while not reader.EndOfStream do
lines.Add (reader.ReadLine())
lines.ToArray()
这个函数中,“reader”和“ResizeArray”从来没被外部使用。这种情况下的代理和长期的运算操作,隔离不会是暂时的——这种状态是永久分离的且独立的,即使程序中的其他进程仍在继续运行。
当最终只有一个动态属性处于隔离状态,那么词法上来说它通常会被强制执行。例如,考虑到lazy(延迟),使用一个被隔离的reader来按需读取所有行的版本:
let readAllLines (file:string) =
seq { use reader = new System.IO.StreamReader(file)
while not reader.EndOfStream do
yield reader.ReadLine() }
隔离状态通常包括一些可变值。这些代码里,引用单元都是在F#中使用的。例如,下面的代码通过使用一个引用单元来保持一定数量的消息。
let agent =
Agent.Start(fun inbox ->
async { let count = ref 0
while true do
let! msg = inbox.Receive()
incr count
printfn "now seen a total of '%d' messages" !count } )
重复一下,这个可变的状态是被隔离的和安全的,且保证是单线程访问的。
带有循环和隔离状态的代理(函数方式)
F#程序员都知道在F#中有两种方式来写循环——使用”命令”while/for加上可变的计数器(ref或mutable),或者使用一个或多个递归函数的”函数式”风格,将累计状态作为参数传入。例如,可以通过“命令式”的方式来写一个统计文件中所有行数的循环:
let countLines (file:string) =
use reader = new System.IO.StreamReader(file)
let count = ref 0
while not reader.EndOfStream do
reader.ReadLine() |> ignore
incr count
!count
或者“递归式”(函数式)
let countLines (file:string) =
use reader = new System.IO.StreamReader(file)
let rec loop n =
if reader.EndOfStream then n
else
reader.ReadLine() |> ignore
loop (n+1)
loop 0
对于本地使用,两种方法都是可以的:函数式方法更为高级一点点,但是大量减少代码中的显示可变量通常更为普遍。F#程序员应该要具备阅读两种方式的能力,而且可以将一个”while”循环转变成等价的”let rec”(这会是一个很好的面试题!)
有趣的是,同样的规则适用于写异步循环:你可以使用命令”while”,或者函数式的”let rec”来定义你的循环。例如,这里有一个使用递归异步函数来统计消息数量的代理:
let agent =
Agent.Start(fun inbox ->
let rec loop n = async {
let! msg = inbox.Receive()
printfn "now seen a total of %d messages" (n+1)
return! loop (n+1)
}
loop 0 )
这里是我们如何向这个代理发送消息的:
fori in 1 .. 10000 do
agent.Post (sprintf "message %d" i)
当这些代码运行时, 我们会得到这样的输出:
now seen a total of 0 messages
now seen a total of 1 messages
....
now seen a total of 10000 messages
为了重述要点,用来定义邮箱代理的两种常用模式是:命令式的
let agent =
Agent.Start(fun inbox ->
async {
// isolated imperative state goes here
...
while <condition> do
// read messages and respond
...
})
和递归式/函数式:
let agent =
Agent.Start(fun inbox ->
let rec loop arg1 arg2 = async {
// receive and process messages here
...
return! loop newArg1 newArg2
}
loop initialArg1 initialArg2 )
重复一下,在F#中任何一种方式都是合理的——使用递归异步函数式会稍微先进一点,但是越函数式就越普遍。
消息和联合类型
使用一个discriminate联合类型的消息是很普遍的。例如,在接下来我想展示的基于代理的Driectx示例版本中,我们使用了下面的消息类型来模拟引擎:
typeMessage =
| PleaseTakeOneStep
| PleaseAddOneBall of Ball
这个模拟引擎是这样的一个代理:
let simulationEngine =
Agent.Start(fun inbox ->
async { while true do
// Wait for a message
let! msg = inbox.Receive()
// Process a message
match msg with
| PleaseTakeOneStep -> state.Transform moveBalls
| PleaseAddOneBall ball -> state.AddObject ball })
许多情况下,像这样在使用强类型的消息会是一个好注意。然而,当需要和其他使用消息的机械作交互操作时,你不应该害怕使用如“obj”和“string”这样的合成类型来作为你的消息类型,也不用担心代理在运行时的输入和分析的解码工作。
参数化的和抽象的代理
代理仅仅是一种F#编码设计模式。这意味着你可以使用所有常用的F#代码技术来进行参数化,抽象化和重复使用代理定义中的片断。例如,你可以使用股票报价的传递时间间隔来参数化上面使用过的函数"serveQuoteStream":
openSystem.Net.Sockets
/// serve up a stream of quotes
let serveQuoteStream (client: TcpClient,periodMilliseconds: int) = async {
let stream = client.GetStream()
while true do
do! stream.AsyncWrite( "AAPL 439.2"B )
do! Async.Sleep periodMilliseconds
}
这意味着在你的股票报价服务器中不同的请求可以有不同的周期。
与此类似,你也可以使用函数参数来抽象整个代理类:
let iteratingAgent job =
Agent.Start(fun inbox ->
async { while true do
let! msg = inbox.Receive()
do! job msg })
let foldingAgent job initialState =
Agent.Start(fun inbox ->
let rec loop state = async {
let! msg = inbox.Receive()
let! state = job state msg
return! loop state
}
loop initialState )
你可以用下面的方式来写第一个:
letagent1 = iteratingAgent (funmsg -> async { do printfn "got message '%s'" msg })
然后第二个:
let agent2 =
foldingAgent (fun state msg ->
async { if state % 1000 = 0 then printfn "count = '%d'" msg;
return state + 1 }) 0
报告来自代理的结果
在将来的博客中,我们将关注于从代理执行中获取部分结果的技术,例如在每个MailboxProcessor代理上可使用的PostAndAsyncReply方法。在创建需要网络交流的代理时,这些是很重要的技术。
然而,通常这些技术都被大材小用了,相反,我们只需要向诸如图形用户界面(GUI)提供一些监视结果报告。这样,报告来自任何代理的部分结果的一种简单方法就是使用我们在这个系列的第二部分讨论的设计模式。下面的例子展示了编译这样一个代理——从消息流中每1000个消息采集样本并向GUI或者其他主线程发送样本事件。
// Receive messages and raise an event on each 1000th message
type SamplingAgent() =
// The event that is raised
let sample = new Event<string>()
// Capture the synchronization context to allow us to raise events
// back on the GUI thread
let syncContext = SynchronizationContext.CaptureCurrent()
// The internal mailbox processor agent
let agent =
new MailboxProcessor<_>(funinbox->
async { let count = ref 0
while true do
let! msg = inbox.Receive()
incr count
if !count % 1000 = 0 then
syncContext.RaiseEvent sample msg })
/// Post a message to the agent
member x.Post msg = agent.Post msg
/// Start the agent
member x.Start () = agent.Start()
/// Raised every 1000'th message
member x.Sample = sample.Publish
[注意:这里使用了在此系列中的第二部分描述的两个扩展方法SynchronizationContext(CaptureCurrent和RaiseEvent]
你可以像这样来使用这个代理:
letagent = SamplingAgent()
agent.Sample.Add (fun s -> printfn "sample: %s" s)
agent.Start()
for i = 0 to 10000 do
agent.Post (sprintf "message %d" i)
和预期一样,这个程序报告了传向代理的样本消息:
sample: message 999
sample: message 1999
sample: message 2999
sample: message 3999
sample: message 4999
sample: message 5999
sample: message 6999
sample: message 7999
sample: message 8999
sample: message 9999
代理和错误
我们都知道错误和异常会经常发生。好的错误检测,报告和日志在基于代理编程中是必要的。当使用F#内存中的代理(邮箱处理器)时,让我们看看如何检测和发送错误。
首先,F#异步工作流程中很神奇的一部分就是异常能够在async{…}模块中自动被捕获和传播,甚至跨越异步等待和输入/输出操作。你也可以在async{…}模块中使用try/with,try/finally和use结构来捕获异常和释放资源。这意味着我们仅仅需要处理代理中的那些无法被捕获的错误。
在邮箱处理器代理中,当一个无法捕获的错误发生时,代理上的Error事件会被触发。一个常用的方法就是发送所有的错误到捕获进程,例如:
typeAgent<'T> = MailboxProcessor<'T>
let supervisor =
Agent<System.Exception>.Start(fun inbox ->
async { while true do
let! err = inbox.Receive()
printfn "an error occurred in an agent: %A" err })
let agent =
new Agent<int>(funinbox->
async { while true do
let! msg = inbox.Receive()
if msg % 1000 = 0 then
failwith "I don't like that cookie!"})
agent.Error.Add(fun error -> supervisor.Post error)
agent.Start()
使用一个帮助模板:
moduleAgent =
let reportErrorsTo (supervisor: Agent<exn>) (agent: Agent<_>) =
agent.Error.Add(fun error -> supervisor.Post error); agent
let start (agent: Agent<_>) = agent.Start(); agent
下面是一个我们创建1000个代理的例子,其中的一些会报告错误:
letsupervisor =
Agent<int * System.Exception>.Start(fun inbox ->
async { while true do
let! (agentId, err) = inbox.Receive()
printfn "an error '%s' occurred in agent %d" err.Message agentId })
let agents =
[ for agentId in0 .. 10000->
let agent =
new Agent<string>(funinbox ->
async { while true do
let! msg = inbox.Receive()
if msg.Contains( "agent 99" ) then
failwith "I don't like that cookie!"})
agent.Error.Add(fun error -> supervisor.Post (agentId,error))
agent.Start()
(agentId, agent) ]
当我们发送这些消息时:
for(agentId, agent)inagents do
agent.Post (sprintf "message to agent %d" agentId )
我们会看到:
an error 'I don't like that cookie!' occurred in agent 99
an error 'I don't like that cookie!' occurred in agent 991
an error 'I don't like that cookie!' occurred in agent 992
an error 'I don't like that cookie!' occurred in agent 993
...
an error 'I don't like that cookie!' occurred in agent 9999
这一部分只是处理了F#里位于内存中的MailboxProcessor代理的错误。其他代理(例如,代表服务端请求的代理)也应该被设计和架构成能优雅地处理和转发错误,并能正常地重新启动。
总结
隔离代理是一种这样的编程模式:在跨多个编程领域一次又一次的出现,从设备驱动编程到用户界面编程到分布式编程到可升级的通讯服务器。每次你写一个对象,线程或者异步程序来管理一个长期运行的通讯(如:发送数据到你的计算机话筒,或者从网络读取数据,或者对收到的事件流做出反应),那么你正在写一种代理。每次你写一个ASP.NET web页面处理器是,你正在写一种代理(其状态在每次调用时重组)。在所有的情况中,一些或者所有与通讯相关的状态都是隔离的,这种情况是很正常的。
隔离代理意味在对于一个终端——例如,实现可升级的程序逻辑,包括可升级的请求服务器以及可升级的分布式程序逻辑。和所有异步与并行设计模式一样,他们都不应该被过度使用。尽管它们是一种优雅的,强大的且有效的技术而被广泛地使用着。
在集成于Visual Studio 2010的托管语言中,F#是唯一一个既支持轻量级异步运算又支持内存代理的语言。在F#中,可以用合成的方式来写异步代理,不需要使用回调和反向控制来写代码。这里会有一些变化——例如,在将来的博客中,我们将关注如何使用.NET类库中标准的APM方式来发布你的代理。但是,这样的好处是很多的:如果需要,你可以控制、调整和结构化CPU和I/O并行,而使.NET本地代码在同步时,以及受CPU限制的代码保持保持全部性能。
事实上,很少有支持轻量级交互代理的.NET语言或基于JVM的语言——在早期的.NET中,由于线程的消耗太大导致这不可能实现。在本文中, F# 在2007年对“async{… }”的整合可以理解为语言设计上的一种突破——他实现了工业上和跨语言平台的轻量级的、组合异步编程以及交互式代理。随着Axum语言的雏形的发展(对F#很有影响),目前使工业运行系统设计困惑的僵局在于“是否要让线程成为轻量级”,而F#已经证明异步语言功能是突破的此僵局的一个可行的方法。
F#异步编程可以被看作一种回收装置,并且这里有很多的前兆,例如OCaml与连续体划清界限,Haskell抛弃一元的并发性以及书面地强调回收机制相比较于并发行的重要性。
你可以在Linux/Mono/Mac和Silverlight上使用.NET 2.0 3.5 4.0的F#异步代理。事实上,你甚至可以在F#被WebSharper平台转化为Javascript后,继续使用F#的异步编程。玩得开心。