跳到主要内容

2. 分时系统内的进程间通信系统

本节描述一组使进程间通信能在分时系统内部进行的操作。沿用 [10] 的记法, 我把这一进程间通信设施称为 IPC。为便于介绍这套 IPC, 先描述一个分时系统的模型; 然后用这个模型来说明这些进程间通信操作的用法。

模型分时系统有两部分: 监督程序 (monitor) 和进程。当某个进程已用掉"足够"的时间时, 监督程序负责把控制权从一个进程切换到另一个进程; 它还负责处理硬件中断、管理主存与换出介质、控制从一个进程到另一个进程的控制权转移 (即保护机制)、创建进程、照看休眠中的进程, 并向进程提供一组机器扩展操作 (常称为 Supervisor 或 Monitor Call)。进程执行通常的用户功能 (用户进程), 也执行那些在分时系统中通常被视为监督功能、但在本模型里不由监督程序执行的功能 (系统进程)。典型的系统进程是磁盘处理程序或文件系统。系统进程是磁盘处理程序或文件系统。系统进程大概被允许在监督态 (supervisor mode) 下执行, 它们实际执行 I/O 指令, 并执行用户进程不被允许执行的其他特权操作。在所有其他方面, 用户进程和系统进程是相同的。出于效率考虑, 把系统进程看作被锁定在内存中可能是有用的。

尽管保护问题在本研究后文会受到关注, 但此处不是我要关心的问题: 相反, 我将假定所有进程都是从不犯任何错误的"好"进程。如果读者在阅读本札记时需要一个保护结构作为参照, 那么 [1][3][7][8] 中发展出的 capability 系统应当令人满意。

在进程可以调用监督程序执行的那些操作中, 有六个对于提供进程间通信能力特别重要。

RECEIVE。该操作允许指定进程向执行 RECEIVE 的进程发送一条消息。该操作有四个参数: 等待该消息的端口 (定义见下) —— RECEIVE 端口; 可以接收消息的发送方端口 —— SEND 端口; 可用于接收该消息的缓冲区的说明; 以及传输完成后要转移到的位置 —— 重启动位置 (restart location)。

SEND。该操作把一条消息从执行 SEND 的进程发送给指定进程。它有四个参数: 消息要发往的端口 —— RECEIVE 端口; 消息从其发出的端口 —— SEND 端口; 包含待发消息的缓冲区的说明; 以及重启动位置。

RECEIVE ANY。该操作允许任何进程向执行 RECEIVE ANY 的进程发送一条消息。该操作有四个参数: 等待该消息的端口 —— RECEIVE 端口; 可用于接收该消息的缓冲区的说明; 一个重启动位置; 以及一个可用于记下发送该消息的端口的位置。

SEND FROM ANY。该操作允许一个进程向能够接收来自任何进程的消息的进程发送一条消息。它与 SEND 有同样的四个参数。(该操作的必要性将在后文详细说明)。

SLEEP。该操作允许当前正在运行的进程把自己置为休眠, 等待某个事件完成。该操作有一个可选参数, 即要等待的事件。一个例子是硬件中断的到来。监督程序绝不会因为进程执行上述四个操作之一而单方面让进程休眠; 不过, 如果进程处于休眠状态时上述四个操作之一被满足, 该进程就会被唤醒。

UNIQUE。该操作从监督程序取得一个唯一编号。

端口是通往某个进程 (RECEIVE 端口) 或来自某个进程 (SEND 端口) 的一条特定数据通路, 所有端口都有一个关联的唯一端口号, 用来标识该端口。端口按如下方式用于从一个进程向另一个进程传输消息。考虑两个希望通信的进程 A 和 B。进程 A 执行一个从端口 M 到端口 N 的 RECEIVE。进程 B 执行一个从端口 M 到端口 N 的 SEND。监督程序把端口号匹配起来, 并把消息从进程 B 传给进程 A。缓冲区一旦完全传出进程 B, 进程 B 就在 SEND 操作中指定的位置被重新启动。消息一旦在进程 A 处完全接收完毕, 进程 A 就在 RECEIVE 操作中指定的位置被重新启动。进程究竟如何获得用于与其他进程通信的正确端口号, 不是监督程序关心的事 —— 这个问题留给各个进程自己解决。

执行 SEND 时, 在匹配的 RECEIVE 被执行之前什么都不会发生。监督程序中的某处必须有一张表, 记录与进程及重启动位置相关联的端口号。每次 SEND/RECEIVE 匹配完成之后, 相应的表项就被清除。如果一段时间内没有执行相应的 RECEIVE, 该 SEND 会在一段时间后超时, 并通知执行 SEND 的进程。如果执行了 RECEIVE, 但匹配的 SEND 很长时间没有发生, 该 RECEIVE 就会超时, 并通知执行 RECEIVE 的进程。

让"未使用"的表项超时这一机制在根本上并不重要, 只是提供一种方便的表垃圾回收 (garbage collect) 方法。即使某项过早超时也没有问题, 因为进程总可以重新执行该操作。不过, 超时间隔应当足够长, 使得反复重新执行某个操作只带来很小的开销。

RECEIVE ANY 永不超时, 但可以用监督程序调用 (supervisor call) 撤回。由 SEND FROM ANY 产生的消息总是立即发出, 如果不存在合适的接收者就会被丢弃。不会返回错误消息, 确认 (如果有的话) 由各进程自行负责。如果用于匹配 SEND 和 RECEIVE 的那张表一旦溢出, 发起后续 SEND 和 RECEIVE 的进程会得到通知, 就如同该 SEND 或 RECEIVE 超时一样。

重启动位置是与一个伪中断 (pseudo interrupt) 相关联的中断入口, 该伪中断对于执行了指定重启动位置的那个操作的进程而言是本地的。如果引起该伪中断的事件发生时该进程正在运行 (例如, 一条满足某个未决 RECEIVE 的消息到达), 其效果恰好如同硬件中断了该进程并把控制权转移到重启动位置。系统会保存足够的信息, 使该进程在中断处理完毕之后能从被中断处继续执行。如果该进程处于休眠状态, 它会被置为就绪, 而该伪中断被保存下来, 直到该进程再次运行, 此时该中断才被允许。因此, 任何 RECEIVE 或 RECEIVE ANY 消息端口都可以用来提供进程中断、事件通道 (event channel)、进程同步、消息传输等。由用户程序自行决定它想要什么。

读者可以自己作为练习去确信: 他所身负的那个监督程序是可以改造成提供上述六个操作的 —— 大多数监督程序都可以, 因为这些只是额外的监督程序调用而已。

一个例子。假定我们的模型分时系统在初始化时有若干进程一直在运行。此外, 这些永久进程拥有一些普遍已知且永久分配的端口<2>。假定这些永久运行的进程中有两个是 logger 进程 (logger-process) 和电传扫描进程 (teletype-scanner-process)。电传扫描进程第一次开始运行时, 它把自己置为休眠, 等待来自硬件电传扫描器的中断。logger 进程最初把自己置为休眠, 等待通过众所周知的永久 SEND 端口和 RECEIVE 端口从电传扫描进程发来的消息。电传扫描进程维护一张以电传编号为索引的表, 每个表项中包含一对用于把该电传的字符发送给某个进程的端口号, 以及一对用于从某个进程接收该电传字符的端口号。如果一个字符到达 (唤醒电传扫描进程), 而该进程没有任何针对该电传的表项, 它就从监督程序 (通过 UNIQUE) 取得一对唯一编号, 并使用它已知 logger 进程有未决 RECEIVE 的那些端口, 把包含这对编号的消息发送给 logger 进程。扫描进程还把这对编号登记到电传表中, 并把该字符以及今后来自该电传的所有字符从第二个编号对应的端口发送到第一个编号对应的端口。扫描进程还必须把第二对唯一编号传给 logger 进程, 供其用于电传输出, 并使用这些端口号执行一个 RECEIVE。当 logger 进程收到来自扫描进程的消息时, 它启动一份 SDS 940 TSS [6] 用户所称的 executive<3>, 并把端口号传给这份 executive, 使这个 executive 进程也能用这些端口与电传进行输入输出。如果 logger 进程想从用户那里取得作业号和口令, 它可以在把这些端口号传给 executive 之前, 临时用它们与用户通信。只要这些端口号一次只传给一份 executive, 扫描进程就总可以为某个特定电传使用同样的端口号。

重要的是区分"把一个端口从一个进程传给另一个进程"与"把一个端口号从一个进程传给另一个进程"这两种行为。在前面的例子中, 来自某个特定电传的字符由电传扫描进程要么发给 logger 进程、要么发给 executive 进程, 此时 SEND 端口始终留在电传扫描进程中, 而 RECEIVE 端口则从 logger 进程转移到 executive 进程。另一方面, SEND 端口号在 logger 进程和 executive 进程之间传递, 使执行 RECEIVE 的进程能够从正确的 SEND 端口执行 RECEIVE。关键在于: 一旦某个进程把端口转移给别的进程, 原来的进程就不能再使用该端口。我们可以增加一种机制来强制这一点。[9] 中的受保护对象系统 (protected object system) 就是这样一种机制。使用这种机制时, 执行 SEND 的进程需要持有 SEND 端口的 capability, 而系统中该 SEND 端口的 capability 在任何给定时刻只能存在一个。执行 RECEIVE 的进程则需要持有 RECEIVE 端口的 capability, 而该 RECEIVE 端口的 capability 在给定时刻也只能存在一个。若没有这样的保护机制, 那么即便端口号从未被显式传递过, 端口也会仅仅因为各进程在互不重叠的时间段内使用它而隐式地从一进程转移到另一进程。

当然, 如果我们能使用受保护对象系统, 那么在一次传输可以发生之前实际上就不需要指定两个端口号。一个进程知道某个已存在的 RECEIVE 端口号这一事实, 可以视为该进程有权向该端口发送的初步证据 (prima facie evidence)。于是 RECEIVE 端口和 RECEIVE ANY 端口之间的差别就仅取决于某个特定端口号已经分发出去的份数。如果可以假定网络中所有自主分时系统都会采用这种保护机制, 那么基于这种做法的系统显然比本文描述的系统更可取。如果无法作此假定, 那么要求同时给出两个端口号似乎更实际。

注意, 在本文所述的进程间通信系统 (IPC) 中, 当两个进程希望通信时, 它们自己建立连接, 并且可以自由地以对双方都方便的方式进行。例如, 它们可以交换端口号, 或者由一个进程挑选所有端口号并指示另一个进程使用哪些。不过, 在某个分时系统的具体实现中, 系统的构建者可能选择限制进程执行 SEND 和 RECEIVE, 并可能禁止任意传递端口和端口号, 转而要求调用监督程序 (或某个其他特殊程序) 来执行这些功能。

本 IPC 的流控 (flow control) 采用一种简单的做法提供: 由某个 SEND 引起的数据传输, 在接收方执行 RECEIVE 之前绝不开始。当然, 进程间也可以来回发送消息, 提示某个进程停止发送, 或者应当分配空间。

一般来说, 众所周知且永久分配的端口通过 RECEIVE ANY 和 SEND FROM ANY 使用。这些永久端口最常用于启动进程, 因此通过它们发送的数据很少。如果一个进程正在运行 (也许是休眠中) 并且有一个未决的 RECEIVE ANY, 那么任何知道该接收端口号的进程都可以直接与该进程通话, 无需经过 logger。这在本地分时系统内部显然至关重要, 而在更一般的网络中, 如果要达到资源共享的理想, 它似乎也非常有用。例如, 在资源共享网络中, 各站点子程序库中的程序可以在永久分配且端口号众所周知的端口上始终保持未决的 RECEIVE ANY。于是, 为了使用某项特定的网络资源 (例如矩阵运算硬件), 网络中任何地方运行的进程都可以向矩阵求逆子程序发送一条消息, 其中包含待求逆的矩阵以及用于返回结果所需的端口号。

另一个例子演示 FORTRAN 编译程序的用法。我们已经说明过用户如何坐在自己的电传机前并连接到 executive。我们从那里继续。用户正在与 executive 进行输入输出, 而 executive 正在执行 SEND 和 RECEIVE。最终用户键入 RUN FORTRAN, 于是 executive 请监督程序启动一份 FORTRAN 编译程序, 并把 executive 原先用于与电传机通话的端口号作为启动参数传给 FORTRAN。(至少在概念上, FORTRAN 得到的是一个用于从电传机接收字符的端口, 以及一个用于向电传机发送字符的端口)。FORTRAN 当然预期这些参数, 并通过所指出的端口执行 SEND 和 RECEIVE, 以从用户那里了解他想要使用什么输入文件和输出文件。FORTRAN 向用户打出 INPUT FILE?, 用户回答 F001。随后 FORTRAN 向文件系统进程发送一条消息, 该进程正休眠等待事情做。消息通过众所周知的端口发送, 它请文件系统打开 F001 供输入。消息中还包含一对端口号, 文件系统进程可以用它来发送回复。文件系统查找 F001, 打开它供输入, 在其打开文件表中登记一些表项, 然后向 FORTRAN 发回一条消息, 其中包含 FORTRAN 可以用来读取该文件的端口号。对输出文件也遵循同样的过程。编译完成之后, FORTRAN 把电传机的端口号 (以及那些端口) 交还给一直在休眠、等待来自 FORTRAN 的消息的 executive, 然后 FORTRAN 自行停机。文件系统进程在无事可做时便回去休眠<4>。

同样, 文件系统进程可以保留一小批端口号反复使用, 只要它能让文件系统的用户在用完之后把这些端口号归还。当然, 当这批端口号最终被消耗殆尽时, 文件系统可以从监督程序取得一些新的唯一编号。