Linux 的進程間通訊:管道

本文由雲+社區發表javascript

做者:鄒立巍java

版權聲明:

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

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

微博ID:orroz

微信公衆號:Linux系統技術

前言

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

  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會將先後兩個進程的輸入輸出用一個管道相連,以便達到進程間通訊的目的: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 <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. */
        close(pipefd[1]);

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

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

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

        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);
        }

        wait(NULL);
    }


    exit(0);
}

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

img

此時兩個進程就只用管道實現了一個單工通訊,而且這種狀態下不用考慮多個進程同時對管道寫產生的數據交叉的問題,這是最經典的管道打開方式,也是咱們推薦的管道使用方式。另外,做爲一個程序員,即便咱們瞭解了Linux管道的實現,咱們的代碼也不能依賴其特性,因此處理管道時該越界判斷仍是要判斷,該錯誤檢查仍是要檢查,這樣代碼才能更健壯。

FIFO

命名管道在底層的實現跟匿名管道徹底一致,區別只是命名管道會有一個全局可見的文件名以供別人open打開使用。再程序中建立一個命名管道文件的方法有兩種,一種是使用mkfifo函數。另外一種是使用mknod系統調用,例子以下:

[zorro@zorro-pc pipe]$ cat mymkfifo.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>

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

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

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

    exit(0);
}

咱們使用第一個參數做爲建立的文件路徑。建立完以後,其餘進程就可使用open()、read()、write()標準文件操做等方法進行使用了。其他全部的操做跟匿名管道使用相似。須要注意的是,不管命名仍是匿名管道,它的文件描述都沒有偏移量的概念,因此不能用lseek進行偏移量調整。

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

此文已由騰訊雲+社區在各渠道發佈

獲取更多新鮮技術乾貨,能夠關注咱們騰訊雲技術社區-雲加社區官方號及知乎機構號

相關文章
相關標籤/搜索