Linux 的程式間通訊:管道

qcloud發表於2019-03-06

> 本文由雲 + 社群發表

> 作者:鄒立巍

版權宣告:

本文章內容在非商業使用前提下可無需授權任意轉載、釋出。

轉載、釋出請務必註明作者和其微博、微信公眾號地址,以便讀者詢問問題和甄誤反饋,共同進步。

微博IDorroz

微信公眾號:Linux系統技術

前言

管道是 UNIX 環境中歷史最悠久的程式間通訊方式。本文主要說明在 Linux 環境上如何使用管道。閱讀本文可以幫你解決以下問題:

  1. 什麼是管道和為什麼要有管道?
  2. 管道怎麼分類?
  3. 管道的實現是什麼樣的?
  4. 管道有多大?
  5. 管道的大小是不是可以調整?如何調整?

什麼是管道?

管道,英文為 pipe。這是一個我們在學習 Linux 命令列的時候就會引入的一個很重要的概念。它的發明人是道格拉斯.麥克羅伊,這位也是 UNIX 上早期 shell 的發明人。他在發明了 shell 之後,發現系統操作執行命令的時候,經常有需求要將一個程式的輸出交給另一個程式進行處理,這種操作可以使用輸入輸出重定向加檔案搞定,比如:

[zorro@zorro-pc pipe]$ ls  -l /etc/ > etc.txt
[zorro@zorro-pc pipe]$ wc -l etc.txt 
183 etc.txt

但是這樣未免顯得太麻煩了。所以,管道的概念應運而生。目前在任何一個 shell 中,都可以使用 “|” 連線兩個命令,shell 會將前後兩個程式的輸入輸出用一個管道相連,以便達到程式間通訊的目的:

[zorro@zorro-pc pipe]$ ls -l /etc/ | wc -l
183

對比以上兩種方法,我們也可以理解為,管道本質上就是一個檔案,前面的程式以寫方式開啟檔案,後面的程式以讀方式開啟。這樣前面寫完後面讀,於是就實現了通訊。實際上管道的設計也是遵循 UNIX 的 “一切皆檔案” 設計原則的,它本質上就是一個檔案。Linux 系統直接把管道實現成了一種檔案系統,藉助 VFS 給應用程式提供操作介面。

雖然實現形態上是檔案,但是管道本身並不佔用磁碟或者其他外部儲存的空間。在 Linux 的實現上,它佔用的是記憶體空間。所以,Linux 上的管道就是一個操作方式為檔案的記憶體緩衝區。

管道的分類和使用

Linux 上的管道分兩種型別:

  1. 匿名管道
  2. 命名管道

這兩種管道也叫做有名或無名管道。匿名管道最常見的形態就是我們在 shell 操作中最常用的”|”。它的特點是隻能在父子程式中使用,父程式在產生子程式前必須開啟一個管道檔案,然後 fork 產生子程式,這樣子程式通過拷貝父程式的程式地址空間獲得同一個管道檔案的描述符,以達到使用同一個管道通訊的目的。此時除了父子程式外,沒人知道這個管道檔案的描述符,所以通過這個管道中的資訊無法傳遞給其他程式。這保證了傳輸資料的安全性,當然也降低了管道了通用性,於是系統還提供了命名管道。

我們可以使用 mkfifo 或 mknod 命令來建立一個命名管道,這跟建立一個檔案沒有什麼區別:

[zorro@zorro-pc pipe]$ mkfifo pipe
[zorro@zorro-pc pipe]$ ls -l pipe 
prw-r--r-- 1 zorro zorro 0 Jul 14 10:44 pipe

可以看到建立出來的檔案型別比較特殊,是 p 型別。表示這是一個管道檔案。有了這個管道檔案,系統中就有了對一個管道的全域性名稱,於是任何兩個不相關的程式都可以通過這個管道檔案進行通訊了。比如我們現在讓一個程式寫這個管道檔案:

[zorro@zorro-pc pipe]$ echo xxxxxxxxxxxxxx > pipe 

此時這個寫操作會阻塞,因為管道另一端沒有人讀。這是核心對管道檔案定義的預設行為。此時如果有程式讀這個管道,那麼這個寫操作的阻塞才會解除:

[zorro@zorro-pc pipe]$ cat pipe 
xxxxxxxxxxxxxx

大家可以觀察到,當我們 cat 完這個檔案之後,另一端的 echo 命令也返回了。這就是命名管道。

Linux 系統無論對於命名管道和匿名管道,底層都用的是同一種檔案系統的操作行為,這種檔案系統叫 pipefs。大家可以在/etc/proc/filesystems 檔案中找到你的系統是不是支援這種檔案系統:

[zorro@zorro-pc pipe]$ cat /proc/filesystems |grep pipefs
nodev    pipefs

觀察完了如何在命令列中使用管道之後,我們再來看看如何在系統程式設計中使用管道。

PIPE

我們可以把匿名管道和命名管道分別叫做 PIPE 和 FIFO。這主要因為在系統程式設計中,建立匿名管道的系統呼叫是 pipe(),而建立命名管道的函式是 mkfifo()。使用 mknod() 系統呼叫並指定檔案型別為為 S_IFIFO 也可以建立一個 FIFO。

使用 pipe() 系統呼叫可以建立一個匿名管道,這個系統呼叫的原型為:

#include <unistd.h>

int pipe(int pipefd[2]);

這個方法將會建立出兩個檔案描述符,可以使用 pipefd 這個陣列來引用這兩個描述符進行檔案操作。pipefd[0] 是讀方式開啟,作為管道的讀描述符。pipefd[1] 是寫方式開啟,作為管道的寫描述符。從管道寫端寫入的資料會被核心快取直到有人從另一端讀取為止。我們來看一下如何在一個程式中使用管道,雖然這個例子並沒有什麼意義:

[zorro@zorro-pc pipe]$ cat pipe.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    if (write(pipefd[1], STRING, strlen(STRING)) < 0) {
        perror("write()");
        exit(1);
    }

    if (read(pipefd[0], buf, BUFSIZ) < 0) {
        perror("write()");
        exit(1);
    }

    printf("%s\n", buf);

    exit(0);
}

這個程式建立了一個管道,並且對管道寫了一個字串之後從管道讀取,並列印在標準輸出上。用一個圖來說明這個程式的狀態就是這樣的:

img

一個程式自己給自己傳送訊息這當然不叫程式間通訊,所以實際情況中我們不會在單個程式中使用管道。程式在 pipe 建立完管道之後,往往都要 fork 產生子程式,成為如下圖表示的樣子:

img

如圖中描述,fork 產生的子程式會繼承父程式對應的檔案描述符。利用這個特性,父程式先 pipe 建立管道之後,子程式也會得到同一個管道的讀寫檔案描述符。從而實現了父子兩個程式使用一個管道可以完成半雙工通訊。此時,父程式可以通過 fd[1] 給子程式發訊息,子程式通過 fd[0] 讀。子程式也可以通過 fd[1] 給父程式發訊息,父程式用 fd[0] 讀。程式例項如下:

[zorro@zorro-pc pipe]$ cat pipe_parent_child.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    pid_t pid;
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

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

    if (pid == 0) {
        /* this is child. */
        printf("Child pid is: %d\n", getpid());
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

        bzero(buf, BUFSIZ);
        snprintf(buf, BUFSIZ, "Message from child: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

    } else {
        /* this is parent */
        printf("Parent pid is: %d\n", getpid());

        snprintf(buf, BUFSIZ, "Message from parent: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

        sleep(1);

        bzero(buf, BUFSIZ);
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

        wait(NULL);
    }


    exit(0);
}

父程式先給子程式發一個訊息,子程式接收到之後列印訊息,之後再給父程式發訊息,父程式再列印從子程式接收到的訊息。程式執行效果:

[zorro@zorro-pc pipe]$ ./pipe_parent_child 
Parent pid is: 8309
Child pid is: 8310
Message from parent: My pid is: 8309
Message from child: My pid is: 8310

從這個程式中我們可以看到,管道實際上可以實現一個半雙工通訊的機制。使用同一個管道的父子程式可以分時給對方傳送訊息。我們也可以看到對管道讀寫的一些特點,即:

在管道中沒有資料的情況下,對管道的讀操作會阻塞,直到管道內有資料為止。當一次寫的資料量不超過管道容量的時候,對管道的寫操作一般不會阻塞,直接將要寫的資料寫入管道緩衝區即可。

當然寫操作也不會再所有情況下都不阻塞。這裡我們要先來了解一下管道的核心實現。上文說過,管道實際上就是核心控制的一個記憶體緩衝區,既然是緩衝區,就有容量上限。我們把管道一次最多可以快取的資料量大小叫做 PIPESIZE。核心在處理管道資料的時候,底層也要呼叫類似 read 和 write 這樣的方法進行資料拷貝,這種核心操作每次可以操作的資料量也是有限的,一般的操作長度為一個 page,即預設為 4k 位元組。我們把每次可以操作的資料量長度叫做 PIPEBUF。POSIX 標準中,對 PIPEBUF 有長度限制,要求其最小長度不得低於 512 位元組。PIPEBUF 的作用是,核心在處理管道的時候,如果每次讀寫操作的資料長度不大於 PIPEBUF 時,保證其操作是原子的。而 PIPESIZE 的影響是,大於其長度的寫操作會被阻塞,直到當前管道中的資料被讀取為止。

在 Linux 2.6.11 之前,PIPESIZE 和 PIPEBUF 實際上是一樣的。在這之後,Linux 重新實現了一個管道快取,並將它與寫操作的 PIPEBUF 實現成了不同的概念,形成了一個預設長度為 65536 位元組的 PIPESIZE,而 PIPEBUF 隻影響相關讀寫操作的原子性。從 Linux 2.6.35 之後,在 fcntl 系統呼叫方法中實現了 F_GETPIPE_SZ 和 F_SETPIPE_SZ 操作,來分別檢視當前管道容量和設定管道容量。管道容量容量上限可以在/proc/sys/fs/pipe-max-size 進行設定。

#define BUFSIZE 65536

......

ret = fcntl(pipefd[1], F_GETPIPE_SZ);
if (ret < 0) {
    perror("fcntl()");
    exit(1);
}

printf("PIPESIZE: %d\n", ret);

ret = fcntl(pipefd[1], F_SETPIPE_SZ, BUFSIZE);
if (ret < 0) {
    perror("fcntl()");
    exit(1);
}

......

PIPEBUF 和 PIPESIZE 對管道操作的影響會因為管道描述符是否被設定為非阻塞方式而有行為變化,n 為要寫入的資料量時具體為:

O_NONBLOCK 關閉,n <= PIPE_BUF:

n 個位元組的寫入操作是原子操作,write 系統呼叫可能會因為管道容量 (PIPESIZE) 沒有足夠的空間存放 n 位元組長度而阻塞。

O_NONBLOCK 開啟,n <= PIPE_BUF:

如果有足夠的空間存放 n 位元組長度,write 呼叫會立即返回成功,並且對資料進行寫操作。空間不夠則立即報錯返回,並且 errno 被設定為 EAGAIN。

O_NONBLOCK 關閉,n > PIPE_BUF:

對 n 位元組的寫入操作不保證是原子的,就是說這次寫入操作的資料可能會跟其他程式寫這個管道的資料進行交叉。當管道容量長度低於要寫的資料長度的時候 write 操作會被阻塞。

O_NONBLOCK 開啟,n > PIPE_BUF:

如果管道空間已滿。write 呼叫報錯返回並且 errno 被設定為 EAGAIN。如果沒滿,則可能會寫入從 1 到 n 個位元組長度,這取決於當前管道的剩餘空間長度,並且這些資料可能跟別的程式的資料有交叉。

以上是在使用半雙工管道的時候要注意的事情,因為在這種情況下,管道的兩端都可能有多個程式進行讀寫處理。如果再加上執行緒,則事情可能變得更復雜。實際上,我們在使用管道的時候,並不推薦這樣來用。管道推薦的使用方法是其單工模式:即只有兩個程式通訊,一個程式只寫管道,另一個程式只讀管道。實現為:

[zorro@zorro-pc pipe]$ cat pipe_parent_child2.c
#include &lt;stdlib.h&gt;
#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
#include &lt;string.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/wait.h&gt;

#define STRING &quot;hello world!&quot;

int main()
{
    int pipefd[2];
    pid_t pid;
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror(&quot;pipe()&quot;);
        exit(1);
    }

    pid = fork();
    if (pid == -1) {
        perror(&quot;fork()&quot;);
        exit(1);
    }

    if (pid == 0) {
        /* this is child. */
        close(pipefd[1]);

        printf(&quot;Child pid is: %d\n&quot;, getpid());
        if (read(pipefd[0], buf, BUFSIZ) &lt; 0) {
            perror(&quot;write()&quot;);
            exit(1);
        }

        printf(&quot;%s\n&quot;, buf);

    } else {
        /* this is parent */
        close(pipefd[0]);

        printf(&quot;Parent pid is: %d\n&quot;, getpid());

        snprintf(buf, BUFSIZ, &quot;Message from parent: My pid is: %d&quot;, getpid());
        if (write(pipefd[1], buf, strlen(buf)) &lt; 0) {
            perror(&quot;write()&quot;);
            exit(1);
        }

        wait(NULL);
    }


    exit(0);
}

這個程式實際上比上一個要簡單,父程式關閉管道的讀端,只寫管道。子程式關閉管道的寫端,只讀管道。整個管道的開啟效果最後成為下圖所示:

img

此時兩個程式就只用管道實現了一個單工通訊,並且這種狀態下不用考慮多個程式同時對管道寫產生的資料交叉的問題,這是最經典的管道開啟方式,也是我們推薦的管道使用方式。另外,作為一個程式設計師,即使我們瞭解了 Linux 管道的實現,我們的程式碼也不能依賴其特性,所以處理管道時該越界判斷還是要判斷,該錯誤檢查還是要檢查,這樣程式碼才能更健壯。

FIFO

命名管道在底層的實現跟匿名管道完全一致,區別只是命名管道會有一個全域性可見的檔名以供別人 open 開啟使用。再程式中建立一個命名管道檔案的方法有兩種,一種是使用 mkfifo 函式。另一種是使用 mknod 系統呼叫,例子如下:

[zorro@zorro-pc pipe]$ cat mymkfifo.c
#include &lt;stdio.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/stat.h&gt;
#include &lt;stdlib.h&gt;

int main(int argc, char *argv[])
{

    if (argc != 2) {
        fprintf(stderr, &quot;Argument error!\n&quot;);
        exit(1);
    }

/*
    if (mkfifo(argv[1], 0600) &lt; 0) {
        perror(&quot;mkfifo()&quot;);
        exit(1);
    }
*/
    if (mknod(argv[1], 0600|S_IFIFO, 0) &lt; 0) {
        perror(&quot;mknod()&quot;);
        exit(1);
    }

    exit(0);
}

我們使用第一個引數作為建立的檔案路徑。建立完之後,其他程式就可以使用 open()、read()、write() 標準檔案操作等方法進行使用了。其餘所有的操作跟匿名管道使用類似。需要注意的是,無論命名還是匿名管道,它的檔案描述都沒有偏移量的概念,所以不能用 lseek 進行偏移量調整。

關於管道的其它議題,比如 popen、pclose 的使用等話題,《UNIX 環境高階程式設計》中的相關章節已經講的很清楚了。如果想學習補充這些知識,請參見此書。

此文已由騰訊雲 + 社群在各渠道釋出

獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號

更多原創文章乾貨分享,請關注公眾號
  • Linux 的程式間通訊:管道
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章