从微内核构建全面的POSIX操作系统,进程间通信起到至关重要的作用。当各种提供服务的进程加到微内核中,IPC做为粘合层,把这些部件连结成一个紧密的整体。
尽管消息传递是QNX Neutrino RTOS IPC的主要形式,还有其他集中形式的IPC,除非提到,这些其他形式的IPC都是构建在本地消息传递基础之上。策略是在内核中创建一个简单的,鲁棒,易扩展的的IPC服务。更多复杂的IPC服务可以在此基础上实现。
比较微内核和单内核中高级IPC服务(比如pipes和FIFOs)的性能,性能指标是相当的。
QNX提供了如下形式的IPC
Service: | Implemented in: |
---|---|
Message-passing | Kernel |
Signals | Kernel |
POSIX message queues | External process |
Shared memory | Process manager |
Pipes | External process |
FIFOs | External process |
设计者可以选择这些服务,基于带宽需求,队列需求,网络透明等。如何权衡是复杂的,但是灵活性非常有必要。
做为实现微内核工程设计的一部分,使用消息传递作为基本的IPC原语是深思熟虑的。作为一种IPC通信形式,消息传递是同步操作并且有数据复制。让我们更详细的探究这两个属性。
同步消息是QNX中的主要IPC形式
线程调用MsgSend()到另外一个线程,发送线程阻塞,目标线程调用MsgReceive()处理收到的消息,然后执行MsgReply()。如果一个线程执行MsgReceive()时,没有未处理的消息,那么该进程阻塞直到另外一个线程执行MsgSend()
在QNX中,服务器线程一半都是循环执行的,等待从Client线程接收到一个消息。像先前描述的,不论是server还是Client都可能处在READY状态,而没有在CPU上执行,此时是由于线程优先级和调度策略,而不是线程被阻塞。
让我们首先看一下Client线程。
Figure 19: Changes of state for a client thread in a send-receive-reply transaction.
Figure 20: Changes of state for a server thread in a send-receive-reply transaction.
发送线程是天生具有同步执行属性的,因为发送数据请求促使发送线程阻塞,接收线程被调度执行。不需要其他kernel工作决定哪个线程限制性。执行和数据移动直接从一个上下文转到另外一个。
这些消息原语并不包括数据队列能力,如有必要,可以在接收线程实现排队。发送线程通常只是等待一个响应,队列会带来不要的负载和负载性。因此,发送线程不需要单独的,显示的阻塞调用来等待响应。
发送和接收操作是阻塞的和同步的,而MsgReply()或者MsgError()则不会阻塞。因为Client线程已经阻塞等待响应,所以不需要额外的同步操作。这允许server响应一个消息后继续处理,kernel或者其他网络代码异步的传输应答数据给发送线程并改变发送线程为准备执行状态。因为大部分servers是趋向于做些处理准备介绍下一次请求,所以工作良好。
MsgReply() vs MsgError()
MsgReply()函数用来返回状态以及一些数据给Client。MsgError()仅仅返回client的状态。这两个函数都会解除client的阻塞状态。
因为消息服务直接复制消息到另外一个进程的地址空间,而不需要中间buffer,因此消息分发性能接近了硬件内存带框。
内核并没有给消息内容赋予任何特定含义,消息体内的数据含义是由发送者和接收者定义的。当然也提供了良好定义的消息类型,以便用户写的进程和线程增强和替代系统自带的服务。
消息原语支持多部分传输,以便发送和接收线程不需要预分配单一的连续的buffer,相反,消息发送和接收线程可以使用向量表指示内存中的消息段。注意各个部分的尺寸可以不同。
多部分传输的消息包含一个头部和数据部。此外,如果潜在的数据结构是ring buffer,那么把ring buffer数据的起始和结束偏移包含在消息内。多部分传输有点类似于scatter/gather DMA机制。
Figure 21: A multipart transfer.
多部分传输也广泛的应用到文件系统上。对于读操作,数据通过消息的多份数据,从文件系统cache复制到应用程序。每一部分都指向cache地址,用以解决cache内存的不连续。
比如,对于cache块大小为512字节,使用5部分消息可以读取1454字节数据。
Figure 22: Scatter/gather of a read of 1454 bytes
因为消息数据显示的在地址空间复制,消息可以很容易分配在stack上,而不是从一个特定的页对齐内存池分配。因此,实现client和server进程间API的库函数无需考虑IPC特定的内存分配调用。
比如,client线程用来请求文件系统manager执行lseek的代码实现如下:
#include
#include
#include
off64_t lseek64(int fd, off64_t offset, int whence) {
io_lseek_t msg;
off64_t off;
msg.i.type = _IO_LSEEK;
msg.i.combine_len = sizeof msg.i;
msg.i.offset = offset;
msg.i.whence = whence;
msg.i.zero = 0;
if(MsgSend(fd, &msg.i, sizeof msg.i, &off, sizeof off) == -1) {
return -1;
}
return off;
}
off64_t tell64(int fd) {
return lseek64(fd, 0, SEEK_CUR);
}
off_t lseek(int fd, off_t offset, int whence) {
return lseek64(fd, offset, whence);
}
off_t tell(int fd) {
return lseek64(fd, 0, SEEK_CUR);
}
上述代码在进程栈上分配message结构,然后设置这个结构的各个成员,发送给关联到fd的文件系统manager 。返回值指明操作是否成功。
注意:因为大部分消息传递的数据量非常小,复制消息通常要比控制MMU page tables要快。对于大块的数据传输,进程间的共享内存通常是更好的选择。
对于简单的single-part消息,操作系统提供了函数直接访问buffer指针,不需要使用IOV。这种情况下,部分序号被替换为直接指向的消息尺寸。
对于消息发送原语,根据发送和响应buffer的不同,引入了四个变种
Function | Send message | Reply message |
---|---|---|
MsgSend() | Simple | Simple |
MsgSendsv() | Simple | IOV |
MsgSendvs() | IOV | Simple |
MsgSendv() | IOV | IOV |
其他的消息原语如果只是用一个直接消息buffer,那么只需去掉后缀v即可。
IOV | Simple direct |
---|---|
MsgReceivev() | MsgReceive() |
MsgReceivePulsev() | MsgReceivePulse() |
MsgReplyv() | MsgReply() |
MsgReadv() | MsgRead() |
MsgWritev() | MsgWrite() |
在QNX Neutrino RTOS中,消息传递是通过channels和connections,而不是面向线程的。一个线程如果希望接收消息首先要创建一个channel;另外一个线程希望发送消息给这个线程,则必须创建一个连接,绑定到这个channel上。
server通过内核调用创建一个channels,使用MsgReceive()在channels上接收消息。Client创建一个connections,并连接到servers提供的channels上。一旦连接被建立,clients使用MsgSend()。如果多个线程都绑定到同一个channel,所有的连接都映射到相同的内核对象上。channels和connections被命名为进程内的一个整数标识符。而客户端connections直接映射为文件描述符。
架构上,通过映射client connections连接到FDs上,消除了另外一层转换。我们不用管基于文件描述符的消息发送到哪里,只需把消息发送到这个文件描述符即可(也就是connection ID)
Function | Description |
---|---|
ChannelCreate() | Create a channel to receive messages on. |
ChannelDestroy() | Destroy a channel. |
ConnectAttach() | Create a connection to send messages on. |
ConnectDetach() | Detach a connection. |
Figure 23: Connections map elegantly into file descriptors.
对于服务进程来说应该实现如下事件循环来接收和处理消息:
chid = ChannelCreate(flags);
SETIOV(&iov, &msg, sizeof(msg));
for(;;) {
rcv_id = MsgReceivev( chid, &iov, parts, &info );
switch( msg.type ) {
/* Perform message processing here */
}
MsgReplyv( rcv_id, &iov, rparts );
}
channel有以及几个消息列表
一个LIFO等待消息线程队列。
一个优先级为FIFO线程队列, 线程已经发送消息但是还没有接收到
一个未排序的线程列表,已经发送且被收到,但是还没有reply
在任何一个列表中,等待线程被阻塞。可以有多个线程和多个客户端在一个channel上等待。
除了同步Send/Receive/Reply服务,OS也支持固定尺寸,非阻塞的消息。比如pulses消息仅携带非常小的负载(4字节数据,加一字节code)
Pulses包含相对小的负载: 8 bits code和32bits数据。Pulses经常用做中断处理函数的提示机制。Pulses允许服务通知client,无需阻塞这些客户端。
一个服务进程接收消息和pulses按照优先级顺序。当服务内的线程接收到请求,他们继承了发送线程的优先级,因此,向Server发起请求的线程优先级被保留,server以客户端线程的优先级运行。这个消息驱动的优先级集成可以避免优先级反转问题。
例如,假定系统包含如下线程。
如果没有优先级继承,那么如果T2发送一条消息给server,它的有效优先级变成了server的22,所以T2的优先级被反转了。
当server收到一个消息,它的有效优先级变成了消息发送者的最高优先级。在这个情况下,T2的优先级低于服务器的优先级,所以当server收到这个消息时,它的有效优先级被替换为T2的优先级。
接下来,假定T1发送了消息给server,而它的优先级高于当前server的优先级,当T1发送了消息后,server的优先级发生了变化。
在server接收到消息后,需要更改优先级,避免另外一种优先级反转。如果server的优先级保持在10不变,另外一个线程T3运行在优先级11,server不得不等待T3一段时间才会去接收T1的消息。也就是T1被一个低优先级的线程T3耽搁了。
可以在调用ChannelCreate指定_NTO_CHF_FIXED_PRIORITY标记,关闭优先级继承。如果你正在使用adaptive partitioning,这个标记也促使接收线程不要运行在发送线程的partitions上。
消息传递API包含如下函数
Function | Description |
---|---|
MsgSend() | Send a message and block until reply. |
MsgReceive() | Wait for a message. |
MsgReceivePulse() | Wait for a tiny, nonblocking message (pulse). |
MsgReply() | Reply to a message. |
MsgError() | Reply only with an error status. No message bytes are transferred. |
MsgRead() | Read additional data from a received message. |
MsgWrite() | Write additional data to a reply message. |
MsgInfo() | Obtain info on a received message. |
MsgSendPulse() | Send a tiny, nonblocking message (pulse). |
MsgDeliverEvent() | Deliver an event to a client. |
MsgKeyData() | Key a message to allow security checks. |
通过Send/Receive/Reply构造QNX应用为一组合作的进程和线程,使得系统使用同步通知。IPC因此发生在系统特定状态转换,而不是异步的操作。
异步系统的一个很重要问题就是:事件通知需要信号处理函数运行。异步IPC使得系统很难完全测试系统操作,并确保无论处理函数运行何时信号,处理将像需要的运行。
使用Send/Receive/Reply构造的同步,非队列系统架构的应用程序是非常容易实现的。
当我们用各种队列IPC,共享内存和其他五花八门同步原语构造应用时,避免死锁是一个困难的问题。比如,假定线程A不会释放mutex 1直到线程B释放了mutex 2。不幸的是线程B所在的状态不会释放mutex 2,直到线程A释放mutex1,导致了死锁。模拟工具可以用来检测系统是否会发生死锁。
Send/Receive/Reply IPC原语可以很简单的构造出无死锁的系统,只需遵守如下规则:
第一条规则明显的用来避免两个进程互相锁死,第二个规则的原因我们需要进一步解释。如下图是一些合作的进程组。
Figure 25: Threads should always send up to higher-level threads
树状关系中同一级线程之间不会发送消息,发送操作只限于父子之间,而且都是孩子发向父亲。
这种方式的一个例子是client应用发送消息到database服务进程,然后database进程发送到filesystem进程。因为发送线程阻塞等待目标线程应答,因此目标线程不应该发送消息给发送线程,否则会发生死锁。
但是线程树中的高级线程如何通知低级别线程之前请求的操作结果呢?(这里假定低级线程不想等待最后一次发送的响应结果)。
QNX提供了一种非常灵活的架构,MsgDeliverEvent()内核调用发送非阻塞事件。所有的异步服务都可以使用这个函数实现。比如,服务器端select()调用是一个API,应用程序可以用它来等待I/O事件的完成。此外异步通知机制可以作为反向通道,高级线程发送消息给低级线程,我们也可以使用它构造 timers,hardware总端或者其他事件源的通知系统。
Figure 26: A higher-level thread can send a pulse event
还有一个问题是,高级线程如何请求低级线程执行某些工作,而无需冒着发送死锁风险。低级线程作为一个工作线程,服务于高级线程,执行高级线程请求的工作。低级线程发送工作汇报,高级线程并不会响应这个发送。效果上,高级线程的notify用来启动工作,低级线程用发送消息汇报执行效果。
QNX内核中非常先进的的一个设计是事件处理子系统。POSIX和它的实时扩展定义了一定数目的异步通知方法(例如,UNIX信号不会入队或者传输数据,POSIX实时信号可以入队和传输数据)
内核也定义了额外的,QNX特定的通知机制,比如pulses。实现这些事件机制需要消耗一定的代码空间,所以我们的实现策略是在一个简单,丰富的事件子系统上构造这些特定的实现。
一个执行线程收到的事件有如下来源:
时间本身可以有如下不同类型:
QNX pulses,中断,各种形式的信号,以及强制unblock事件。Unblock是一种方法,可以解除正在阻塞的线程,不要要显示的发送正在实际等待的事件。
给定这些事件类型,应用程序需要能力请求究竟哪一个异步事件通知技术最适合他们的需求,请求server进程执行代码支持所有选项不大可行。
Client线程可以指定一个数据结构或者cookie给server。当server需要通知client线程,Server调用MsgDeliverEvent()然后microkernel设置时间类型到client线程的cookie中
Figure 27: The client sends a sigevent to the server
ionotify()函数client线程请求异步事件发送的一种方法。
一些POSIX异步服务(比如mq_notify和client端select()操作)是建立在ionotify之上的。当在某个文件描述符上执行I/O,线程可以选择等待I/O事件完成或者数据到达。而不是线程阻塞在执行读写请求的资源管理进程,ionotify()可以允许client线程发送一个事件给资源管理器,client线程需要在指定I/O条件发生时收到通知。使用这种方式,允许线程继续执行和响应其他事件源。
select()调用使用I/O通知实现,允许一个线程阻塞等待多个fd上的多种I/O事件的发生。
下面是请求事件发生的条件:
OS支持32种标准的POSIX信号(像UNIX中),以及POSIX实时信号。POSIX标准定义实时信号不同于UNIX-style信号
POSIX定义了一组非阻塞的消息传送能力,称为消息队列。和pipe类似,消息队列是命名对象,供readers和writers操作。消息队列和pipe相比有更多的结构,在通信过程中,提供了更多的控制。
和消息传递原语不同,POSIX消息队列是在kernel外部实现的
POSIX消息队列为实时系统开发者提供了熟悉的接口。类似于实时系统中的邮箱。
QNX消息队列和POSIX消息队列有根本的不同。我们的消息块数据是直接在发送进程和接收线程地址空间复制。而POSIX消息队列,则实现了存储转发设计,发送者不会阻塞并且可以有很多消息排队。POSIX消息队列是独立于使用他们的线程存在的。多个命名消息队列可以被不同的进程操作。
从性能角度来说,POSIX消息队列要比QNX消息传送数据慢。但是,消息队列带来的灵活性,值得我们牺牲这点性能。