linux進程間通信(IPC)一 管道(pipe)

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

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

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

 

root@frank-virtual-machine:/home/frank/opt# ls -l /etc/ > etc.txt
root@frank-virtual-machine:/home/frank/opt# wc -l etc.txt 
183 etc.txt
root@frank-virtual-machine:/home/frank/opt# wc -l < etc.txt
183

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

root@frank-virtual-machine:/home/frank/opt# ll | wc -l
15

對比以上兩種方法,我們也可以理解爲,管道本質上就是一個文件,前面的進程以寫方式打開文件,後面的進程以讀方式打開。這樣前面寫完後面讀,於是就實現了通信。實際上管道的設計也是遵循UNIX的“一切皆文件”設計原則的,它本質上就是一個文件。Linux系統直接把管道實現成了一種文件系統,藉助VFS給應用程序提供操作接口。
雖然實現形態上是文件,但是管道本身並不佔用磁盤或者其他外部存儲的空間。在Linux的實現上,它佔用的是內存空間。所以,Linux上的管道就是一個操作方式爲文件的內存緩衝區。

當然,linux的shell環境中的‘|’管道操作符只能把前面命令的標準輸出stdout送給後面的命令,如果要把標準錯誤也一起送入後面的命令該怎麼辦?很簡單,只要增加個重定向就ok了,2>&1 會把標準錯誤重定向到標準輸出。

root@frank-virtual-machine:/home/frank/opt/linuxprj/pipe/lpi103-4# ll x* z* | wc -l
ls: 無法訪問'z*': 沒有那個文件或目錄
2
root@frank-virtual-machine:/home/frank/opt/linuxprj/pipe/lpi103-4# ll x* z* 2>&1| wc -l
3

管道的分類和使用
Linux上的管道分兩種類型:

  1. 無名管道
  2.  有名管道

無名管道最常見的形態就是我們在shell操作中最常用的”|”。它的特點是只能在父子進程中使用,父進程在產生子進程前必須打開一個管道文件,然後fork產生子進程,這樣子進程通過拷貝父進程的進程地址空間獲得同一個管道文件的描述符,以達到使用同一個管道通信的目的。此時除了父子進程外,沒人知道這個管道文件的描述符,所以通過這個管道中的信息無法傳遞給其他進程。這保證了傳輸數據的安全性,當然也降低了管道了通用性,於是系統還提供了有名管道。
我們可以使用mkfifo或mknod命令來創建一個有名管道,這跟創建一個文件沒有什麼區別:

 

root@frank-virtual-machine:/home/frank/opt# mkfifo pipe
root@frank-virtual-machine:/home/frank/opt# ls -l pipe 
prw-r--r-- 1 zorro zorro 0 Jul 14 10:44 pipe

可以看到創建出來的文件類型比較特殊,是p類型。表示這是一個管道文件。有了這個管道文件,系統中就有了對一個管道的全局名稱,於是任何兩個不相關的進程都可以通過這個管道文件進行通信了。比如我們現在讓一個進程寫這個管道文件:

 

root@frank-virtual-machine:/home/frank/opt# echo helloworld> pipe

此時這個寫操作會阻塞,因爲管道另一端沒有人讀。這是內核對管道文件定義的默認行爲。此時如果有進程讀這個管道,那麼這個寫操作的阻塞纔會解除:

root@frank-virtual-machine:/home/frank/opt# cat pipe 
helloworld

大家可以觀察到,當我們cat完這個文件之後,另一端的echo命令也返回了。這就是有名管道。
Linux系統無論對於有名管道和無名管道,底層都用的是同一種文件系統的操作行爲,這種文件系統叫pipefs。大家可以在/etc/proc/filesystems文件中找到你的系統是不是支持這種文件系統:

root@frank-virtual-machine:/home/frank/opt# 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]是寫方式打開,作爲管道的寫描述符。從管道寫端寫入的數據會被內核緩存直到有人從另一端讀取爲止。我們來看一下如何在一個進程中使用管道,雖然這個例子並沒有什麼意義:

 


#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    char buf[BUFSIZ];
    printf("BUFSIZ is : %d \n", 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("read()");
        exit(1);
    }

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

    exit(0);
}

這個程序創建了一個管道,並且對管道寫了一個字符串之後從管道讀取,並打印在標準輸出上。BUFSIZ is 8192。
1個進程自己給自己發送消息這當然不叫進程間通信,所以實際情況中我們不會在單個進程中使用管道。

進程在pipe創建完管道之後,往往都要fork產生子進程,fork產生的子進程會繼承父進程對應的文件描述符。利用這個特性,父進程先pipe創建管道之後,子進程也會得到同一個管道的讀寫文件描述符。從而實現了父子兩個進程使用一個管道可以完成半雙工通信。此時,父進程可以通過fd[1]給子進程發消息,子進程通過fd[0]讀。子進程也可以通過fd[1]給父進程發消息,父進程用fd[0]讀。程序實例如下:

 


#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("child read()");
            exit(1);
        }

        printf("child receive:%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("child 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("parent write()");
            exit(1);
        }

        sleep(1);

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

        printf("parent receive:%s\n", buf);

        wait(NULL);
    }


    exit(0);
}

父進程先給子進程發一個消息,子進程接收到之後打印消息,之後再給父進程發消息,父進程再打印從子進程接收到的消息。程序執行效果:

root@frank-virtual-machine:/home/frank/opt/linuxprj/pipe/lpi103-4# ./pipe2
Parent pid is: 12838
Child pid is: 12839
child receive:Message from parent: My pid is: 12838
parent receive:Message from child: My pid is: 12839

從這個程序中我們可以看到,管道實際上可以實現一個半雙工通信的機制。使用同一個管道的父子進程可以分時給對方發送消息。

注意:我們的代碼中有一行:sleep(1); 也就是父進程在wirte完數據以後,要等待子進程讀取管道數據,而這個延時完全靠sleep的時間決定,如果我們去掉sleep(1),結果會怎樣?如下:

root@frank-virtual-machine:/home/frank/opt/linuxprj/pipe/lpi103-4# ./pipe3
Parent pid is: 12855
parent receive:Message from parent: My pid is: 12855
Child pid is: 12856

我們看到,父進程寫到管道的的數據被自己讀取了,而子進程就在讀管道數據的地方阻塞了。也就是說,使用1個管道在父子進程間通信,雖然實現了半雙工,但是這個雙工比較是分時的,效率無法保證。那我們再增加一個管道試試?

#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];
    int pipefd2[2];
    pid_t pid;
    char buf[BUFSIZ];
    char i = 0;
    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }
    if(pipe(pipefd2) == -1){
    	perror("pipe2()");
	exit(1);
    }

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

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

        	printf("child receive:%s\n", buf);

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

    } else {
	while(i++ < 5){
        	/* 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("parent write()");
            		exit(1);
        	}


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

      		printf("parent receive:%s\n", buf);
	}
	wait(NULL);
    }


    exit(0);
}

結果如下:第一個管道用於父進程給子進程發送數據,第二個管道相反,則父子進程的管道通信不在需要延時等待。 

root@frank-virtual-machine:/home/frank/opt/linuxprj/pipe/lpi103-4# ./pipe4
Parent pid is: 12898
Child pid is: 12899
child receive:Message from parent: My pid is: 12898
Child pid is: 12899
parent receive:Message from child: My pid is: 12899
Parent pid is: 12898
child receive:Message from parent: My pid is: 12898
Child pid is: 12899
parent receive:Message from child: My pid is: 12899
Parent pid is: 12898
child receive:Message from parent: My pid is: 12898
Child pid is: 12899
parent receive:Message from child: My pid is: 12899
Parent pid is: 12898
child receive:Message from parent: My pid is: 12898
Child pid is: 12899
parent receive:Message from child: My pid is: 12899
Parent pid is: 12898
child receive:Message from parent: My pid is: 12898
parent receive:Message from child: My pid is: 12899

 

我們也可以看到對管道讀寫的一些特點,即:
在管道中沒有數據的情況下,對管道的讀操作會阻塞,直到管道內有數據爲止。當一次寫的數據量不超過管道容量的時候,對管道的寫操作一般不會阻塞,直接將要寫的數據寫入管道緩衝區即可。

當然寫操作也不會再所有情況下都不阻塞。這裏我們要先來了解一下管道的內核實現。上文說過,管道實際上就是內核控制的一個內存緩衝區,既然是緩衝區,就有容量上限。我們把管道一次最多可以緩存的數據量大小叫做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個字節長度,這取決於當前管道的剩餘空間長度,並且這些數據可能跟別的進程的數據有交叉。

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

 


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

這個程序實際上比上一個要簡單,父進程關閉管道的讀端,只寫管道。子進程關閉管道的寫端,只讀管道。
此時兩個進程就只用管道實現了一個單工通信,並且這種狀態下不用考慮多個進程同時對管道寫產生的數據交叉的問題,這是最經典的管道打開方式,也是我們推薦的管道使用方式。另外,作爲一個程序員,即使我們瞭解了Linux管道的實現,我們的代碼也不能依賴其特性,所以處理管道時該越界判斷還是要判斷,該錯誤檢查還是要檢查,這樣代碼才能更健壯。


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

 


#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進行偏移量調整。

一些具體介紹,可以參考man手冊

root@frank-virtual-machine:/home/frank/opt/linuxprj/pipe/lpi103-4# man 3 mkfifo



參考鏈接:https://www.jianshu.com/p/b3c62923f808
參考鏈接:https://www.ibm.com/developerworks/cn/linux/l-lpic1-v3-103-4/index.html

參考鏈接:https://www.cnblogs.com/chengmo/archive/2010/10/21/1856577.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章