匿名管線解不了的那個問題
第 1 篇給你留下了一條能用的匿名管線:pipe() 交回兩個檔案描述符,一個讀、一個寫,再由一次 fork() 讓父行程與子行程各留一端。那之所以行得通,靠的是一個沒明講的假設——這兩個行程是親戚。子行程在 fork 時繼承了父行程已開啟的描述符,所以兩邊本來就握著同一條管線。但匿名管線在任何地方都沒有名字;檔案系統裡沒有任何東西指向它。所以若兩支毫不相干的程式——比方說你現在敲下的一道 shell 命令,和昨天就啟動的一支常駐程式——想交談,誰都喊不出對方那條管線的名字,繼承這條路也走不通。它們需要一個雙方都能各自獨立找到的會合點。
一條具名管線——也叫 FIFO,取先進先出之意——正是那個會合點。它是一條在檔案系統裡被賦予了名字的管線。你用 mkfifo() 這個呼叫(或 mkfifo 這道 shell 命令)建立一條,於是在你選定的路徑、比如 /tmp/myfifo,會冒出一個特殊檔案。那條路徑就是公開的碰面地點:任何看得到這條路徑的行程都能開啟它,不需要任何親屬關係、也不需要繼承。資料的行為仍然像一條管線——位元組單向流動、核心替它緩衝、讀方會阻塞直到有寫方出現——但現在這兩方是透過檔案系統、而不是透過共享的家族樹來找到彼此。
開啟一條 FIFO:阻塞式的會合
因為一條 FIFO看起來就像一個檔案,你用的是你早在檔案描述符那個章節就認識的那組呼叫跟它打交道:open()、read()、write() 與 close()。一邊以讀的方式開啟那條路徑、另一邊以寫的方式開啟,從此它就正是第 1 篇那串位元組流。令人意外的是 open() 本身。預設情況下,開啟一條 FIFO 會阻塞,直到讀方和寫方雙雙到場。以讀的方式開啟它,你的行程就會在 open() 裡頭凍住、等待,直到某個別的行程以寫的方式開啟同一條路徑——反之亦然。那個阻塞式的握手是一項優點:它保證任何一方都不會開始把位元組推進虛空裡。
# terminal 1: create the FIFO, then read from it $ mkfifo /tmp/myfifo $ cat /tmp/myfifo # blocks here, waiting for a writer # terminal 2: open the same name for writing $ echo "hello from a stranger" > /tmp/myfifo # terminal 1 now unblocks and prints: hello from a stranger
其餘的一切都從匿名管線原封不動地延續過來,包括那些尖銳的稜角。一條 FIFO 仍然是單向的:若你需要回覆,就再造一條反方向的 FIFO。核心緩衝區仍然是有上限的(在 Linux 上通常是 64 KiB),所以一個跑得比讀方快的寫方,終究會在 write() 上阻塞。而最重要的那個危害沒有變:若所有讀方都已關閉它們那一端,卻仍有行程呼叫 write(),核心就會發出 SIGPIPE,其預設動作是直接殺掉寫方。那正是第 1 篇警告過的那條斷管線規則——具名管線並不會把它變溫和,所以一支謹慎的程式要嘛處理這個訊號、要嘛去檢查 EPIPE 這個錯誤。
串流沒有接縫:邊界的問題
匿名管線與具名管線共有一個微妙的限制,值得停下來細想,因為它正是訊息佇列被造出來要修好的東西。一條管線是一串 位元組流:它載運的是一條未經切分的位元組之河,不保留任何訊息邊界。假設一個寫方做了三次各自獨立的 write(),分別寫入「AB」、「CD」、「EF」。讀方並沒有被保證會讀到三次、每次兩個位元組。它可能在一次 read() 裡拿到全部六個位元組「ABCDEF」,也可能拿到「ABC」再「DEF」,或任何一種切法。核心只保證位元組的順序,從不保證一則邏輯訊息在哪裡停、下一則從哪裡開始。
這不是一個臭蟲;這就是「串流」的意思,而同樣的性質在後面章節會在一個 TCP 通訊端上咬你一口。但它把工作推到了你身上。若你的協定要送出一筆筆離散的記錄,那你就必須在原始串流之上自己發明一套框幀(framing)方案——一個長度前綴(「接下來有 12 個位元組,然後才是下一則訊息」),或者一個像換行符這樣、你會去掃描而且絕不允許出現在訊息內部的分隔符。許多真實的臭蟲,不過就是一支程式忘了串流可以拆分或合併它的寫入、假設了一次 write() 等於一次 read(),於是把半則訊息當成完整的一則去讀。
訊息佇列:交付整則訊息
一個訊息佇列正是圍繞這個想法打造的核心物件:它儲存並交付的是一則則 離散的訊息,而不是一串扁平的位元組流。當你送出一則 12 位元組的訊息,接收方收到的就是那則 12 位元組的訊息、作為一個不可分割的單位——你寫進去時的邊界,就是它讀出來時的邊界。在 Linux 上,現代的介面是 POSIX 訊息佇列這一族:用 mq_open() 建立或接上一個具名佇列、用 mq_send() 把一則訊息排進去、用 mq_receive() 把一則取出來。它像 FIFO 一樣活在一個名字底下(一個類似路徑的字串,比如「/myqueue」),所以不相干的行程能找到它;但與 FIFO 不同的是,傳輸的單位是整則訊息。
一個訊息佇列還丟進了兩樣原始管線從不提供的附加品。第一,核心內的持續性:這個佇列與它尚未被取走的訊息,即便當下沒有任何行程把它開著也能存活,所以一個傳送方可以現在就丟下訊息,而一個較晚才啟動的接收方,仍能找到它們在那裡等著——相對地,一條管線在它最後一個描述符關閉的那一刻就蒸發了。第二,優先序:每則訊息帶有一個優先序號碼,而 mq_receive() 永遠交回當下可取的最高優先序訊息,所以一筆緊急的記錄可以插到一般記錄前面,而不必在一條嚴格的隊伍尾端等待。這兩樣,純位元組流的管線都辦不到。
這一切都不是免費的,而在這裡誠實很重要。一個訊息佇列有一個有上限的容量——一個訊息數量的上限,以及一個訊息大小的上限,都在佇列建立時就固定下來——所以一旦佇列滿了,mq_send() 就會阻塞(或者失敗,若你以非阻塞方式開啟佇列),正是一條滿了的管線所施加的同一種反壓。而且每一次傳送仍然會把訊息位元組複製著穿過核心,就跟管線一樣。那份複製的成本,正是這個章節第 4 篇要轉向共用記憶體的理由——在那裡兩個行程映射同一批實體分頁、交換資料時完全不複製——這是最快的 IPC,代價是要自己做同步。
在它們之間選擇,以及接下來是什麼
那你該怎麼挑?當資料確實是一串流、而你想要某種簡單到不行、又能讓 shell 來驅動的東西時,伸手去拿一條具名管線——一條 FIFO 只差幾行程式和一道 mkfifo,而像 cat、grep 與重導向這些工具,全都當它是個檔案似地跟它對話。當你要傳遞的是離散的記錄、而你想讓核心替你保住它們的邊界時,當你需要一則訊息撐過一段沒有活躍讀方的短暫空檔時,或者當優先序很重要時,伸手去拿一個訊息佇列。這兩者都讓不相干的行程透過一個名字碰面——那個共享的命名,正是越過第 1 篇那條繼承來的匿名管線的那一躍。
在我們往下走之前,有一個誠實的提醒:這兩者都不是唯一保留記錄的選項,而在現代的程式碼裡,很多人兩者都跳過。一個 Unix 網域通訊端——在這個章節稍後會講到——同樣讓不相干的本機行程交談,在一條連線上是雙向的,能在它的資料報模式下保留訊息邊界,而且就是你之後會在網路上重用的那同一套程式設計模型。許多正式環境的系統,正是為了那份一致性而偏好它、勝過 POSIX 訊息佇列。這些當中沒有哪一個是放諸四海皆準的最佳;每一個都是「簡單、訊息框幀、持續性、原始速度」這個取捨上的一個不同落點。
你現在握有的行程間通訊地圖,比第 1 篇給你的完整許多:匿名管線給親戚、具名管線給想要一串流的陌生人、訊息佇列給想要有上限、有順序、懂優先序之記錄的陌生人。這些當中每一個的共通點是,它們全都讓資料穿過核心移動。下一篇橫跨到一種全然不同的 IPC——傳的不是資料,而是一個一位元的中斷:一個訊號,核心拍拍一個行程的肩膀、告訴它有事發生了的那種方式。在那之後是最快的機制——共用記憶體,最後則是用 select 與 poll 同時等候許多這樣的通道的那門技藝。