Linux系統編程之匿名管道

1.進程間通信介紹

1.1 進程通信的基本概念

在之前我們已經學習過進程地址空間。Linux 環境下,進程地址空間相互獨立,每個進程各自有不同的用戶地址空間。任何一個進程的全局變量在另一個進程中都看不到,所以進程和進程之間不能相互訪問,要交換數據必須通過內核,在內核中開闢一塊緩衝區,進程1把數據從用戶空間拷到內核緩衝區,進程2再從內核緩衝區把數據讀走,內核提供的這種機制稱為進程間通信(IPC,Inter Process Communication)。

image-20210828230200713

1.2 為什麼要進程間通信

進程通信主要有以下目的:

  • 數據傳輸:一個進程需要將它的數據發送給另一個進程。
  • 資源共享:多個進程之間共享同樣的資源。
  • 通知事件:一個進程需要向另一個或一組進程發送消息,通知它(它們)發生了某種事件(如進程終止時要通知父進程)。
  • 進程控制:有些進程希望完全控制另一個進程的執行(如Debug進程),此時控制進程希望能夠攔截另一個進程的所有陷入和異常,並能夠及時知道它的狀態改變。

1.3 常見的進程通信方式

在進程間完成數據傳遞需要藉助操作系統提供特殊的方法,如今常見的進程間通信方式有:

① 管道 (分為匿名管道與命名管道)
​ ② 信號 (開銷最小)
​ ③ 共享內存

2.管道

2.1管道簡介

管道是Unix中最古老的進程間通信方式,我們把從一個進程連接到另一個進程的數據流叫做管道。

在Linux中,| 符號被用來代表管道。因為在Linux中,不同的命令,如ps,ls,grep等命令的本質都是可執行程序,| 前面的命令前面的命令通常會輸出大量的結果,這些結果將會交由 | 後面的命令繼續處理。

如下面這個命令就是將ps axj中含有PID的結果輸出:

image-20210906114902298

2.2 管道的創建和應用

管道的本質是內核中一塊供不同進程進行讀寫的緩衝區,而外在的操作形式是通過文件讀寫的方式進行。

#include <unistd.h>
功能:創建一無名管道
原型
int pipe(int fd[2]);
參數
fd:文件描述符數組,這是一個輸出型參數,調用該接口後,將會給fd[2]數組分配兩個文件描述符,兩個文件描述符分別對應管道的讀寫兩端。其中fd[0]表示讀端, fd[1]表示寫端
返回值:成功返回0,失敗返回錯誤代碼

我們先用一個簡單的例子來看一下管道的創建:

#include<iostream>
#include<unistd.h>
int main()
{
	int fd[2];
	int ret=pipe(fd); 
	if(-1==ret)
	{
		std::cout<<"管道創建失敗!"<<std::endl;
	}
	std::cout<<"fd[0]:"<<fd[0]<<std::endl<<"fd[1]:"<<fd[1]<<std::endl;
	return 0;
}

運行後:

image-20211027210811579

可以看到,此時fd[0]和fd[1]返回了兩個文件描述符。這兩個文件描述符分別分別對應管道的讀寫兩端。

#include<string.h>    
#include<unistd.h>
#include<sys/wait.h>
#include<sys/stat.h>
#include<stdlib.h>
#include <sys/types.h>    
#include <fcntl.h>    
int main()    
{    
  int fd[2];    
  pipe(fd);    
  pid_t pid = fork();    
  if(pid < 0)    
  {    
    printf("fork error!");    
  }else if(pid == 0)    
  {    
    //child    
    close(fd[0]);    
    char str[100];    
    while(1)    
    {    
      printf("child:");    
      fgets(str, 100, stdin);    
      ssize_t len = strlen(str);    
      if(write(fd[1], str, len) != len)    
      {    
        perror("write to pipe");
        exit(1);
      }
      memset(str, 0, len);
      sleep(1);
    }
  }
  //father
  int count = 0;
  close(fd[1]);
  while(count < 10)
  {
    char str[100];
    ssize_t s = read(fd[0], str, 100);
    if(s < 0){
      perror("read from pipe");
      break;
    }else{
      printf("father:%s", str);
    }
    memset(str, 0, strlen(str));
  }
  return 0;
}

上面這段代碼實現了子進程寫入管道,父進程讀出的過程。

image-20211028172104581

2.3 管道的底層機制

管道是在有血緣關係的進程之間來通信的,如父子進程,兄弟進程等。因此,應用匿名管道時一定會有fork函數的參與。

如下面這個簡化圖可以看到,

  1. 父進程先使用pipe函數創建管道,得到兩個文件描述符 fd[0]、fd[1]指向管道的讀端和寫端。

  2. 父進程調用fork創建子進程,此時父子進程有相同的struct files_struct,父子進程指向的struct file又指向了同一片文件緩衝區。(注意:這個表述並不嚴謹,我們下面馬上就會講到)

  3. 接下來父進程關閉寫端,子進程關閉讀端,就可以實現子進程向管道中寫,父進程讀。注意:管道的通信是單向的!!!!

img

在 Linux 中,管道的實現並沒有使用專門的數據結構,而是藉助了文件系統的file結構和VFS的索引節點inode。通過將兩個 file struct指向同一個臨時的 inode,而這個 VFS 索引節點又指向一個物理頁面而實現的。

image-20211027211509305

如上圖所示,有兩個 file 數據結構,但它們定義文件操作例程地址是不同的,其中一個是向管道中寫入數據的例程地址,而另一個是從管道中讀出數據的例程地址。
這樣,用戶程序的系統調用仍然是通常的文件操作,而內核卻利用這種抽象機制實現了管道這一特殊操作。看待管道,就如同看待文件一樣!管道的使用和文件一致,迎合了「Linux一切皆文件思想」。

2.4 管道讀寫規則

用阻塞的方式打開管道(即默認情況下)

  1. 如果所有管道寫端對應的文件描述符被關閉(管道寫端引用計數為 0),讀端在將管道中剩餘數據讀取後,再次read會返回0。(寫端關閉)

  2. 如果有指向管道寫端的文件描述符沒關閉,且持有管道寫端的進程也沒有向管道中寫數據,這時有進程從管道讀端讀數據,那麼管道中剩餘的數據都被讀取後,再次 read 會阻塞。(讀完不寫)

  3. 如果所有指向管道讀端的文件描述符都關閉了(管道讀端引用計數為 0),進行write操作會產生信號SIGPIPE,進而可能導致write進程退出。(讀端關閉)

  4. 如果有指向管道讀端的文件描述符沒關閉(管道讀端引用計數大於 0),且讀端進程並沒有向管道中讀進程,則當寫端進程寫滿後,會進入阻塞。(寫滿不讀)

2.5 管道的特點

  • 只能用於具有共同祖先的進程(具有親緣關係的進程)之間進行通信。
  • 管道提供流式服務。
  • 管道的生命周期隨進程,進程退出,管道釋放。
  • 內核會對管道操作進行同步與互斥。
  • 管道是半雙工的,數據只能向一個方向流動;需要雙方通信時,需要建立起兩個管道
  • 管道大小為65536 byte