QNX----第3章 进程间通信(1部分)

QNX----第3章 进程间通信(1部分)

进程间通信在将微内核从嵌入式实时内核转换为全面的POSIX操作系统的过程中起着至关重要的作用。随着各种服务提供进程被添加到微内核中,IPC是将这些组件连接到一个内聚整体的粘合剂。

虽然消息传递是QNX中微子RTOS中IPC的主要形式,但也有其他几种形式。除非另有说明,那些其他形式的IPC是在我们的本机消息传递之上构建的。策略是创建一个简单、健壮的IPC服务,可以通过微内核中的简化代码路径进行性能调优;这样就可以实现更多“功能混乱”的IPC服务。

将更高级别的IPC服务(如通过我们的消息传递实现的管道和FIFOs)与它们的单内核对应服务进行比较的基准测试显示了类似的性能。

QNX Neutrino至少提供了以下形式的IPC:

设计人员可以根据带宽需求、排队需求、网络透明性等因素选择这些服务。这种权衡可能很复杂,但灵活性是有用的。

作为定义微内核的工程工作的一部分,将消息传递作为基本的IPC原语是经过深思熟虑的。作为IPC的一种形式,消息传递(如MsgSend()、MsgReceive()和MsgReply()中实现的那样)是同步的,并复制数据。让我们更详细地研究这两个属性。

同步消息传递

同步消息传递是QNX中微子RTOS中IPC的主要形式。向另一个线程(可能在另一个进程中)执行MsgSend()的线程将被阻塞,直到目标线程执行MsgReceive()、处理消息并执行MsgReply()。如果一个线程执行MsgReceive()而没有预先发送的消息挂起,它将阻塞,直到另一个线程执行MsgSend()。

在QNX中微子中,服务器线程通常循环,等待从客户端线程接收消息。如前所述,如果线程可以使用CPU,那么线程——无论是服务器还是客户机——都处于就绪状态。它可能不会因为它和其他线程的优先级和调度策略而获得任何CPU时间,但是线程不会被阻塞。

先看一下客户机线程:

图19:在发送接收回复事务中客户端线程的状态变化

•如果客户端线程调用MsgSend(),而服务器线程还没有调用MsgReceive(),那么客户端线程就会被阻塞。一旦服务器线程调用MsgReceive(),内核就会将客户机线程的状态更改为应答阻塞,这意味着服务器线程已经收到消息,现在必须进行应答。当服务器线程调用MsgReply()时,客户机线程就准备好了。

•如果客户端线程调用MsgSend(),而服务器线程已经在MsgReceive()上阻塞,那么客户端线程立即变成应答阻塞,完全跳过发送阻塞状态。

•如果服务器线程失败、退出或消失,客户端线程就准备好了,MsgSend()指示错误。

接下来,考虑服务器线程:

图20:在发送接收回复事务中的服务器线程的状态改变

•如果服务器线程调用MsgReceive(),而没有其他线程发送给它,那么服务器线程将被阻塞。当另一个线程发送给它时,服务器线程就准备好了。

•如果服务器线程调用MsgReceive(),而另一个线程已经发送给它,那么MsgReceive()将立即返回消息。在这种情况下,服务器线程不会阻塞。

•如果服务器线程调用MsgReply(),它不会被阻塞。

这种固有的阻塞使发送线程的执行同步,因为请求发送数据的行为也会导致发送线程被阻塞,并安排接收线程执行。这种情况发生时,并不需要内核进行显式工作来确定接下来运行哪个线程(对于大多数其他形式的IPC来说也是如此)。执行和数据直接从一个上下文中移动到另一个上下文中。

这些消息传递原语中省略了数据排队功能,因为在接收线程中需要时可以实现排队。发送线程通常准备等待响应;排队是不必要的开销和复杂性。(它会减慢非排队情况)。因此,发送线程不需要进行单独的显式阻塞调用来等待响应(如果使用了其他IPC表单,就会出现这种情况)。

当发送和接收操作是阻塞和同步时,MsgReply()(或MsgError())不会阻塞。由于客户机线程已经被阻塞,等待应答,因此不需要额外的同步,因此不需要阻塞MsgReply()。这允许服务器对客户机进行应答并继续处理,而内核和/或网络代码则异步地将应答数据传递给发送线程,并将其标记为准备执行。由于大多数服务器都倾向于进行一些处理,以准备接收下一个请求(此时它们将再次阻塞),因此这种方法非常有效。

MsgReply()函数用于向客户机返回状态和零个或多个字节。另一方面,MsgError()仅用于向客户机返回状态。这两个函数都将从其MsgSend()中解除对客户机的阻塞。

消息拷贝

由于我们的消息传递服务直接将消息从一个线程的地址空间复制到另一个线程,而不需要中间缓冲,因此消息传递性能接近底层硬件的内存带宽。

内核对消息的内容没有赋予任何特殊的含义——消息中的数据只有由发送方和接收方共同定义的含义。但是,还提供了“定义良好的”消息类型,以便用户编写的进程或线程可以扩充或替代系统提供的服务。

消息传递原语支持多部分传输,因此从一个线程的地址空间传递到另一个线程的消息不必预先存在于单个连续的缓冲区中。相反,发送和接收线程都可以指定一个向量表,表示发送和接收消息片段驻留在内存中的位置。注意,对于发送方和接收方,不同部件的大小可能不同。

多部分传输允许具有与数据块分离的标头块的消息发送,而无需对数据进行消耗性能的复制以创建连续的消息。此外,如果底层数据结构是环形缓冲区,指定一个由三部分组成的消息将允许在环形缓冲区内以单个原子消息的形式发送一个头和两个不相交的范围。与此概念等效的硬件是一个分散/聚集DMA设备。

图21:多部分传输

文件系统也广泛使用多部分传输。在读取时,数据直接从文件系统缓存复制到应用程序中,使用包含n部分数据的消息。每个部分都指向缓存,并补偿缓存块在内存中不与块内的读开始或结束连续这一事实。

例如,在缓存块大小为512字节的情况下,读取1454字节可以满足以下五部分的消息:

图22:1454字节的读的分散/聚集

由于消息数据是显式地在地址空间之间复制的(而不是通过页面表操作),因此可以很容易地在堆栈上分配消息,而不是从用于MMU“页面翻转”的特殊的页面对齐内存池中分配消息。因此,在客户机和服务器进程之间实现API的许多库例程可以被简单地表示出来,而不需要详细说明特定于IPC的内存分配调用。

例如,客户端线程用于请求文件系统管理器代表其执行lseek的代码实现如下:

这段代码本质上是在堆栈上构建一个消息结构,用各种常量填充它,并从调用线程传递参数,然后将其发送给与fd关联的文件系统管理器。应答表示操作的成功或失败。

注意:这个实现并不阻止内核检测到大的消息传输,并选择在这些情况下实现“页面翻转”。由于大多数传递的消息都非常小,因此复制消息通常比操作MMU页表要快。对于批量数据传输,进程间的共享内存(消息传递或其他同步原语用于通知)也是一个可行的选择。

 

简单的消息

对于简单的单部分消息,操作系统提供的函数可以直接将指针指向缓冲区,而不需要IOV(输入/输出向量)。在这种情况下,部件的数量被直接指向的消息的大小所替代。

对于message send原语(接受一个send和一个reply缓冲),这引入了四个变体:

其他接收直接消息的消息传递原语只需在名称中加上“v”即可:

频道和连接

在QNX中微子RTOS中,消息传递是指向通道和连接的,而不是直接从一个线程传递到另一个线程。希望首先接收消息的线程创建一个通道;另一个希望向该线程发送消息的线程必须首先通过“附加”到该通道建立连接。

消息内核调用需要通道,服务器将其用于MsgReceive()消息。连接由客户机线程创建,以“连接”到服务器提供的通道。一旦建立了连接,客户端就可以通过它们发送MsgSend()消息。如果一个进程中的许多线程都连接到同一个通道,那么连接都映射到同一个内核对象以提高效率。通道和连接在进程中由一个小整数标识符命名。客户端连接直接映射到文件描述符。

从架构上讲,这是一个关键点。通过让客户端连接直接映射到FDs,我们消除了另一层转换。我们不需要根据文件描述符(例如,通过read(fd)调用)来“确定”在哪里发送消息。相反,我们可以直接将消息发送到“文件描述符”(即、连接ID)。

图23:与文件描述映射成文件描述符的连接

作为服务器的进程将实现一个事件循环来接收和处理消息,如下所示:

这个循环允许线程从任何有通道连接的线程接收消息。服务器还可以使用name_attach()创建一个通道,并将名称与之关联。然后发送方进程可以使用name_open()定位该名称并创建到它的连接。

该频道有几个与之相关的消息列表:

Receive:等待消息的线程的LIFO队列。

Send:线程优先FIFO队列,该线程已发送尚未收到的消息。

Reply:发送已收到但尚未回复的消息的线程的无序列表。

在这些列表中的任何一个中,等待线程都被阻塞(接收、发送或应答阻塞)。多个线程和多个客户机可以在一个通道上等待。

脉冲

除了同步发送/接收/回复服务之外,OS还支持固定大小的非阻塞消息。这些被称为脉冲并携带一个小负载(四个字节的数据加一个字节码)。

pulse包含一个相对较小的有效负载——8位代码和32位数据。脉冲常被用作中断处理程序中的通知机制。它们还允许服务器在不阻塞客户机的情况下向客户机发送信号。

图24:脉冲装载一个小负载

优先级继承和消息

服务器进程以优先级顺序接收消息和脉冲。当服务器中的线程接收请求时,它们将继承发送线程的优先级(而不是调度策略)。因此,请求服务器工作的线程的相对优先级被保留,服务器工作将以适当的优先级执行。这种消息驱动的优先级继承避免了优先级反转问题。

例如,假设系统包含以下内容:

•服务器线程,优先级为22

•优先级为T1的客户端线程

•优先级为10的客户端线程T2

在没有优先级继承的情况下,如果T2向服务器发送一条消息,那么它实际上是在优先级22的情况下完成了工作,因此T2的优先级被颠倒了。

实际发生的情况是,当服务器接收到消息时,其有效优先级将更改为最高优先级的发送方的优先级。在本例中,T2的优先级低于服务器的优先级,因此当服务器接收到消息时,服务器的有效优先级将发生更改。

接下来,假设T1在优先级为10时向服务器发送消息。由于T1的优先级高于服务器当前的优先级,因此当T1发送消息时,服务器的优先级会发生变化。

在服务器接收到消息以避免另一个优先级反转的情况之前,就会发生变化。如果服务器的优先级保持在10,而另一个线程T3开始以优先级11运行,服务器必须等待T3让它有一些CPU时间,以便最终能够接收T1的消息。所以T1会被低优先级的线程T3延迟。

在调用ChannelCreate()时,可以通过指定_NTO_CHF_FIXED_PRIORITY标记来关闭优先级继承。如果使用自适应分区,这个标志还会导致接收线程不在发送线程的分区中运行。

消息传递API

消息传递API由以下函数组成:

 

实现发送/接收/回复

在使用同步通知的系统中,将QNX中微子应用程序设计为一个通过发送/接收/应答来协同线程和进程的团队。因此,IPC是在系统中指定的转换时发生的,而不是异步发生的.

异步系统的一个重要问题是,事件通知需要运行信号处理程序。异步IPC可能使彻底测试系统的操作变得困难,并确保无论信号处理程序何时运行,处理都将按照预期进行。应用程序通常通过依赖显式打开和关闭的“窗口”来避免这种情况,在此期间信号将被容忍。

使用基于发送/接收/应答构建的同步、非队列系统体系结构,可以非常容易地实现和交付健壮的应用程序体系结构。

 当使用排队IPC、共享内存和其他同步原语的各种组合构造应用程序时,无效死锁情况是另一个难题。例如,假设线程A直到线程B释放互斥锁2之后才释放互斥锁1。不幸的是,如果线程B在线程A释放互斥锁1之前处于不释放互斥锁2的状态,则会出现僵局。经常调用模拟工具,以确保在系统运行时不会发生死锁。

发送/接收/应答IPC原语允许构建无死锁系统,只需要遵守以下简单规则:

1.不要让两个线程互相发送。

2.始终将线程安排在一个层次结构中,发送到树中。

第一个规则明显避免了僵持局面,但第二个规则需要进一步解释。合作线程和进程团队安排如下:

图25:线程应该总是发送到更高级别的线程

在这里,层次结构中任何给定级别的线程从不互相发送,而是只向上发送。其中一个示例可能是发送到数据库服务器进程的客户机应用程序,数据库服务器进程又发送到文件系统进程。由于发送线程阻塞并等待目标线程应答,并且由于目标线程在发送线程上没有发送阻塞,因此无法发生死锁。

但是,更高级别的线程如何通知较低级别的线程,它已经获得了先前请求的操作的结果?(假设较低级别的线程不希望在最后一次发送时等待响应的结果。)

QNX中微子RTOS通过MsgDeliverEvent()内核调用提供了一个非常灵活的体系结构来交付非阻塞事件。所有常见的异步服务都可以通过此实现。例如,select()调用的服务器端是一个API,应用程序可以使用它来允许线程等待一组文件描述符上的I/O事件完成。除了需要异步通知机制作为从高级别线程到低级别线程的通知的“反向通道”之外,我们还可以为计时器、硬件中断和相关的其他事件源构建可靠的通知系统。

图26:更高级别的线程可以“发送”一个脉冲事件

一个相关的问题是,更高级别的线程如何能够请求较低级别的线程的工作而不发送给它,从而导致死锁。较低级别的线程仅作为较高级线程的“工作线程”,根据请求执行工作。级别较低的线程会发送消息以“报告工作”,但级别较高的线程不会回复。它将延迟应答,直到更高级别的线程有工作要做,它将使用描述工作的数据进行应答(这是非阻塞操作)。实际上,回复是用来开始工作的,而不是发送,它巧妙地避开了规则1。