12.4 消息传递
Message Passing
在小型多处理器系统里,最常见是共享存储器的并发程序设计,然而在大型多机系统和网络上,目前的大多数并发程序设计都基于消息。在12.4.1节到12.4.3节里,我们将考虑基于消息的计算中最主要的三个问题:命名、发送和接收。12.4.4节将更仔细地关注发送和接收语义的一种特殊组合,称为远程过程调用。这里的大部分例子来自Ada、Occam和SR程序设计语言,Java的网络库和PVM/MPI库程序包。
|
12.4.1 |
12.4.1 通信对方的命名
|
例12.42 指名进程、端口和入口 |
Naming Communication Partners
要发送或接收一条消息,通常都必须描述把它送到哪里去,或者从哪里收到它。通信的参与方需要相互指名(或者引用)对方。所用的名字可以是对线程或进程的直接引用,另一种方式是引用某个模块的入口或端口,或者某种套接字或通道抽象。图12.18展示了这里的几种可选方式。
第一种指名方式(按进程给消息定址)出现在Hoare最早的CSP建议,以及PVM和MPI里。每个PVM和MPI进程有唯一的id(一个整数),每个send或receive都必须给定通信对方的id。MPI要求其实现可重入,一个进程可以安全地划分为多个线程,每个线程都可以以进程的名义发送或接收消息。PVM具有隐藏的不能自动同步的状态变量,这使线程化的PVM程序有些问题。
|
例12.43 Ada的entry调用 |
|
例12.44 OCcam通道 |
第二种指名方式(按照端口给消息定址)出现在Ada里。Ada里形式为t.foo(args) 的入口调用把消息送到作业t的名字为foo的入口(t可以是作业名或变量名,如果是变量,其值应该是指向作业的指针)。正如我们在12.2.3节看到的,Ada作业与模块类似,其入口类似于直接嵌套在作业内部的子程序的头部。作业通过执行accept语句(将在12.4.3节讨论)去接收送给它的入口的消息。每个entry都属于(唯一的)一个作业,所有送给同一入口的消息都由这个作业接收。
第三种指名方式(按照通道给消息定址)出现在Occam里(虽然没有在CSP里)。通道声明由内部的CHAN和CALL类型支持:
![]()
上面声明描述了一个名为stream的单向通道,它可以传输类型为BYTE的消息;还有一个名字为lookup的双向通道,它可以传递名字为ssn的整数的请求和名字为name的36个字节的字符串响应。CALL通道实际上就是一对CHAN通道(每个都为单向)的语法包装。要在CHAN通道发送一条消息,Occam线程必须使用特殊的“叹号”运算符:
![]()
要在CALL通道发送一条消息(并接受一个响应),线程采用类似于子程序调用的语法:
![]()
![]()
在(第606页)讨论并行循环时,我们已经注意到,Occam语言的规则禁止并发线程对同一变量做有冲突的访问。对于通道变量,基本规则就是恰好有一个线程可以向这个通道发送消息,而且恰好有一个线程可以从它接收消息(对于CALL通道,恰好有一个线程可以发送消息,恰好有一个线程可以接收消息并发送回复)。这些规则在Occam 3里有所放松,那里提供了一种SHARED通道并提供了相应的互斥机制。只允许一个线程由SHARED CALL通道接收请求,但可以有多个线程向这种通道发送。按照类似想法,多个线程可以CLAIM(请求)一集CHAN通道,以便在临界区里排他性地使用,但只有一个线程可以GRANT(许可)这些通道,以便作为发送和接收的另外一方。
在SR和Java的互联网库里,可以看到这些指名方式的组合。一个SR程序在一台或多台虚拟机上执行,每台虚拟机有自己独立的地址空间,可能被实现为网络上的一个独立结点。一台虚拟机里的消息发送到(并且接收自)一种类似通道的称为op的抽象。与Occam的通道不同,SR的op对发送或接收线程的数量或者标识都没有限制,任何能按常规的词法作用域规则看到一个op的线程,都可以向它发送或从它接收消息。receive操作必须显式命名所用的op,send操作也可以这样做,但它还可以用能力变量。能力变量就像是指向op的指针,但是指针只能在特定虚拟机的内部使用,而能力的使用却可以跨过虚拟机的边界。除了启动参数和可能的I/O外,不同虚拟机之间通信的仅有方式就是通过能力变量。这样,在最外层,我们可以把SR程序看作有一种类似端口的指名模式。消息(通过能力变量)发送到虚拟机的op,在那里它们可能被任何线程接收。
|
例12.45 Java的数据报消息 |
Java的标准java.net库提供了两种消息传递风格,分别对应于互联网的UDP和TCP协议。UDP在两者中更简单些。它是一种数据报协议,也就是说,每条消息都被独立地而且并不可靠地送向目的地。网络软件将设法发送这些消息,但是在这里没有任何保证。此外,发送到同一目的地的两条消息(即使都到达了)也可能以任意的顺序到达。UDP消息采用端口指名方式(见图12.18(b)),一条消息被送到某一特定的互联网地址和端口号。TCP协议也采用基于端口的指名方式,但只是用它建立连接(见图12.18(c)),所建立的连接用于随后的通信。连接能可靠地并保证按顺序地传递消息。
要发送或接收UDP消息,Java线程必须创建一个数据报套接字:
![]()
DatagramSocket构造函数的参数是可选的,如果没有给出描述,操作系统将自动选择一个可用端口。典型情况是服务器端描述了特定的端口,而客户端通常让OS去选择。要发送一条UDP消息,线程用:
![]()
这里DatagramPacket构造函数的参数描述了一个字节数组buf和它的长度len,以及接收方的互联网地址和端口号。接收方的描述与此对称:
![]()
![]()
对于TCP通信,服务器端通常一直在一个端口上“监听”,客户端可能把请求发送到这里,要求建立一个连接:
![]()
|
例12.46 Java中基于连接的消息 |
这里的accept操作被阻塞,直至服务器端从客户端接收到一个连接请求,此时它通常立即分支出一个新线程与客户端通信,父线程循环回来在accept等待下一个连接。
客户端发送连接请求的方式是将服务器端的符号名和端口号传给Socket的构造函数:
![]()
一旦建立了连接, Java里的客户端和服务器端通常就会调用Socket类的方法,去创建输入和输出流,它们支持所有标准的Java正文I/O功能(7.9.3节
):
![]()

在我们考虑过的各种消息传递机制中,只有数据报没有提供任何顺序约束。一般而言,大多数消息传递系统都保证通过同一“通信路径”传递的消息按发送的顺序到达。在显式指名进程的情况下,一条路径连接起唯一的一对发送方和接收方,来自发送方的所有消息按发送顺序到达接收方。在采用指名端口的方式时,一条数据路径连接起任意数目的发送方和唯一的接收方(我们也看到了SR的情况,如果接收方是像虚拟机那样的复杂实体,它内部可能包含许多线程)。消息按某种顺序到达端口,接收方也按这种顺序看到它们。应该注意,无论如何,来自同一个发送方的所有消息还是按顺序到达端口的,来自不同发送方的消息可能按不同的顺序到达。在指名通道时,一条路径连接了所有可以使用这一通道的发送方和所有可以使用它的接收方。Java的一个TCP连接在每一端有一个OS进程,但内部可能有许多线程,每个线程都可以使用其进程的连接端。一个SR的op可以被能看到它的任何线程使用。在这两种情况下,通道的功能都像是一个队列:发送(入队)和接收(出队)操作是有序的,因此所有的东西都按发送的顺序接收。
|
12.4.2 |
12.4.2 发送
Sending
在设计send操作时,必须考虑的最重要问题之一就是它阻塞调用者的时间段。一旦某线程初始化了一个send操作,它何时才能继续执行?阻塞可以有三种不同的作用方式。
资源管理:在基础系统还没有把向外发送的数据复制到安全位置之前,发送线程不应该去修改这个值。大多数系统阻塞发送方直到某个时间点,在此之后它就可以安全地修改自己的数据而不会危及向外发送的消息。
失败语义:特别是在通过远距离网络通信时,消息传递出错的可能性比计算的其他方面都大得多。许多系统阻塞发送方,直到它们能保证消息的正确送达为止。
返回参数:在许多情况下,一个消息里包含着一个请求,期望得到一个回复。许多系统阻塞消息发送方直到收到回复。
在决定应该阻塞多长时间时,我们必须考虑同步语义、对于缓冲区的需求,以及运行时的错误报告问题。
同步语义
|
例12.47 send语义的三种主要的可能选择 |
消息从发送方传到接收方的过程可能经过许多中间步骤,特别是如果要穿过互联网。消息首先必须在发送方机器的软件中下降若干层次,而后可能经过数量非常多的中间机器,最后在接收方机器里上升若干层次。我们可以设想在其中的任何步骤之后解除对于发送方的阻塞。然而从用户程序行为的角度看,这些选择中的大部分都是无法分辨的。如果我们暂时假定消息传递系统总能找到存放外发消息的缓冲区空间,那么,前面有关延迟的三条理由实际上也提出了三种主要的语义选择。
无等待发送:发送方的阻塞不超过某个很小的有界时间段。消息传递机制的实现把消息复制到安全位置,而后负责将它送达。
同步发送:发送方一直等到它的消息被接收。
远程调用发送:发送方一直等到收到回复。
图12.19里描绘了这三种方式。
无等待send出现在SR语言和Java的互联网程序包里。同步send出现在Occam里。远程调用send出现在SR、Occam和Ada里。PVM和MPI提供的是基于实现的无等待send和同步send的混合方式,其中send操作被阻塞到外送消息可以安全地被修改。在那些自己做内部缓冲的实现里,这一规则相当于无等待send,在其他实现里相当于同步send。PVM要求程序必须写成能应付后一种更受限情况的形式。程序员在MPI里可以有选择权,如果需要,可以要求采用无等待send或同步send。如果用户选择的不是所用系统的默认方式,在一些系统里性能有可能受到影响。
缓冲区
可惜的是,在实践中,任何消息传递系统都不能提供完全没有等待的send版本(当然,除了直接把消息丢掉之外)。如果我们设想一个线程,它在一个循环里不断把消息发给一个根本不接收的线程,立刻就可以看到这里需要的缓冲区空间量是无界的。任何实现都需要准备好在某一点阻塞过分活跃的发送方,防止它破坏整个系统。这种阻塞是一种反压,也可以通过降低线程的调度优先级,或者改变基础消息传输机制的参数等方式,对这种情况施加更温和一点的反压。
设计和实现
实现问题对语义的影响
不可能缓存无限量的数据,也不可能给已经继续执行的发送方同步地报告一个错误,这些都是我们已经看到过的许多实例中最近的例子,它们说明实际实现可能会影响程序员所看到的语言的语义。其他例子包括源代码行或标识符的长度(2.1.1节)、数据(无论是静态、栈还是堆分配)和递归函数求值(3.2节)可用的存储量限制,在case语句的标号中不提供区间(6.4.2节),for循环的reverse、downto和常量步长(6.5.1节),全集合的大小限制(为了能用位向量方式实现——8.1节),Modula-2模糊导出的固定大小要求(9.2.1节),在cobegin语句里不允许嵌套的线程和无限制的枝(为了避免仙人掌栈——8.6.1节和第606页的旁白)。这方面的一些限制也会反映到语言的形式化语义中,其他东西(通常是在不同实现中变化很大的那些东西)则限制了系统可以正确运行的语义合法程序的范围。

图12.19 send操作的同步语义:无等待send (a),同步send (b),和远程调用(c)。在每个图中我们都已经假定原始消息在接收方执行receive操作之前到达,一般而言情况未必如此。
|
例12.48 与缓存相关的死锁 |
对于任何固定大小的缓冲区空间,我们都有可能设计出一个程序,使之需要更大的空间量才能正确运行。举个例子,设想一个消息传递系统可以在任何通信路径上缓冲n个消息。现在设想一个程序,其中A要发送n + 1个消息给B,而后发送一个消息给C。C随之通过另一通信路径发送一个消息给B。作为B一方,要求在接收来自A的消息之前先接收来自C的消息。这样,当A发送了n个消息之后被阻塞时,就出现了依赖于实现的死锁。实现可能采用的最好方式就是提供足够大的空间,使任何实际应用都不会发现问题规模的限制。
对于同步send和远程调用send而言,缓冲区空间通常不是问题了,因为消息所需的空间量受到线程数的限制,而系统里对于程序能创建多少线程可能早就有限制。发送回复的线程总应该允许继续,我们知道马上就能重用有关的缓冲区空间,因为送请求的线程正在那里等着这个回复。
|
例12.49 确认 |
错误报告
如果基础消息传递系统不可靠,语言或库通常都采用确认消息来验证成功的传输(见图12.20)。如果在合理时间内没收到确认,实现系统通常就会重发一次。如果尝试了几次都没收到客户端的确认,系统将生成一个错误报告。

图12.20 用于检查错误的确认消息。在没有搭载技术的情况下,远程调用send(左)可能需要4条基础消息,而同步send(右)只需两条。
只要消息发送方还处于阻塞状态,在试图送达消息的过程中出现的错误总可以作为异常,也可以作为状态信息放到结果参数或全局变量里。然而,一旦发送方已经继续执行下去了,那么就再也没有报告发生错误的明显方式了。UDP的解决办法是说消息总是不可靠的,如果真有什么事情出错,消息就会就不声不响地丢失了。TCP的“解决办法”说只有在出现了“灾难性”错误时才会导致消息丢失,此时连接也会变为不可用的,进一步的调用都立即失败。MPI采纳了一种更严厉的方式,一些实现所专有的错误可能在运行时检查和处理,但一般而言如果有消息无法送达,那么就认为整个程序失败了。PVM提供了一种通知机制,在出现结点或进程失败事件时,它将给一个事先指定的进程发送一条消息。此后这个指定进程可以去做一些清理工作,例如废除一些相关的有依赖的进程,或者启动一个新进程把失败进程的工作接过去,等等。
不同方式之间的模拟
所有三种send都可以互相模拟。要得到远程调用send的效果,线程可以在无等待send之后用receive要求得到一个回复。我们也可以用类似代码在同步send里模拟远程调用send。要得到同步send的效果,线程可以在无等待send之后用receive要求一个高级的确认消息,接收方在收到原始消息之后将立即发送它。要通过远程调用send得到同步send的效果,线程在收到请求后应立即回复,不返回任何参数。
要通过同步send或远程调用send得到无等待send的效果,我们就必须插入一个缓冲区进程(也就是我们的共享存储中有界缓冲区的消息传递版本),它在可能的情况下立即给“发送方”或“接收方”送去回复。缓冲区进程里空间的可
用性把资源限制的情况明确化了,这种限制在无等待send的实现里并没有暴露出来。
语法和语言集成
在上面的模拟实例里,我们都假定采用基于库的消息传递实现。在这种实现里,send、receive、accept等都是常规的子程序,取固定的静态可确定数目的参数,其中通常有两个参数描述地址和被发送消息的长度。如果需要发送的消息保存在多个程序变量里,程序员就必须显式地把这些值汇集或整编到一个记录的各个域中。在接收端,程序员还必须把有关的值分解(解编)并送回程序变量。与此相对应的情况是,并发程序设计语言可以提供一些消息传递操作,它们的“参数”表可以包含任意多个需要发送的值。进一步说,编译器还可以安排对这些值做类型检查,可以采用一些类似于跨编译单元的子程序连接时所用的技术(见14.6.2节的介绍)。最后,我们在12.4.3节还将看到,显式的并发语言还可以使用非子程序调用的语法形式,例如可以把远程调用的accept和reply结合成一种形式,使reply不必去明确说明自己对应于哪个accept。
设计和实现
模拟和效率
可惜的是,在用户层用一种send语义去模拟另一种语义,通常都不可能做到像在基础原语上做出的优化实现那么有效。以希望用远程调用send模拟同步send为例,再进一步假定远程调用send是在网络软件的基础上构造的,该软件需要通过确认来验证消息传输。服务器在送出一个回复之后,其运行系统将等待客户端的确认。如果一个服务器线程在发送回复之前可能运行任意长的一段时间,那么运行系统就需要为请求和回复分别发送确认消息。如果程序员想在远程调用send的这种实现上模拟同步send,那么基础网络可能最后就需要传送总共4条消息(在出现错误时还会更多)。与此相对应,同步send的“本地”实现只需要两条基础消息。在一些情况下,远程调用send的运行时系统可以考虑把发送第一条确认的时间推迟一段时间,在出现随后的回复时把它“搭载”上去(如果存在回复消息)。在这种情况下,同步send的模拟也需要发送3条消息而不是2条。我们将在练习12.33和探索12.38里进一步考虑模拟的效率问题。
12.4.3 接收
|
12.4.3 |
Receiving
对于消息接收机制而言,最主要的变化可能就是显式的receive操作和12.2.3节(第611页)描述的隐式接收。在前面一直用作实例的语言和系统里,只有SR提供了隐式接收(有些RPC系统也提供了它,我们将在12.4.4节看到)。
在采用隐式接收时,到达给定端口(或经由给定通道)的每个消息都创建一个新控制线程,当然要受到资源的限制(任何实现在线程数增加得过多时都会暂停处理外来请求)。采用显式receive操作时,外来消息必须入队,直到某个现有线程表明有意去接收它。在任意给定时间点,可能存在数量很大的一批待接收消息。大多数采用显式接收的语言和库都允许线程具有某种选择性,表明它希望接收的消息的类型。
PVM和MPI的每条消息都包含发送进程的id和由发送方确定的一个整数标志。receive操作描述所期望的发送方id和消息标志,与之匹配的消息才会被接收。在许多情况下,接收方对发送方id和/或消息标志采用某种“通配符”,以便能接收一批不同消息。还有一些特殊形式的receive,进程可以利用它们去检测(并不阻塞)某种特定类型的消息当前是否可用(这种操作也称为轮询);或者用于描述“超时”,如果在特定时间期间内不能收到匹配的消息就继续下去。
|
例12.50 Ada 83里的有界缓冲区 |
由于Ada、Occam和SR是语言而不是库,因此它们能采用一些特殊的非过程调用语法形式描述选择性的消息接收。此外,因为消息被做在命名系统和类型系统里,这些语言都能基于端口/通道名和参数做选择性接收,而不是基于更原始的标志概念。这三种语言里的选择性receive结构都是卫式命令(在6.7节
有讨论)的某种特殊形式。
图12.21给出的是用Ada 83写出的有界缓冲区代码。这里有一个主动性的“管理器”线程,它在循环里执行一个select语句。(再说一次,在Ada里完全可以用受保护对象的方式写有界缓冲区,如12.3.2节描述的,其中不用管理器线程。)Ada的accept语句接收远程调用请求的in和in out参数(8.3.1节)。在与之匹配的end处,accept将返回in out和out参数作为回复消息。客户端作业可以通过入口调用与这个有界缓冲区通信:
![]()

图12.21 Ada里的有界缓冲区,其中明确使用了一个管理器作业。
|
例12.51 超时和分布式终止 |
这个有界缓冲区里的select语句有两个分支。在缓冲区未满而且存在可用insert请求的情况下,第一个分支可被选中;在缓冲区不空而且存在可用的remove请求时第二个分支可被选中。分支之间的选择是一个两步过程:首先求值各个卫(各个when表达式),而后考虑那些真分支后面的accept语句,看看相应的消息是否可用。(在accept前面的卫是可选的,没有卫时的行为就像when true =>。)如果在上面例子里的两个卫都真(缓冲区处于部分填充状态),而且两种消息都可用,那么可以执行任一分支里的语句,具体选择由实现决定。(关于在卫为真的分支中选择的公平性问题,请看配套光盘第76页的旁白。)
每个select语句里至少必须有一个带accept的分支(以及可选的when),此外,这里还可以有另外三种分支:

如果其他分支在how_long秒内都不可选,那么delay分支就可以被选中。(Ada要求其实现支持长至一天,短至20 ms的延迟时间。)只有在所有潜在通信对方都已终止,或者可能都停在带terminate分支的select语句里时,terminate分支才可以被选中。选择这种分支将导致执行这个select的作业终止。如果存在else分支,在没有其他分支的卫为真或没有立即可以执行的accept语句时,这个分支就会被选中。在带有else分支的select语句里不允许再有delay分支。在实践中,人们有可能希望在这种管理器风格的有界缓冲区的select语句里包含一个terminate分支。
|
例12.52 Occam里的有界缓冲区 |
Occam里与select等价的结构称为ALT。与Ada一样,这里在不同分支之间的选择也基于布尔表达式和消息的可用性。(一个小差异:Occam语义描述了一种一步的求值过程,把消息的可用性看作卫的一部分。)有界缓冲区实例的体在图12.22里给出。还请注意,Occam以缩格作为控制流结构的分界,此外它也没有mod运算符。
|
例12.53 send同步的非对称性 |
Occam的问号运算符(?)就是receive,叹号运算符(!)是send。与Ada一样,这里的主动管理器线程也把ALT嵌在一个循环里。按这里的写法,所描述的ALT语句有两个卫,当full_slots < SIZE而且名为producer的通道上有消息可用时,第一个卫为真;当full_slots > 0而且在通道request上有消息可用时,第二个卫为真。
因为在这个例子用的是同步send,所以其中对生产者和消费者的处理有一种非对称性:前者只需要把数据发送给管理器,后者就必须送一个空参数,然后等着管理器把数据送回来:

通过对CALL通道使用远程调用,就可以去掉这里的非对称性:
![]()

图12.22 把有界缓冲区定义为一个Occam进程。

现在客户端代码的样子大致是:
![]()
|
例12.54 Occam中receive的超时 |
在缓冲区管理器代码里,ACCEPT的体就是随后的那个语句(访问buf的那个语句),对于next_empty、next_full和full_slots的更新都在回复客户端之后进行。
利用一个从ALT分支“接收”的时钟伪进程,就可以在Occam里实现Ada的delay分支的效果:

图12.23 将有界缓冲区实现为一个SR主动进程。
![]()
一个分支也可以仅仅基于布尔条件进行选择,并不试图去接收:
![]()
在Occam的ALT里没有与Ada的terminate等价的东西,也没有与else等价的东西(通过很短的延迟可以获得类似效果。)
设计和实现
窥探内部消息
SR的卫和调度表达式可以去“窥探消息的内部”,这就要求语言的运行系统能够看到所有待处理的消息。这样,SR的实现就必须准备接收(并缓存)任意数目的消息,它可以利用操作系统或其他基础软件提供这种缓存功能。进一步说,缓存空间不可能真正没有限制,这一事实也意味着卫和调度表达式都不能看到那些由于反压而被延迟发送的消息。
在SR里,选择性接收也基于卫式命令。代码在图12.23给出。这里的st表示“such that”,它引进卫的布尔部分。客户端代码大致是下面样子:
![]()
|
例12.56 在SR里窥探消息内部 |
|
例12.55 SR里的有界缓冲区 |
如果需要的话,也可以把对客户端的显式reply插入in的每个分支,放到buf访问和对next_empty、next_full和full_slots的更新之间。
SR与Ada和Occam有一个最重要的不同点:按它的安排,潜在消息的参数位于st条件的作用域里,这就使接收方在决定是否接收消息之前,可以先去“窥探其内容”:
![]()
接收方还可以描述一个调度表达式,通过这种机制以乱序方式从一个特定端口(也就是一个给定的op)中接收消息:
![]()
![]()
SR的in语句与Ada的select类似,它也可以以else卫结束,在没有立即可用的消息时将选择这个卫。这里没有与delay或terminate等价的结构。
|
12.4.4 |
12.4.4 远程过程调用
Remote Procedure Call
三种send(无等待、同步、远程调用)中任何一种都可以与receive的两种主要形式(显式的或隐式的)之一配对。远程调用send与显式接收的组合(例如像在Ada中那样)有时称为握手。远程调用send和隐式接收的组合称为远程过程调用(RPC)。RPC在若干并发语言里可用(SR明显是这些语言之一),也得到许多带有桩编译器的顺序语言扩充的支持。桩编译器独立于语言的正常编译器,它接受有关被远程调用的子程序的一个形式化描述作为输入。这种描述大致等价于子程序的头部和所有参数类型的声明,桩编译器能基于这种输入,生成客户端桩和服务器端桩。针对某个特定子程序的客户端桩把所需参数和所期望操作的一个指示符整编到一个消息缓冲区里,然后把这个消息发送给服务器,等待回复消息,并把得到的回复解编后送回作为结果参数。服务器端的桩子程序以消息缓冲区为参数,从中分解出有关请求的各个参数,调用适当的局部过程,把这种过程的返回参数整编到一个消息里,然后把消息发送回适当的客户。客户端的桩子程序的调用相对比较简单。服务器端的桩子程序的调用将在下面有关“实现”的小节里讨论。
语义
大多数RPC系统的主要目标就是尽可能地使调用的远程性质透明化,也就是说,使远程调用尽可能像本地调用 [BN84]。在一个桩编译器系统里,客户端桩的接口应该与它作为代理的那个远程过程的接口一模一样,程序员通常应该在不知道或不关心这一过程是本地还是远程的情况下就能调用它。
在实践中达到透明性还是很困难的,这里存在几个主要问题。
参数模式:很难实现跨网络的引用调用参数,因为实际参数不在被调子程序的地址空间里。(访问全局变量也有类似的困难。)
性能:一个无法逃避的事实是,远程过程调用可能需要更长时间才能返回。由于需要面对网络延迟,使用时常常不得不考虑这种情况的影响。
失败语义:与调用本地过程相比,远程调用失败的可能性更大。对于本地调用做下面假设通常是可以接受的:被调过程或者正好运行了一次,或者整个程序失败了。把这种假设用到远程调用就是过于受限制了。
只要程序的正确性不依赖于引用参数所造成的别名,我们都可以用值/结果参数取代其中的引用参数。正如8.3.1节提到的,Ada声明说,如果一个程序可以显示出对in out参数的引用传递和值/结果传递实现之间的不同,这个程序就是错误的。如果绝对需要,引用参数和全局变量实际上也能实现,我们可以通过消息传递一个类似名字调用参数实现中那样的块(8.3.2节
),但这种做法会带来很高的代价。就像7.10节里已经说过的,很少有语言或系统在传递给远程过程时进行对链接数据结构的深拷贝。
在本地调用与远程调用之间的性能差异,只能通过人为地减缓本地调用的方式来掩盖,这显然不是一种可以接受的方式。
采用在出现失败事件时令调用方夭折的方式,可以提供正好一次调用的失败语义。或者换一种方式,在高可靠系统里可以推迟调用方的执行,直至操作系统或语言的运行时系统能用前面卸入磁盘的信息重新构造起失败的计算。(失败恢复技术也超出了本书的范围。)另一种有吸引力的选择是用通知失败的方式接受“至多失败一次”语义。在试图恢复丢失的消息时,实现可以根据需要重新传输远程调用请求。这里还应保证这种重新传输不会导致出现多于一次的实际调用,但是可以接受由于出现了通信故障,实际调用根本就没有发送的情况。如果程序设计的语义里提供了异常,那么实现中就可以利用它们,把通信故障做得就像是另一种运行时错误。
实现
|
例12.57 一个RPC服务器系统 |
在内核的接口层,receive通常都是一种显式操作。要使receive对应用程序员是隐式出现的,RPC的桩编译器生成的代码(或者如SR一类语言的运行时系统)就必须填补这种显式-隐式之间的空白。我们在下面描述基于桩编译器的实现,对于具有隐式接收的并发语言,正规编译器要做的本质上也就是这些事情。
图12.24展现了典型RPC系统的层次结构。上面横线之上的代码由应用程序员写出,位于横线中间的代码包括库例程和由RPC桩编译器产生的代码。在初始化这种RPC系统时,应用系统需要两次调用进入运行时系统:第一次给系统提供由桩编译器产生的桩子程序代码指针,第二次调用启动一个消息分派器。第二次调用之后的情况依赖于这一服务器本身是否为并发的。如果它是并发的,那么还要依赖于它的线程是在一个OS进程上还是在多个线程上实现的不同情况。
最简单的情况是一个OS进程上的单线程服务器线程。这时指派器运行在一个循环里,调用进入内核去接收消息。一旦有消息到达,指派器就调用适当的RPC桩子程序,该子程序分解出请求的各个参数,并去调用适当的应用层过程。在过程返回时,桩子程序把返回参数整编为一个回复消息,调用进入内核把消息送回调用方,而后返回指派器。
设计和实现
远程过程的参数
Ada的相对高级的参数语义模型使同一组参数模式可以用于子程序和(握手的)入口。只要可能,Ada编译器通常总会把许多实在参数通过引用的方式送给子程序,以避免高昂的复制代价。当然,如果作业位于多处理器或集群的不同处理器上,编译器通常就会把同样的实在参数用值-结果的方式传给入口。
有几种并发语言提供了特别为支持远程调用而设计的参数模式。例如,在Emerald [JLHB88] 里,每个参数都引用一个对象,对于远程对象的引用将通过透明的消息传递的方式实现。为了尽可能减少这种引用出现的频繁程度,远程调用中传递的对象常常随着调用迁移,它们被打包在请求消息里送到远程站点(在那里就可以局部访问它们了),而后在回复中送回调用方。Emerald称此为移动调用。在Hermes [SBG+91] 里,参数传递是破坏性的:从调用方的观点看,实在参数将变成未初始化的,因此可以迁移到被调用方,不会出现远程引用的危险。

图12.24 远程过程调用服务器的实现。应用代码通过安装桩编译器(没有显示)生成的桩,初始化有关RPC系统。而后它调用进入运行时系统,以允许外来调用。根据所用特定系统的实现细节不同,有关分派器可以是使用主程序里的单一进程(在这种情况下,启动分派器的调用绝不返回),或者是创建一个进程池,由它来处理进来的请求。
只要每个远程请求都能很快处理完毕,这种简单组织方式就工作得很好,甚至根本不需要阻塞。如果远程请求必须不时地等待用户层同步,那么服务器进程就必须管理一个线程的就绪表,如12.2.4节所述,但是需要把指派器集成到普通的线程调度器里。在当前线程阻塞时(在应用代码里),调度器/指派器就将从就绪表里抓出一个新线程。如果就绪表为空,调度器/指派器就调用进入内核去接收一条消息,并分出一个新线程去处理它,而后继续执行可用运行线程,直到就绪表再次变为空。
在多进程的服务器里,启动指派器的调用通常首先要求内核分出一个进程“池”,用于为远程请求提供服务。其中的进程都执行前一段落里描述的那些操作。在线程和进程之间一一对应的语言或库里,每个进程将反复从内核接收消息而后调用适当的桩子程序。如果用的是更一般的线程包,每个进程将运行来自就绪表的线程直至该表变为空,在这一点它(进程)将调用进入内核去获取下一条消息。只要可运行的线程数大于或等于进程数,它们就不会去接收新消息。一旦可运行线程数小于进程数,剩下的进程就会调用进入内核,阻塞在那里直至有新请求到达。
检查你的理解
49. 请说明进程指名其通信对方的三种方式。
50. 什么是数据报?
51. 为什么(一般而言)send消息可能需要被阻塞?
52. 消息发送方的三种主要同步方式是什么?它们之间有哪些权衡要素?
53. 什么是消息传递程序里的汇集和分解操作?什么是整编和解编?
54. 请说明在显式和隐式消息接收之间的权衡要素。
55. 什么是远程过程调用(RPC)?什么是桩编译器?
56. 在RPC系统里实现透明性的主要障碍是什么?
57. 什么是握手?它与远程过程调用有什么不同?
58. 请解释Ada的select语句的用途(或等价的,解释Occam的ALT)。
59. 要提供在接收消息之前去“窥探”其内部的功能,语义和实际提出哪些主要挑战?






