十四、進程間通訊--管道

一、進程間通訊

系統中的進程都是獨立的個體,擁有屬於自己的用戶空間,所以每個進程之間的數據是不共享的。在前面學習fork時,瞭解到父、子進程可以共享fork之前打開的文件描述符。那麼現在我們思考一個問題:父進程現在想給子進程發送一個“hello world"字符串。可以採取哪種方式,我想大多數人會想到 藉助文件傳輸這種辦法:

  • 父、子進程指向同一個文件,先讓父進程對文件進行write操作,子進程等待,讓它sleep睡眠,保證父進程先運行進行數據的寫入;
  • 父進程寫完“hello world"後,文件偏移量會偏移到d的位置,故子進程在讀取時需要先把文件偏移量移動到h纔可以保證讀到數據。
# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<assert.h>
# include<fcntl.h>

int main()
{
    int fd=open("a.txt",O_RDWR);//一定要讀寫打開
    assert(fd!=-1);
    char buff[128]={0};
    pid_t pid=fork();
    
    if(pid==0)
    {
        sleep(1);
        lseek(fd,0,SEEK_SET);
        int n=read(fd,buff,20);
        printf("buff:%s\n",buff);
    }
    else
    {
       int n= write(fd,"Hello,world!",20);
    }
    close(fd);
    exit(0);
}

在這裏插入圖片描述

可以看到這中方法存在以下問題:

  • 無法準確保證子進程在父進程後面運行:子進程又不知道父進程的處理需要多少秒,所以無法確定睡眠的秒數,容易導致混論。
  • 速度效率極低:每次對文件進行操作,就需要和磁盤進行交互,寫入時進行一次I/O操作,讀出時進行一次I/O操作,只是一個讀取過程就需要耗費大量的時間,所以效率低下。
  • 傳送信息對象固定:只能在父子進程之間進行數據的傳遞,沒有辦法做到任意兩個文件的信息傳遞。

爲了解決上述的問題,達到進程間任一兩個進程進行數據交互通訊的目的,科學家們研究出了進程間通訊(IPC)的幾種方式:有名管道,無名管道,消息隊列,信號量,共享內存,socket套接字。我們會一一進行講解,socket套接字一般用於網絡上,支持兩個不同主機上的進程間通訊。和其他五個不太一樣,故不和它們進行對比。那其他五個的進程通訊方式比較如下:

進程通訊方式 內容 效率 共享實質 進程數量 阻塞機制 特點 使用場景
有名管道 數據 磁盤上文件標識符 任意兩個進程 open,read,write 1、半雙工通信方式 2、在磁盤上會存在一個管道文件標識,但管道文件並不佔用磁盤block空間,數據交互在內存上 應用於同一臺主機上的有權限訪問磁盤的任意幾個進程間通訊
無名管道 數據 父子進程共享文件描述符 父子兩個 read,write 1、半雙工通信方式 2、藉助父子進程之間共享fork之前打開的文件描述符 3、字節流服務 只能用於父子進程之間
消息隊列 消息 內核對象 n msgrcv(獲取數據) 1、可以實現信息的隨機查詢,可以按消息的類型讀取 2、生命週期隨內核 3、無優先級時採取FIFO 發送帶有類型的數據,可以真正實現多進程間通訊
信號量 同步 - 內核對象 n P操作 1、主要用於進程間同步 2、基於操作系統的PV操作 負責進程之間的互斥、同步等功能,和其他進程間通訊方式搭配來實現進程間同步通信
共享內存 數據 內核對象 n P操作 1、最快的IPC 2、需要進行同步控制 使得多個進程訪問同一塊內存空間 ,通信機制運行效率高

進程間通訊必須要有一塊共享的內存,這樣纔可以實現進程間數據的交互和通訊。什麼場景下需要進程間通訊呢?

  • 數據傳輸:一個進程需要將它的數據發送給另一個進程,發送的數據量在一個字節到幾兆字節之間。
  • 共享數據:多個進程想要操作共享數據,一個進程對共享數據的修改,別的進程應該立刻看到。
  • 通知事件:一個進程需要向另一個或一組進程發送消息,通知它(它們)發生了某種事件(如進程終止時要通知父進程)。
  • 資源共享:多個進程之間共享同樣的資源。爲了作到這一點,需要內核提供鎖和同步機制。
  • 進程控制:有些進程希望完全控制另一個進程的執行(如Debug進程),此時控制進程希望能夠攔截另一個進程的所有陷入和異常,並能夠及時知道它的狀態改變。

二、管道

每個進程的空間地址是獨立的,因此進程與進程之間是不能相互訪問的,要進行進程間通訊,必須通過內核,內核會開闢一段特殊的內存空間,進程可以在這塊內存空間進行數據的交換。
管道是一個重要的通信機制,思想是:在內存中創建一個共享文件,從而使通信雙方利用這個共享文件來傳遞信息,由於這種方式具有單向傳遞數據的特點,所以稱爲管道,即在某一時刻只能一端讀數據一端寫數據。
根據使用方式和通信對象將它分爲無名管道,有名管道兩類:

1. 有名管道

  • 含義:在磁盤有一個管道文件標識,這個管道文件會佔據一個inode結點,但是不會佔用block塊,數據在傳遞過程中會緩存到內核開闢的內存上。
  • 作用:磁盤上的空間,只要進程有執行權限都可以訪問,這塊區域在有權限的進程之間是共享的,這個就保證了任何進程都可以訪問到這塊磁盤,這就是利用管道進行進程間通訊的共享內存空間。故管道文件僅僅是爲了使得不同進程(對磁盤有權限)能共享。

2. 無名管道
沒有管道文件的創建,藉助父子進程共享fork之前打開的文件描述符,共同指向內核開闢的內存空間,來實現進程間通訊。

3. 管道的特點

  • 無論有名無名,寫入管道的數據都在內存中。
  • 管道的生命週期隨着進程結束而結束。
  • 管道是一種半雙工的通信方式,即某一時刻只能A-B,B-A。
  • 有名管道可以在任意進程間使用,而無名主要在父子進程間。

二、有名管道FIFO

有名管道需要創建管道文件,創建成功後可以使用系統調用open,read等函數對文件進行操作。

之所以叫FIFO,因爲管道本質是一個先進先出的隊列數據結構,最早放入的數據最先被讀出來

(一)基本概念

我們可以使用命令創建管道文件:

mkfifo filename 

也可以使用庫函數創建管道文件:

# include<sys/stat.h>
int mkfifo(const char* pathname,mode_t mode);//文件名,指定創建文件的權限
             返回值:若成功返回0,失敗返回-1

一旦用mkfifo創建一個FIFO管道文件,就可以用open打開它,用read,write等可以對文件進行操作。

我們來看一下有名管道是如何實現進程間的通信,假設現在又A,B兩個進程,A進程向管道文件寫數據,B進程從管道文件讀數據,那麼就有下面這張圖:

在這裏插入圖片描述
我們需要清楚這幾個點:

  • 在磁盤上會有一塊FIFO文件標識符,佔據inode區域,inode結點會指向內核開闢的內存上的一片空間,兩個進程對於磁盤上的文件是共享的。進程之間通訊的所有數據都會在內存交互,而不會存儲到FIFO文件,因爲它只是一個標識,所以進程通訊結束後,可以通過ls -a查看文件詳細信息,文件大小爲0
  • A,B兩個進程通過文件inode區域可以訪問到這一塊內存區域,都指向內存上這一塊空間的起始位置,就可以開始進行半雙工通信了。
  • A,B進程只能一個寫打開,一個讀打開。不能以讀寫的方式打開,那樣就會造成混亂,A寫給B的,A自己讀了,所以管道是一個半雙工的通信機制,某一時刻只能A寫B讀(A-B);B寫A讀(B-A)。
  • 內核對管道這塊的內存空間的管理是以循環方式管理,即寫到末尾時會從頭開始寫,覆蓋原來的數據;在讀取數據時會將讀過的數據刪除,當讀到末尾時,會從頭開始讀。故這一塊區域是循環的使用的,類似循環鏈表。

也正是因爲這種工作方式:只有當有進程往管道中寫數據,同時有進程從管道讀數據這種情況下,管道纔會有意義,如果一個進程是以只寫或只讀方式操作管道文件,那就沒有意義,會阻塞,浪費空間。

(二)實例

使用一下管道進行兩個進程間的通訊:有A.c,B.c兩個進程:

  • A進程負責打開管道,循環獲取用戶從鍵盤上輸入的信息,存儲到buff,再將數據寫入管道中,最後關閉管道。
  • B進程負責打開管道,循環從管道中讀取數據,將數據輸出到屏幕上。

那麼我們代碼如下:
A.c

# include<stdio.h>
# include<stdlib.h>
# include<assert.h>
# include<unistd.h>
# include<sys/stat.h>
# include<fcntl.h>
# include<string.h>
int main()
{
    int fd=open("./FIFO",O_WRONLY);
    assert(fd!=-1);
    printf("fifo1 write succes!\n");   
    while(1)
    {
        char buff[128]={0};
        printf("input:");
        fgets(buff,127,stdin);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        int n=write(fd,buff,strlen(buff)-1);
    }
    close(fd);

}


B.c

# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<sys/stat.h>
# include<assert.h>
# include<fcntl.h>

int main()
{
    int fd=open("./FIFO",O_RDONLY);
    assert(fd!=-1);

    printf("fifo2 Read success!\n");
    while(1)
    {
        char buff[128]={0};
        int n=read(fd,buff,127);
        if(n<=0)
        {
            break;
        }
        printf("read:%s\n",buff);
    }
    close(fd);
}

我們對代碼進行下面幾種方法的測試,分析出現的情況:

  1. 先運行A進程,B進程不運行出現的情況,出現的結果:

在這裏插入圖片描述

可以看到A進程阻塞,不能輸出A進程的提示信息,這就表示open函數阻塞,因爲我們是以只寫的方式打開管道,B進程不運行表示沒有進程來讀取管道數據,管道只寫不讀,就沒有意義,所以會一直阻塞。

  1. 先運行B進程,A進程不運行,會出現的情況:
    和(1)的情況是一樣的,會阻塞,直到A進程運行才解除阻塞。

在這裏插入圖片描述
通過這兩個測試,我們直到任何一個進程先運行都不能正常運行,會出現阻塞,但我們要搞清楚一點,阻塞並不是說open函數會阻塞,而是操作的對象會阻塞,因爲操作的是管道文件,只讀/只寫會導致無意義,阻塞,如果換成普通文件就不會阻塞。

3.同時運行兩個進程,出現的情況:
在這裏插入圖片描述
在這裏插入圖片描述

可以看到兩個進程運行,一個對管道寫,一個從管道讀,可以立即輸出提示信息,open不會阻塞,輸入一個數據,讀出一個數據,不會存在B進程read空讀的問題,它會一直阻塞着,直到A進程往管道里面寫入一個數據,它纔讀一個數據,而A進程的write操作,也不會一直的讓你寫,而是你讀一個我寫一個。所以read,write會阻塞,這樣就解決了讀寫混亂的問題。close關閉管道文件不會阻塞。

我們可以看到一下管道文件的詳細信息:

可以看到文件大小爲0。
在這裏插入圖片描述
表明數據的交互都是在內存中,它只是一個標識符。

(三)注意

我們可以總結出有名管道需要注意的點:

  • 以open方式打開管道文件會阻塞,直到有進程以另一種方式打開管道文件(讀或寫)。
  • 如果管道對應的內存空間沒有數據,則read會阻塞,直到內存中有數據或寫端關閉返回0。
  • 如果管道對應的內存空間已滿,則write就會阻塞,直到內存中有空間或讀端關閉返回0.

瞭解一個命令:

ulimit -a可以顯示當前的各種用戶進程限制,包括塊大小,創建進程數等。

三、無名管道

無名管道的使用存在限制,它只能用於父,子進程之間,但是它卻最常用,原因很簡單:現在的項目都是由父進程創建子進程,替換爲新代碼來實現不同的操作。這樣可以只創建一個進程,通過不斷fork,execl進行不同功能的實現,所以無名管道最常用。
因爲是父子之間,所以不用單獨創建管道文件和打開文件,創建打開是一起的,故不用open操作,打開後就可以進行write,read等操作了。

無名管道沒有文件名,存儲在內核題和的一段內存中,通過藉助這段內存完成進程間的通信,進程結束,管道也隨之結束。

(一)基本概念

創建和打開管道文件是一起的,是通過pipe函數實現的:

# include<unistd.h>
int pipe(int filedes[2]);//數組2位就夠了,一個表示寫,一個表示讀
              返回值:成功返回0,出錯返回-1

參數filedes返回兩個文件描述符,filedes[0]爲讀打開,filedes[1]爲寫打開,filedes[1]的輸出是filedes[0]的輸入;即filedes[1]->filedes[0];

無名管道只能在具有公共祖先的進程之間使用,通常,一個管道由一個進程創建,然後該進程調用fork,此後父、子進程之間就可以對管道進行操作,我們來具體看一下過程:

首先父進程pipe創建和打開管道,內核開闢一段內存,稱爲管道,用於通信,它有一個讀端,一個寫端,通過fds參數傳給用戶兩個文件描述符,fds[0]指向管道的讀端,fds[1]指向管道的寫端。此時管道的兩端通過進程相互連接,fds[1]寫端指向fds[0]讀端,數據通過內核開闢的管道流動:

在這裏插入圖片描述

父進程fork之後。子進程複製父進程的所有文件描述符,父進程有fds[1]->fds[0],子進程也有fd[1]->fds[0],都通過管道進行連接:
在這裏插入圖片描述

因爲管道是半雙工通信,所以只能一端讀,一端寫。那麼根據需求,將多餘的連接關閉,如果我們讓父進程寫,子進程讀,那麼需要父進程關閉fd[0]讀端,子進程關閉fd[1]寫端

在這裏插入圖片描述
當管道的一端關閉後,另一端打開時,會有兩種情況:

  • 當管道的寫端被關閉,讀端打開時,在所有數據都被讀取後,read返回0,指示達到了文件結束處。
  • 當管道的讀端被關閉,寫端打開時,則產生SIGPIPE信號,如果忽略該信號或者捕捉該信號並從處理程序返回,則write返回-1,error設置爲EPIPE。

表示如果同一時刻只打開管道的一端,如父進程打開寫端,子進程將寫端,讀端都關閉,那就會出現上述的的問題,或父進程關閉讀,寫端,子進程打開讀端,就產生第一種情況。

(二)實例

因爲是用於父子進程,所以一個.c文件即可。我們實現:父子進程的數據交互,父進程給子進程發送信息,即在父進程種關閉讀端,向管道中寫入數據,子進程關閉寫端,從管道中讀取數據。

# include<stdio.h>
# include<string.h>
# include<unistd.h>
# include<assert.h>
# include<string.h>
# include<fcntl.h>

int main()
{
    int fds[2];

    int res=pipe(fds);//fds[0]讀端fds[1]寫端
    pid_t pid=fork();
    assert(pid!=-1);

    if(pid==0)//子進程
    {
        close(fds[1]);//關閉寫端
        while(1)
        {
            char buff[128]={0};
            int n=read(fds[0],buff,127);
            if(n<=0)
            {
                break;
            }
            printf("read:%s\n",buff);
        }
        close(fds[0]);
    }
    else
    {
        close(fds[0]);
        while(1)
        {
            char buff[128]={0};
            printf("input:");
            fgets(buff,127,stdin);
            if(strncmp(buff,"end",3)==0)
            {
                break;
            }
            int n=write(fds[1],buff,strlen(buff)-1);
        }
        close(fds[1]);

    }

    
}

將其運行:

在這裏插入圖片描述

可以看到,在子進程輸出之前會打出input,原因是它們是兩個獨立的進程,都在併發運行,即父進程運行自己的,子進程也在運行自己的,子進程是buff中有數據打印,比父進程慢一點,所以每次都會輸出input.
read,write一樣會阻塞,父進程不輸入,子進程就不會輸出。注意pipe函數不會阻塞,因爲調用它創建和打開管道文件,就有了讀端和寫端。

加油哦!💪。

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