JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

管線與 Shell 管線

兩個行程各有獨立、被牆隔開的記憶體——那一個行程要怎麼把位元組交給另一個?最古老、也最簡單的答案是管線:核心裡一塊單向的緩衝區,一個行程往裡頭寫、另一個從裡頭讀。本篇要從零把管線造起來,然後讓你看見,正是它在驅動著你想都不想就打出來的那條日常 `ls | grep | wc`。

兩個行程之間的那道牆

到了這裡,你已經把一個辛苦掙來的事實刻進骨子裡了:每個行程都有它自己私有的位址空間。0x7fffe3a0 這個號碼在一個行程裡,和同一個號碼在另一個行程裡,指向的是兩個完全不同的位元組;核心與記憶體管理單元會確保如此。這份隔離是一份禮物——一支失控的程式沒辦法在你的記憶體上亂塗——但只要你想讓兩支程式協作,它立刻拋出一個尖銳的問題。如果行程 A 算出了某個東西、行程 B 需要它,而兩者都伸不進對方的記憶體,那這個值要怎麼越過那道牆?這整個問題有個名字:行程間通訊(IPC),而本階就是一趟巡禮,看看 Unix 給出了哪些答案。

最最開頭的那個答案,也是幾乎其他一切都拿來和它比較的那個,就是管線(pipe)。這幅圖正好就是那個日常用語的字面意思:管線是一條單向的通道。位元組從一頭進去、從另一頭出來,按順序,它做的就只有這件事。關鍵的轉折在於管線住在哪。它不在行程 A 的記憶體裡,也不在行程 B 的——它是一塊核心握著的小緩衝區,坐落在那片無人之境裡,兩個行程都能請核心去碰它。A 藉由對它那一頭呼叫 write() 來寫入;B 藉由對它那一頭呼叫 read() 來讀取;核心則在兩者之間、在那道兩個行程都無法直接跨越的牆之間,來回傳遞那些位元組。

親手造一條管線:pipe() 與 fork()

我們來做一條,因為它的機件很短,而且它解釋了接下來的一切。單獨一個呼叫 pipe(),請核心建立那塊緩衝區,並一口氣交回兩個描述符:一個是讀取端、一個是寫入端。你傳給它一個兩元素的陣列,它把陣列填好——依照慣例,fd[0] 是你拿來讀的那一頭、fd[1] 是你拿來寫的那一頭(一個小口訣:0 長得像你早就認識的輸入槽、1 像輸出)。在這一刻,兩個描述符都住在同一個行程裡,這還沒什麼用:一條你拿來跟自己講話的管線,不過是一種慢吞吞的複製記憶體的方式。

魔法發生在下一步,而它重用了你已經見過的東西:fork()。回想一下,fork() 複製了行程,而子行程繼承了父行程整張描述符表的一份複本。所以在 pipe() 之後接著 fork(),兩個行程都握著同一條管線的兩頭——fd[0] 與 fd[1] 如今從兩個不同的行程內部,指名同一塊核心緩衝區。那塊跨越 fork 被繼承下來的共享緩衝區,就是那條通道。從這裡開始,剩下的不過是記帳:每一邊把自己不會用的那一頭關掉,好讓這條管線變成嚴格單向的。父行程也許只留寫入端、子行程只留讀取端,於是現在位元組往一個方向流,從父到子。

int fd[2];
if (pipe(fd) == -1) { perror("pipe"); return 1; }  /* fd[0]=read, fd[1]=write */

pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }

if (pid == 0) {                 /* CHILD: will only READ */
    close(fd[1]);               /* close the write end we won't use */
    char buf[64];
    ssize_t n = read(fd[0], buf, sizeof buf);   /* receive from parent */
    if (n > 0) write(1, buf, n);                /* echo to our stdout */
    close(fd[0]);
    _exit(0);
}

/* PARENT: will only WRITE */
close(fd[0]);                   /* close the read end we won't use */
const char *msg = "hello across the wall\n";
write(fd[1], msg, strlen(msg)); /* send to child */
close(fd[1]);                   /* closing write end signals end-of-file */
waitpid(pid, NULL, 0);          /* reap the child */
一條從父到子的單向管線。形狀永遠都一樣:pipe()、fork(),然後每一邊把自己不用的那一頭關掉。關掉父行程的寫入端,正是最終告訴子行程的 read() 它已抵達檔案結尾的東西。每一個呼叫都有檢查。

為什麼每一邊都得關掉自己多餘的那一頭

那個 close 不是為了整潔——它是承重的,而做錯它正是經典的管線臭蟲。核心遵循的規則是這樣:對一條管線做 read() 只有在那條管線每一個寫入描述符、在每一處都被關掉之後,才會回傳 0,也就是檔案結尾的信號。讀取者問的是「還有誰能寄東西過來嗎?」,而核心只有在「沒有任何一個可寫的端、在任何行程裡還開著」時,才回答「不會再有了」。所以如果父行程忘了把它自己那份讀取端的複本關掉,或子行程留著一份多餘的寫入端複本活著,這世上就還有一個寫入者——於是讀取者的 read() 會永遠卡住,等著一個永遠不會開口的寄件者寄來位元組。

鏡像反過來的那種失敗一樣有教育意義,而那也是一個你再過兩篇就會遇到的信號,第一次露臉的地方。假設讀取者已經離開了——它結束了,或關掉了它的讀取端——但有個寫入者還在不停呼叫 write()。那些位元組無處落腳;永遠不會有人去讀它們。核心不肯讓寫入者永遠堆積垃圾,所以它一口氣做兩件事:write() 以錯誤碼 EPIPE 失敗,而核心對那個寫入中的行程遞送 SIGPIPE 信號,其預設動作是當場把它殺掉。這就是著名的「broken pipe(管線斷裂)」,也正是為什麼當你退掉那支正在讀某條長管線輸出的程式時,整條管線會在那一瞬間停下。

從管線到 Shell 管線:`|` 究竟做了什麼

現在我們能讀懂你打出來的最尋常的那一行了。當你執行 `$ ls | wc -l` 時,那根豎槓不是什麼魔法標點——它是給 shell 的一道指令,要它透過一條管線,把一支程式的標準輸出接到另一支的標準輸入上。shell 用的正是你現在握著的那些零件:它呼叫 pipe() 造出通道、fork 兩次(每支程式一次),並在每個子行程裡,趁 fork 與 exec 之間的那道縫隙、在變成 ls 或 wc 之前,動一場微小的手術。那場手術就是檔案那一階最後一個觀念,dup2() 與重導向:把一個標準描述符重新繫到某個管線端上。

  1. shell 呼叫一次 pipe(fd),為 ls 與 wc 之間的通道拿到一個讀取端 fd[0] 與一個寫入端 fd[1]。
  2. 它 fork 出第一個子行程給 ls。在那個子行程裡,它呼叫 dup2(fd[1], 1)——讓描述符 1(stdout)指向管線的寫入端——然後把 fd[0] 與 fd[1] 都關掉,最後 exec() 成 ls。如今 ls 寫到它平常的 stdout,渾然不知那些位元組是進了管線。
  3. 它 fork 出第二個子行程給 wc。在那個子行程裡,它呼叫 dup2(fd[0], 0)——讓描述符 0(stdin)指向管線的讀取端——把 fd[0] 與 fd[1] 都關掉,再 exec() 成 wc。如今 wc 從它平常的 stdin 讀取,渾然不知那些位元組是來自 ls。
  4. 關鍵在於,父行程 shell 把它自己那兩份 fd[0] 與 fd[1] 的複本關掉。也唯有到那時,wc 才可能看見檔案結尾——因為也唯有到那時,當 ls 一結束,這條管線每一個寫入端才終於全都關上了。

退一步,整條 shell 管線不過就是這支舞重複幾遍。對 `ls | grep foo | wc -l`,shell 造兩條管線、fork 三個子行程,並把每個子行程的 stdin 與 stdout 用 dup2() 接到對的管線端上,好讓前一個的輸出成為下一個的輸入。鏈子裡每一支程式都是用樸素、無聊的那種方式寫成的——讀 stdin、寫 stdout——並且對管線一無所知。那份組合性完完全全活在 shell 的水管工程裡。這正是 Unix 安靜的天才之處:一些各自只做一件事的小工具,用一塊核心緩衝區黏起來,組成它們的作者誰也沒想像過的組合。

管線真正是什麼,以及它在哪裡止步

管線是核心裡一塊小小的、固定大小的緩衝區——在 Linux 上,預設通常是 64 KiB——而正是那個有限的大小,讓它規規矩矩、而不會失控。這塊緩衝區製造了反壓:如果寫入者快、讀取者慢,緩衝區會填滿,於是下一個 write() 就乾脆阻塞,把寫入者停在那兒,直到讀取者排掉一些空間。對稱地,一個對空管線呼叫 read() 的讀取者,會阻塞到有一個位元組到來為止。沒有人需要手動去協調速度;管線的「滿/空」這兩種狀態,自動把兩頭節流成同步前進。這正是檔案那一階的阻塞行為,免費替你做著真正的同步工作。

對它的限制要誠實,因為這些限制塑造了本階其餘部分為何存在。管線是單向的:位元組從寫入端流向讀取端、從不回頭,所以雙向的對話需要兩條管線。它是一條原始的位元組串流,沒有訊息邊界——如果一個寫入者做了三次 write() 呼叫,讀取者可能在一次 read() 裡收到全部黏在一起的位元組,也可能以不同方式切開;管線並不保存「這 N 個位元組曾是一則訊息」。而 pipe() 造出來的管線是匿名的:它在任何地方都沒有名字,所以一個第二行程能抵達它的唯一辦法,就是跨越 fork() 繼承那個描述符。這就是為什麼這裡每個範例都要 fork。兩支彼此無關、已經各自在跑的程式,根本找不到這條管線。

最後那個限制,正是通往本階其餘部分的門。緊接著的下一篇,講具名管線與訊息佇列,它解除了「必須因 fork 而有親緣關係」的限制,好讓兩個陌生人能會合。信號那幾篇處理的是另一種需求——一記微小的、非同步的輕推,而不是一串資料的流動。共享記憶體則會藉由完全移除核心的那一次複製,去追逐純粹的速度。而最後一篇,迎戰的是「同時等待許多個描述符」。但你要牢牢握住管線的形狀,因為它就是那把量尺:往後每一種機制,某種程度上,都是對「一條樸素的匿名管線做不到的某件事」所給出的答案。