從微內核構建全面的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通信形式,消息傳遞是同步操作並且有數據複製。讓我們更詳細的探究這兩個屬性。
Synchronous message passing
同步消息是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.
- 如果client線程調用了MsgSend(),而server線程沒有調用msgReceive(),那麼client線程變爲SEND阻塞狀態。一旦server線程調用了MsgReceive(),內核改變client線程狀態爲REPLY阻塞,表示server線程已經收到了消息,現在必須發送reply。當服務線程調用了MsgReply(),client線程變成READY狀態。
- 如果client線程調用MsgSend(),而Server線程阻塞在MsgReceive(),那麼Client線程立刻變爲REPLY阻塞,完全忽略掉SEND-blocked狀態。
- 如果server線程失敗,退出或者消失,Client線程變爲READY,MsgSend()調用返回一個錯誤。
Figure 20: Changes of state for a server thread in a send-receive-reply transaction.
- 如果Server線程調用了MsgReceive(),並且沒有其他線程發送消息給Server線程,那麼Server線程變成RECEIVE阻塞。當另外一個線程發送給它,那麼Server線程變成READY。
- 如果server線程調用MsgReceive(),另外一個線程已經發送了消息給它,那麼MsgReceice()立刻返回。這種情況下,server不會阻塞。
- 如果server線程調用MsgReply(),那麼線程不會阻塞。
發送線程是天生具有同步執行屬性的,因爲發送數據請求促使發送線程阻塞,接收線程被調度執行。不需要其他kernel工作決定哪個線程限制性。執行和數據移動直接從一個上下文轉到另外一個。
這些消息原語並不包括數據隊列能力,如有必要,可以在接收線程實現排隊。發送線程通常只是等待一個響應,隊列會帶來不要的負載和負載性。因此,發送線程不需要單獨的,顯示的阻塞調用來等待響應。
發送和接收操作是阻塞的和同步的,而MsgReply()或者MsgError()則不會阻塞。因爲Client線程已經阻塞等待響應,所以不需要額外的同步操作。這允許server響應一個消息後繼續處理,kernel或者其他網絡代碼異步的傳輸應答數據給發送線程並改變發送線程爲準備執行狀態。因爲大部分servers是趨向於做些處理準備介紹下一次請求,所以工作良好。
MsgReply() vs MsgError()
MsgReply()函數用來返回狀態以及一些數據給Client。MsgError()僅僅返回client的狀態。這兩個函數都會解除client的阻塞狀態。
Message copying
因爲消息服務直接複製消息到另外一個進程的地址空間,而不需要中間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 <unistd.h>
#include <errno.h>
#include <sys/iomsg.h>
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要快。對於大塊的數據傳輸,進程間的共享內存通常是更好的選擇。
Simple messages
對於簡單的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() |
Channels and connections
在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上connection的接收消息。
channel有以及幾個消息列表
- Receive
一個LIFO等待消息線程隊列。
- Send
一個優先級爲FIFO線程隊列, 線程已經發送消息但是還沒有接收到
- Reply
一個未排序的線程列表,已經發送且被收到,但是還沒有reply
在任何一個列表中,等待線程被阻塞。可以有多個線程和多個客戶端在一個channel上等待。
Pulses
除了同步Send/Receive/Reply服務,OS也支持固定尺寸,非阻塞的消息。比如pulses消息僅攜帶非常小的負載(4字節數據,加一字節code)
Pulses包含相對小的負載: 8 bits code和32bits數據。Pulses經常用做中斷處理函數的提示機制。Pulses允許服務通知client,無需阻塞這些客戶端。
Priority inheritance and messages
一個服務進程接收消息和pulses按照優先級順序。當服務內的線程接收到請求,他們繼承了發送線程的優先級,因此,向Server發起請求的線程優先級被保留,server以客戶端線程的優先級運行。這個消息驅動的優先級集成可以避免優先級反轉問題。
例如,假定系統包含如下線程。
- 一個服務線程,優先級22
- client線程T1,優先級13
- client線程T2,優先級10
如果沒有優先級繼承,那麼如果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上。
Message-passing API
消息傳遞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. |
Robust implementations with Send/Receive/Reply
通過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用來啓動工作,低級線程用發送消息彙報執行效果。
Events
QNX內核中非常先進的的一個設計是事件處理子系統。POSIX和它的實時擴展定義了一定數目的異步通知方法(例如,UNIX信號不會入隊或者傳輸數據,POSIX實時信號可以入隊和傳輸數據)
內核也定義了額外的,QNX特定的通知機制,比如pulses。實現這些事件機制需要消耗一定的代碼空間,所以我們的實現策略是在一個簡單,豐富的事件子系統上構造這些特定的實現。
一個執行線程收到的事件有如下來源:
- 某個線程調用了MsgDeliverEvent()
- 一箇中斷處理handler
- 一個timer超時
時間本身可以有如下不同類型:
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
I/O notification
ionotify()函數client線程請求異步事件發送的一種方法。
一些POSIX異步服務(比如mq_notify和client端select()操作)是建立在ionotify之上的。當在某個文件描述符上執行I/O,線程可以選擇等待I/O事件完成或者數據到達。而不是線程阻塞在執行讀寫請求的資源管理進程,ionotify()可以允許client線程發送一個事件給資源管理器,client線程需要在指定I/O條件發生時收到通知。使用這種方式,允許線程繼續執行和響應其他事件源。
select()調用使用I/O通知實現,允許一個線程阻塞等待多個fd上的多種I/O事件的發生。
下面是請求事件發生的條件:
- _NOTIFY_COND_OUTPUT - output buffer有空間接收數據
- _NOTIFY_COND_INPUT - 資源管理器定義數目的數據可讀
- _NOTIFY_COND_OBAND - 資源管理器定義 帶外數據可用。
Signals
OS支持32種標準的POSIX信號(像UNIX中),以及POSIX實時信號。POSIX標準定義實時信號不同於UNIX-style信號
POSIX message queues
POSIX定義了一組非阻塞的消息傳送能力,稱爲消息隊列。和pipe類似,消息隊列是命名對象,供readers和writers操作。消息隊列和pipe相比有更多的結構,在通信過程中,提供了更多的控制。
和消息傳遞原語不同,POSIX消息隊列是在kernel外部實現的
Why use POSIX message queues?
POSIX消息隊列爲實時系統開發者提供了熟悉的接口。類似於實時系統中的郵箱。
QNX消息隊列和POSIX消息隊列有根本的不同。我們的消息塊數據是直接在發送進程和接收線程地址空間複製。而POSIX消息隊列,則實現了存儲轉發設計,發送者不會阻塞並且可以有很多消息排隊。POSIX消息隊列是獨立於使用他們的線程存在的。多個命名消息隊列可以被不同的進程操作。
從性能角度來說,POSIX消息隊列要比QNX消息傳送數據慢。但是,消息隊列帶來的靈活性,值得我們犧牲這點性能。