首页 > 代码库 > 消息传递(Message Passing)

消息传递(Message Passing)

消息传递(Message Passing)

 

通常可用把并行程序理解为一组相互独立的、能够发关和接收消息的组件,这也称为角色模型(ActorModel),在维基百科上可找到更正式的解释 http://en.wikipedia.org/wiki/Actor_model。虽然使用消息传递的场景往往相当复杂,但是其背后的思想却相对简单,正像下面将要看到的示例一样:

消息传递背后的基本思想是这样的,系统是由代理(agents)或角色(actors)组合面成的,它们都可以发送和接收消息。当代理接收消息时,这个消息被放在队列(queue)中,直到这个代理准备处理它;当代理处理消息时,根据消息的内部状态和内容决定做什么。代理有多种的可能性为了响应入站消息:可能发送应答给初始绷住的代理,可为不同的代理创建澵消息,可创建新代理,还可能更新一些内部数据结构。

F# 提供了泛型类 MailboxProcessor,实现消息传递和角色模型。当MailboxProcessor 创建时,它就有(从名字可推测)一个消息队列,能够用于接收消息;MailboxProcessor 负责决定消息接收以后做什么。实现 MailboxProcessor往往要遵循一些简单的模式,下面的示例演示了 MailboxProcessor 的最简单模式:

 

open System

 

let mailbox =

  MailboxProcessor.Start(funmb ->

    letrec loop x =

      async { let! msg = mb.Receive()

             let x = x + msg

             printfn "Running total: %i - newvalue %i" x msg

             return! loop x }

    loop0)

 

mailbox.Post(1)

mailbox.Post(2)

mailbox.Post(3)

 

Console.ReadLine() |> ignore

 

前面代码的运行结果如下:

 

Running total: 1 - new value 1

Running total: 3 - new value 2

Running total: 6 - new value 3

 

在第一部分,我们创建一个信箱,接收整型消息。当信箱接收到消息时,就把它加到运行中的总计,然后,显示这个运行中的总计,连同接收到的值一起。我们仔细看看这是如何实现的。MailboxProcessor 有一个静态的启动(Start)方法,它接收的参数为一个函数,这个函数有一个MailboxProcessor 的新实例,它必须返回工作流;应该用这个异步工作流去读队列中的消息。它之所以能成为异步工作流,是因为消息需要以异步方式读,这保证了信箱不会依赖于一个线程,如果有大量信箱会导致扩展性问题。需要检查队列是否有新消息到达,通常,是使用一个无限循环去检查队列。这里,我们定义了一个递归函数 loop,它通过调用 Receive 函数读队列,然后处理消息,最后调用它自己再启动一个进程。这个一个无限递归,但没有栈举出的危险,是因为这个函数是尾递归(tail recursive)的。loop 函数只有一个参数,我们用它来保存信箱的状态,在这里就是一个整数,表示运行中的总计。

还有一点值得注意的是,在示例的结尾有一句 Console.ReadLine(),这很重要,这是因为消息队列是在单独的线程中处理的,一旦我们使用 Post 方法发布消息完成之后,主线程就不再运行,直接退出,导致进程退出。在这里,进程可能会在信箱刚处理队列中的消息之前退出。调用Console.ReadLine(),提供了一种简单的方法阻塞主线程,直到用户有机会看到处理消息的信箱结果。

关于此示例的最后细节:信箱的 Post 成员函数从任何线程调用都案例的,因为信箱的工作队列,可以保证每个消息能够依次以原子方式被处理。但是,目前的这个示例还未乃至这一点,但很快在下面的两个示例中就能见到。

这个特定的异步工作流没什么用处,但是,它说明了工作流最简单的使用模式:接收消息,更新一些内部状态,然后响应消息。在这里,响应消息就是写到控制台,这可能过于简单化而没有太多的用处。然而,可以发现这种使用模式更实用的情形,比如,使用信箱去收集大量的值,然后,集中到图形界面线程,用来显示值。在下面两个示例中我们将学习更多有关这种方法。

首先,我们将以更详细一点的方式来看一下我们要解决的问题。如果需要模拟生成数据,我们可能想要版实时生成的数据;当使用图形界面时,需要面对两个富有挑战性的约束。第一,图形界面必须运行在它自己的线程中,且这个线程必须不能长时间占用,否则,图形界面就会没有响应,这样,要在图形界面线程中长时间运行这个模拟程序是不可能的;第二,访问图形界面只能从创建它的线程中,即图形界面线程。如果模拟程序运行其他线程,那么就不能直接写到图形界面。好在图形界面对象提供了Invoke 方法,它可以在图形界面线程中调用函数,以安全地方式用生成的数据更新图形界面。调用 Invoke 函数,在性能方面通常也会有消极影响,因为把数据都集中到图形界面代价很大。如果模拟程序频繁地输出少量数据,指处理结果是一个好办法,在屏幕上每秒种打印 12 到 20 次,就能得到平滑的动画效果。我们先学习如何使用信箱来解决这个问题的特定案例,然后,再看第二个示例,把它整理成更为通用。

F# 的信箱在这里,能够以优雅的方式在打印数据到屏幕之前缓冲数据。这个算法的基础相当简单。运行模拟程序的线程提交消息到信箱;当信箱接收了足够多的消息后,就通知图形界面有新更新需要绘制。这种编程网格还提供了一种整洁的方式,把生成数据的逻辑与图形界面中表示数据的逻辑分开来。我们来看一下完整的代码,然后,再分步解释它是如何工作的。运行这个程序需要引用System.Drawing.dll 和 System.Windows.Forms.dll:

 

open System

open System.Threading

open System.Windows.Forms

open System.Drawing.Imaging

open System.Drawing

 

// the width & height for thesimulation

let width, height = 500, 600

 

// the bitmap that will hold the outputdata

let bitmap = new Bitmap(width, height,PixelFormat.Format24bppRgb)

 

// a form to display the bitmap

let form = new Form(Width = width, Height =height,

                    BackgroundImage = bitmap)

 

// the function which recieves that pointsto be plotted

// and marshals to the GUI thread to plotthem

let printPoints points =

  form.Invoke(newAction(fun () ->

    List.iterbitmap.SetPixel points

    form.Invalidate()))

  |>ignore

 

// the mailbox that will be used to collectthe data

let mailbox =

  MailboxProcessor.Start(funmb ->

    //main loop to read from the message queue

    //the parameter "points" holds the working data

    letrec loop points =

     async { // read a message

              let! msg = mb.Receive()

              // if we have over 100 messageswrite

              // message to the GUI

             if List.length points > 100then

                printPoints points

                return! loop []

              // otherwise append message andloop

              return! loop (msg :: points) }

    loop[])

 

// start a worker thread running our fake simulation

let startWorkerThread() =

  //function that loops infinitely generating random

  //"simulation" data

  letfakeSimulation() =

    letrand = new Random()

    letcolors = [| Color.Red; Color.Green; Color.Blue |]

    whiletrue do

     // post the random data to the mailbox

     // then sleep to simulate work being done

     mailbox.Post(rand.Next(width),

       rand.Next(height),

       colors.[rand.Next(colors.Length)])

     Thread.Sleep(rand.Next(100))

  //start the thread as a background thread, so it won‘t stop

  //the program exiting

  letthread = new Thread(fakeSimulation, IsBackground = true)

  thread.Start()

 

// start 6 instances of our simulation

for _ in 0 .. 5 do startWorkerThread()

 

// run the form

Application.Run form

 

这个示例有三个关键部分:模拟程序如何把数据发到信箱,信箱如何缓存需要发送到图形界面上的点,图形界面如何接收这些点。我们就分别看一看。把数据发到信箱仍然很简单,继续调用信箱的 Post 方法,但这个示例与前面一个有两点重要不同:第一,传递的数据结构不同,但是,因为 Post 方法是泛型的,故仍保持强类型;第二,从六个不同的线程中调用 Post 方法。消息队列使它工作的很好,因此,每一个都能工作。使用一种简单的方法缓存数据,这样就能名统计接收到的消息数,当接收到 100 个消息时,就把它们发送到图形界面:

 

async { // read a message

       let! msg = mb.Receive()

       // if we have over 100 messages write

       // message to the GUI

       if List.length points > 100 then

         printPoints points

         return! loop []

       // otherwise append message and loop

       return! loop (msg :: points) }

 

数字 100 是随意定的,尤其是对于这种模拟程序,似乎是更好的选择。还有一点值得注意,统计每次循环接收到的消息数,使用List.length 函数,从性能 的角度来看,这不是最理想的,因为 List.length函数在我们每次调用时,都要遍历整个列表。这对于我们目前的示例算不上什么,因为这个列表相当小;然而,如果增加缓冲的大小,这种方法就会变成瓶颈了。更好的方法可以是把函数每次迭代期间的增量,作为参数单独保存;然而,这个示例没有这样做,是为了保持简单。还有一种方法是保存上次更新的时间,如果前一次更新的时间超过了二十分之一秒,才再次更新。这个方法效果不错,因为它可以让我们只关注为了达到平滑的动画效果而需要的每秒多少帧数。另外,本书的示例没有使用这种方法,因为何用这种方法会增加使示例复杂化的不必要元素。这个示例还有一点值得说一下,即如何把数据写到屏幕:

 

let printPoints points =

  form.Invoke(newAction(fun () ->

    List.iterbitmap.SetPixel points

    form.Invalidate()))

  |>ignore

 

这个函数 printPoints 很简单, 它有一个参数 points,调用窗体上下文中的委托,把点写成位图,最后,调用窗体的 Invalidate函数保证点的正确显示。

前一个示例很好地演示了如何使用信箱,但它有一主要的问题是代码不能重用,如果能把信箱包装成一个可重用的组件就更好了,F# 面向对象的功能提供了很好的解决途径。下面的示例还演示了两个重要的概念,比如,如何在同一个信箱中支持不同类型的消息,以及如何把消息返回给信箱客户端。另外,运行这个示例需要引用System.Drawing.dll 和System.Windows.Forms.dll:

 

open System

open System.Threading

open System.ComponentModel

open System.Windows.Forms

open System.Drawing.Imaging

open System.Drawing

 

// type that defines the messages types ourupdater can handle

type Updates<‘a> =

  |AddValue of ‘a

  |GetValues of AsyncReplyChannel<list<‘a>>

  |Stop

 

// a generic collecter that recieves anumber of post items and

// once a configurable limit is reachedfires the update even

type Collector<‘a>(?updatesCount) =

  //the number of updates to cound to before firing the update even

  letupdatesCount = match updatesCount with Some x -> x | None -> 100

 

  // Capture the synchronization context of thethread that creates this object. This

  //allows us to send messages back to the GUI thread painlessly.

  letcontext = AsyncOperationManager.SynchronizationContext

  letrunInGuiContext f =

    context.Post(newSendOrPostCallback(fun _ -> f()), null)

 

  // This events are fired in thesynchronization context of the GUI (i.e. the thread

  //that created this object)

  letevent = new Event<list<‘a>>()

  letmailboxWorkflow (inbox: MailboxProcessor<_>) =

    //main loop to read from the message queue

    //the parameter "curr" holds the working data

    //the parameter "master" holds all values received

    letrec loop curr master =

     async { // read a message

              let! msg = inbox.Receive()

              match msg with

              | AddValue x ->

                let curr, master = x :: curr, x:: master

                // if we have over 100 messages write

                //message to the GUI

                if List.length curr > updatesCount then

                  do runInGuiContext(fun () ->event.Trigger(curr))

                  return! loop [] master

                return! loop curr master

              | GetValues channel ->

                // send all data received back

                channel.Reply master

                return! loop curr master

              | Stop -> () } // stop by notcalling "loop"

    loop[] []

 

  //the mailbox that will be used to collect the data

  letmailbox = new MailboxProcessor<Updates<‘a>>(mailboxWorkflow)

 

  //the API of the collector

 

  //add a value to the queue

  memberw.AddValue (x) = mailbox.Post(AddValue(x))

  //get all the values the mailbox stores

  memberw.GetValues() = mailbox.PostAndReply(fun x -> GetValues x)

  //publish the updates event

  [<CLIEvent>]

  memberw.Updates = event.Publish

  //start the collector

  memberw.Start() = mailbox.Start()

  //stop the collector

  memberw.Stop() = mailbox.Post(Stop)

 

// create a new instance of the collector

let collector = newCollector<int*int*Color>()

 

// the width & height for thesimulation

let width, height = 500, 600

 

// a form to display the updates

let form =

  //the bitmap that will hold the output data

  letbitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb)

  letform = new Form(Width = width, Height = height, BackgroundImage = bitmap)

  //handle the collectors updates even and use it to post

  collector.Updates.Add(funpoints ->

    List.iterbitmap.SetPixel points

    form.Invalidate())

  //start the collector when the form loads

  form.Load.Add(fun_ -> collector.Start())

  //when the form closes get all the values that were processed

  form.Closed.Add(fun_ ->

    letvals = collector.GetValues()

    MessageBox.Show(sprintf"Values processed: %i" (List.length vals))

    |>ignore

    collector.Stop())

  form

 

// start a worker thread running our fakesimulation

let startWorkerThread() =

  //function that loops infinitely generating random

  //"simulation" data

  letfakeSimulation() =

    letrand = new Random()

    letcolors = [| Color.Red; Color.Green; Color.Blue |]

    whiletrue do

     // post the random data to the collector

     // then sleep to simulate work being done

     collector.AddValue(rand.Next(width),

       rand.Next(height),

       colors.[rand.Next(colors.Length)])

     Thread.Sleep(rand.Next(100))

  //start the thread as a background thread, so it won‘t stop

  //the program exiting

  letthread = new Thread(fakeSimulation, IsBackground = true)

  thread.Start()

 

// start 6 instances of our simulation

for _ in 0 .. 5 do startWorkerThread()

 

// run the form

Application.Run form

 

这个示例的输出与前一个示例完全相同,基础代码很大程度上也遵循了相同的模式;然而,两者之间也有一些重要的差别。最明显的可能是现在的信箱被包装在一个提供了强类型接口的对象中,我们已经创建了类Collector<‘a>,它的接口像这样:

 

type Collector<‘a> =

  class

    new: ?updatesCount:int -> Collector<‘a>

    memberAddValue : x:‘a -> unit

    memberGetValues : unit -> ‘a list

    memberStart : unit -> unit

    memberStop : unit -> unit

    memberUpdates : IEvent<‘a list>

end

 

这个类是泛型,依据它所收集数据的类型,它的 AddValue 方法用于发送到内部信箱,GetValues 获得到目前为止信箱中的所有消息;采集器(collector)现在必须由它的Start 和 Stop 方法显式启动、停止;最后,采集器(collector)的更新(Updates)事件,当收集到足够的消息时触发。使用事件是一个重要的设计细节,通过事件通知客户端已有更新,这样,采集器(Collector<‘a>)不需要知道它的客户端的使用情况,极大地改进了可用性。

现在使用联合(union)类型表示消息,这样,就可以不同类型消息的灵活性。采集器(Collector<‘a>)的客户端并不直接处理消息,而是使用它提供的成员方法。这个成员方法具有创建不同类型消息的任务,能够为消息队列提供一个值,还可以发送一个消息以读取所有当前消息,以及从读到的新消息中停止信箱[a message to stop the mailbox from reading new messages,真的搞不懂谁停止谁]:

 

type Updates<‘a> =

  |AddValue of ‘a

  |GetValues of AsyncReplyChannel<list<‘a>>

  |Stop

 

接下来,对接收到的消息通过模式匹配实现峭同类型的消息:

 

let! msg = inbox.Receive()

match msg with

| AddValue x ->

  letcurr, master = x :: curr, x :: master

  //if we have over 100 messages write

  //message to the GUI

  ifList.length curr > updatesCount then

    dorunInGuiCtxt(fun () -> fireUpdates(curr))

    return!loop [] master

  return!loop curr master

| GetValues channel ->

  //send all data received back

  channel.Replymaster

  return!loop curr master

| Stop -> ()

 

AddValue 联合的情况是前一个示例主要做的工作,除了这次把值添加到 curr 和 master 列表以外,curr列表保存了将要传递给图形界面的、在下一次需要更新的值,而 master 列表中保存了所有接收到的值;master    列表可以容纳任何客户端请求的所有值。

对于 GetValues 联合的情况是值得花时间看一下的,表示图记端如何返回值。通过调用信箱的PostAndReply 方法,而不是 Post 方法启动这个过程的,可以看到GetValues 成员方法的实现:

 

// get all the values the mailbox stores

member w.GetValues() =mailbox.PostAndReply(fun x -> GetValues x)

 

PostAndReply 方法接受一个函数,传递 AsyncReplyChannel<‘a>类型,能够使用这个 AsyncReplyChannel<‘a> 类型把消息发送回调用者,通过它的Reply 成员,这就是我们在联合的 GetValues 情况所见到的。使用这个方法需要小心,因为在消息返回之前会被阻塞,就是说,直到消息到达队列的前端才会被处理,如果队列很长,花的时间可能也会很长。这样,用户更多地会使用AsyncPostAndReply 方法,因为它可以在等待应答期间避免阻塞线程;然而,这个示例并没有这样做,主要是为了保持示例的简单。

Stop 联合的情况最简单,即停止从队列中读消息;所要做的就是避免递归地调用loop 方法。在这里,这不是问题,但,仍然要返回一个值,我们返回了一个空(unit)类型,用空的括号表示。在这里唯一需要注意的问题是,调用停止(Stop)方法后,不会立即停止信箱,即,只有当要停止的消息到达队列的前端时,才停止信箱。

我们已经看到如何用采集器(Collector<‘a>)类型处理消息,现在来看一下采集器是如何触发更新事件,以便运行在图形界面进程。使用New Event 创建更新事件,就像在 F# 中创建所有其他事件一样。使用runInGuiContext 函数使这个事件运行在图形界面的上下文中:

 

let context =AsyncOperationManager.SynchronizationContext

let runInGuiContext f =

  context.Post(newSendOrPostCallback(fun _ -> f()), null)

 

首先,保存创建这个对象的线程的 SynchronizationContext,使用System.ComponentModel 命名空间中的 AsyncOperationManager的静态属性实现。SynchronizationContext 能够收集到使用它的 Post 成员方法创建的线程,唯一需要小心的事情就是创建采集器对象的线程会成为图形界面线程;然而,我们通常使用主程序线程来做这两件事,因此,这就不成为问题了。这种收集同步上下文的方法也用在BackgroundWorker 类中,见本章的“响应式编程”一节。

现在,窗体的定义就有点简单了,因为我们不再需要为调用信箱而提供函数,而只要简单地处理更新(Updates)事件:

 

// handle the collectors updates even anduse it to post

collector.Updates.Add(fun points ->

  List.iterbitmap.SetPixel points

  form.Invalidate())

 

我们现在还能利用窗体的关闭(Closed)事件来停止信箱处理程序,并获得在用户关闭窗体时忆处理的所有消息的列表:

 

// when the form closes get all the valuesthat were processed

form.Closed.Add(fun _ ->

  letvals = collector.GetValues()

  MessageBox.Show(sprintf"Values processed: %i" (List.length vals))

  |>ignore

  collector.Stop())

 

我们没有改变示例的行为,但这些增加的部分极大地改进了代码了设计,减少了信箱代码与图形界面代码的耦合,极大地提高了采集器类的重用性。

 

 

第十章 小结

 

在这一章,我们讨论了大量的基础知识,也看到了五种不同并发技术,在特定的应用程序中都有它们的位置。

在下一章,我们将学习如何使用其中的一些方法,特别是异步工作流,能使开发分布式应用程序(DistributedApplications)更容易。