IPC 的具體實作 (Examples of IPC Systems)
本系列文章內容參考自經典教材 Operating System Concepts, 10th Edition (Silberschatz, Galvin, Gagne)。本文對應章節:Section 3.7 Examples of IPC Systems。
Section 3.4 與 3.5 介紹了 IPC 的兩種基本模型(共享記憶體與訊息傳遞)及抽象概念,Section 3.7 則以四個具體系統為例,說明這些模型在真實環境中的實作方式:
| IPC 系統 | 模型 | 核心機制 |
|---|---|---|
| POSIX Shared Memory | 共享記憶體 | 記憶體映射檔案,讓多個行程的虛擬位址指向同一塊實體記憶體 |
| Mach | 訊息傳遞 | 以 Port 為通訊端點,利用虛擬記憶體映射取代資料複製 |
| Windows ALPC | 訊息傳遞(含共享記憶體) | 依訊息大小自動選擇三種傳遞策略 |
| Pipes | 資料流 | 最早的 UNIX IPC 機制,以檔案描述符為介面的單向資料通道 |
3.7.1 POSIX 共享記憶體
記憶體映射檔案的概念
Section 3.5 介紹了共享記憶體的抽象概念:兩個行程必須建立一塊共用的記憶體區域才能通訊。但在實作層面有一個根本問題:一個行程要如何「找到」另一個行程建立的記憶體區域? 若直接分配記憶體,這塊區域只存在於建立它的行程的位址空間中,其他行程根本不知道要去哪裡找它。
POSIX 共享記憶體以記憶體映射檔案 (Memory-Mapped File) 解決這個問題:給共享記憶體區域一個名稱 (Name),讓這個名稱像一個檔案一樣存在於系統中。任何行程只要知道這個名稱,就能透過 mmap() 將其映射到自己的虛擬位址空間,OS 會確保兩個行程的指標都指向同一塊實體 記憶體。這樣一來,行程之間的通訊就等同於直接讀寫自己的記憶體,不需要 Kernel 中介,速度極快。
三個關鍵 API
建立 POSIX 共享記憶體區域需要三個系統呼叫,每個負責不同的職責:
第一步:建立(或開啟)共享記憶體物件
fd = shm_open(name, O_CREAT | O_RDWR, 0666);
shm_open() 的行為與 open() 開啟一般檔案完全相同,差別是它操作的對象是共享記憶體物件而非磁碟上的實體檔案。
name:共享記憶體物件的識別名稱,其他行程必須用相 同的名稱才能開啟同一塊共享記憶體。O_CREAT | O_RDWR:「若不存在則建立,開啟讀寫權限」。消費者行程用O_RDONLY以唯讀方式開啟。- 呼叫成功後回傳一個整數檔案描述符 (File Descriptor)。
第二步:設定大小
ftruncate(fd, 4096);
新建立的共享記憶體物件大小為零,必須用 ftruncate() 指定大小(此例為 4096 bytes)。
第三步:映射到位址空間
ptr = (char *) mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
mmap() 將共享記憶體物件映射到行程的虛擬位址空間,回傳指標 ptr。之後對 ptr 的所有讀寫直接等同於對共享記憶體的讀寫,效率等同一般記憶體存取。MAP_SHARED 旗標確保對這塊記憶體的修改對所有共享此物件的行程立即可見。
下圖說明生產者與消費者各自呼叫 mmap() 後,兩個行程的虛擬指標如何指向同一塊共享記憶體物件:
生產者在自己的虛擬位址空間中取得一個 ptr,消費者也在自己的空間中取得另一個 ptr,但這兩個指標底層都映射到同一塊實體記憶體。生產者對 ptr 寫入的資料,消費者的 ptr 能直接讀到,整個過程完全不需要 Kernel 複製資料。這正是共享記憶體在大量資料傳輸時比訊息傳遞更有效率的原因。
Producer-Consumer 完整範例
以下是生產者(建立共享記憶體並寫入字串)與消費者(讀取後移除物件)的完整程式碼。
生產者:
const int SIZE = 4096;
const char *name = "OS";
int fd;
char *ptr;
/* 建立共享記憶體物件 */
fd = shm_open(name, O_CREAT | O_RDWR, 0666);
/* 設定大小 */
ftruncate(fd, SIZE);
/* 映射到位址空間 */
ptr = (char *) mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
/* 寫入資料:ptr 指標直接當成記憶體使用 */
sprintf(ptr, "%s", "Hello");
ptr += strlen("Hello");
sprintf(ptr, "%s", "World!");
消費者:
const int SIZE = 4096;
const char *name = "OS";
int fd;
char *ptr;
/* 開啟已存在的物件(唯讀) */
fd = shm_open(name, O_RDONLY, 0666);
/* 映射到位址空間 */
ptr = (char *) mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
/* 讀取 */
printf("%s", (char *) ptr);
/* 移除共享記憶體物件 */
shm_unlink(name);
shm_unlink() 移除共享記憶體物件的名稱,使其無法再被新行程開啟。但若仍有行程持有開啟的描述符或現有的映射,這塊記憶體的內容不會立即消失,直到所有行程都關閉或解除映射為止。這與 UNIX 檔案系統的 unlink() 語意相同:刪除名稱,但保留資料直到所有參考都結束。
3.7.2 Mach 訊息傳遞
Mach 的設計哲學
Mach 是一個專為分散式系統 (Distributed Systems) 設計的作業系統核心,後來也被證明適用於桌上型與行動裝置(macOS 和 iOS 都以 Mach Kernel 為基礎,如 Section 2.8 所述)。Mach 的核心設計原則是:幾乎所有通訊都透過訊息傳遞完成,包括所有行程間(在 Mach 中稱為 Task 間)的互動。
Mach 中的 Task 類似於一般作業系統的行程,但包含多個執行緒 (Thread) 且關聯的資源較少。可以 把 Task 想像成輕量化的行程容器,內部的多個執行緒共享 Task 的所有資源,包括 Port Rights。
Port 與 Port Rights
Mach 以 Port(通訊埠) 作為訊息傳遞的端點。理解 Port 最直觀的方式,是把它想像成一個實體郵箱 (Mailbox):
- 郵箱本身(Port) 屬於能「拿鑰匙開信箱、取出信件」的那個人。
- 其他人持有的投件權限讓他們能「把信件投入信箱門縫」,但無法開箱讀信。
這個比喻對應到 Mach 的 Port Rights(通訊埠權限) 機制:
| Port Right | 對應郵箱比喻 | 說明 |
|---|---|---|
MACH_PORT_RIGHT_RECEIVE | 信箱鑰匙 | 持有此權限才能從 Port 取出(接收)訊息。同一個 Port 只能有一個 RECEIVE right 持有者,即 Port 的唯一擁有者 (owner),也就是建立這個 Port 的 Task |
MACH_PORT_RIGHT_SEND | 投件權限 | 持有此權限才能向 Port 放入(傳送)訊息。擁有者可以把這個權限授予給任意多個 Task |
Port Rights 的範圍是 Task 層級,同一個 Task 內的所有執行緒共享相同的 Port Rights。
「多對一」的直覺解釋
Port 的所有特性都源 於「一把鑰匙、多個投件人」這個設計:
- 一個接收者(one receiver):RECEIVE right 只有一份,只有 Port 的擁有者能讀訊息,這就是「一」的那一側。
- 多個傳送者(many senders):SEND right 可以被分發給任意多個 Task,這些 Task 的訊息都會依序進入 Port 的訊息佇列。這就是「多」的那一側。
Server Task 建立 Port P_server 並持有 RECEIVE right(信箱擁有者)。System 透過 Bootstrap Server 讓所有 Client 都拿到 P_server 的 SEND right(投件權限)。每個 Client 都能向 P_server 投送請求,所有請求依序排入 Queue,但只有 Server 自己能逐一取出並處理。這正是「多個 Client 對應一個 Server Port」的結構。
Port 的其他特性
- 有限大小 (Finite):訊息佇列的容量有限,若已滿,傳送方必須等待或選擇其他策略(後面會介紹四種選項)。
- 單向 (Unidirectional):一個 Port 只能「從外部收進訊息、擁有者讀出訊息」,資料流只有一個方向。若要雙向通訊,必須建立兩個 Port,一個給 T1 接收、另一個給 T2 接收。
以具體場景說明:Task T1 擁有 Port P1,它要傳訊息給 Task T2 擁有的 Port P2,並期望 T2 回覆。T1 必須先將 P1 的 MACH_PORT_RIGHT_SEND 授予 T2,T2 才有權限回覆到 P1。
下圖展示這個雙 Port 通訊模型的完整結構:T1 持有 P1(RECEIVE right)並持有 P2 的 SEND right;T2 持有 P2(RECEIVE right)並持有 P1 的 SEND right(由 T1 在訊息 Header 中授予):
圖中兩支箭頭揭示了 Mach 雙向通訊的核心機制:T1 傳送訊息時,在 msgh_remote_port 填入目的地 P2,同時在 msgh_local_port 填入自己的 P1 作為「回信地址 (return address)」。T2 收到訊息後,從 Header 讀取這個回信地址,再用它向 P1 傳送回覆。這正是為什麼 Mach 的雙向通訊必須使用兩個 Port:一個接收請求,另一個接收回覆,兩者方向不同,各自有獨立的 RECEIVE 所有權。
系統保留 Port
每個 Task 建立時,Mach Kernel 自動建立兩個特殊 Port:
| Port | 用途 |
|---|---|
| Task Self Port | Kernel 持有此 Port 的接收權,允許 Task 傳訊息給 Kernel(用於系統呼叫等) |
| Notify Port | Task 持有此 Port 的接收權,Kernel 可透過此 Port 傳送事件通知給 Task |
Port 的建立
以 mach_port_allocate() 建立一個新 Port 並分配其訊息佇列空間:
mach_port_t port; // Port 的名稱(整數值,類似 UNIX 的 file descriptor)
mach_port_allocate(
mach_task_self(), // 指向自身 Task
MACH_PORT_RIGHT_RECEIVE, // 建立具有接收權的 Port
&port // 回傳 Port 名稱
);
Port 的「名稱」是一個簡單的整數值,行為就像 UNIX 的 file descriptor。每個 Task 還有一個 Bootstrap Port,可透過它向系統全域的 Bootstrap Server 登記 Port,讓其他 Task 查詢後取得傳送訊息所需的權限。
訊息結構
Mach 訊息由兩部分組成:
| 部分 | 大小 | 內容 |
|---|---|---|
| Header(標頭) | 固定大小 | 訊息大小、來源 Port、目的地 Port 等後設資料 (Metadata) |
| Body(本體) | 可變大小 | 實際資料 |
訊息分為兩種類型:
- Simple Message(簡單訊息):本體包含普通的非結構化使用者資料,Kernel 不解讀其內容。
- Complex Message(複雜訊息):本體可包含指向記憶體位址的指標(稱為 "out-of-line" 資料),或用於轉移 Port Rights 給另一個 Task。Out-of-line 資料特別適合傳輸大量資料:不需複製資料本身,只需傳遞一個指標,接收方自行從指定位址讀取。