Linux程序間通訊(一)

2022-10-25 06:01:35

程序間通訊

概念:程序是一個獨立的資源分配單位,不同程序之間有關聯,不能在一個程序中直接存取另一個程序的資源。

  • 程序和程序之間的資源是相互獨立的,一個程序不能直接存取另外一個程序的資源,但是程序和程序之間不是相互獨立的。

通訊目的:

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

如何實現程序通訊

要讓兩個不同的程序實現通訊,前提條件是讓它們看到同一份資源。所以要想辦法讓他們看到同一份資源,就需要採取一些手段,可以分為下面幾種。

通訊方式分類

1.管道

  • 匿名管道pipe
  • 命名管道

2.System V IPC

  • System V 訊息佇列
  • System V 共用記憶體
  • System V 號誌

3.POSIX IPC

  • 訊息佇列
  • 共用記憶體
  • 號誌
  • 互斥量
  • 條件變數
  • 讀寫鎖

管道

概念:我們把一個程序連線到另一個程序的一個資料流稱為一個「管道」。

管道的特點:

  • 資料只能從管道的一端寫入,從另一端讀出
  • 寫入管道的資料遵循先入先出的原則
  • 管道所傳達的資料是無格式的,這要求管道的讀出方和寫入方必須事先約定好資料的格式
  • 管道不是普通的檔案,不屬於某個檔案系統,只存在於記憶體中
  • 管道讀資料是一次性的,資料一旦被讀走,它就從管道中拋棄,釋放空間
  • 管道是一種特殊的檔案型別,會在應用層開啟兩個檔案描述符fd[0]對應的是寫端,fd[1]對應的是讀端
  • 管道只能服務於有血緣關係的兩個程序

匿名管道

建立匿名管道-----pipe系統呼叫

int pipe(int pidefd[2]);

功能:建立無名管道

引數:pipefd:為int型別陣列的首地址,其存放了管道的檔案描述符pipefd[0]、pipefd[1]

當一個管道建立的時候,他會建立兩個檔案描述符fd[0]和fd[1]。其中fd[0]固定用於讀管道,而fd[1]固定用於寫管道。

返回值:成功:0 失敗:-1

  • 檔案描述符就是作業系統為了高效管理已經開啟檔案所建立的一個索引(檔案描述符在前面的文章介紹過)

匿名管道建立原理:

呼叫pipe函數後,OS會在fd_array陣列中分配兩個檔案描述符給管道,一個是讀,一個是寫,並把這兩個檔案描述符放到使用者傳進來的陣列中,fd[0]代表管道讀端,fd[1]代表管道寫端。這樣一個管道就建立好了。

範例演示:

範例1:觀察兩個檔案描述符的值

#include <stdio.h>
#include <unistd.h>
int main()
{
	int pipefd[2];
	int ret = pipe(pipefd);
	if (ret == -1){
	  // 管道建立失敗
	  perror("make piep");
	  //用於退出程序
	  exit(-1);
	}
	// 成功返回0
	// pipefd[0] 代表讀端
	// pipefd[1] 代表寫端
	printf("fd[0]:%d, fd[1]:%d\n", pipefd[0], pipefd[1]);
	return0;
}

執行結果如下:

顯然,pipefd這個陣列裡面放的是兩個檔案描述符,分別是3和4,因為0,1,2檔案描述符在程序建立的時候會由系統自動建立。

範例2:嘗試使用管道讀寫資料

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>

int main()
{
	  int pipefd[2];
	  int ret = pipe(pipefd);
	  if (ret == -1){
	    // 管道建立失敗
	    perror("make piep");
	    exit(-1);
	  }
	  char buf[64] = "hello world";
	  // 寫資料
	  write(pipefd[1], buf, sizeof(buf)/sizeof(buf[0]));
	  // 讀資料
	  memset(buf,0,sizeof(buf));// 清空buf
	  ssize_t s = read(pipefd[0], buf, 11);
	  buf[s] = '\0';
	  printf("%s\n", buf);
	  return 0;
}//成功輸出hello world

可以看見對管道的操作,實際上就是對兩個讀寫檔案的操作,本質就是對檔案的操作和使用。

管道的本質

Linux下一切皆檔案,看待管道,其實時可以像看待檔案一樣。且管道和檔案使用方法是一致的。管道的生命週期隨程序

父子程序通過匿名管道通訊

原理:匿名管道是提供給有親緣關係兩個程序進行通訊的。所以我們可以在建立管道之後通過fork函數建立子程序,這樣父子程序就看到同一份資源,且父子程序都有這個管道的讀寫檔案描述符。我們可以關閉父程序的讀端,關閉子程序的寫端,這樣子程序往管道里面寫資料,父程序往管道里面讀資料,這樣兩個程序就可以實現通訊了。
原理解讀:

fork函數呼叫成功後,將為子程序申請PCB和使用者記憶體空間,子程序是父程序的副本,在使用者空間將複製父程序使用者空間所有的資料(程式碼段、資料段、BBS、棧、堆,實際上是複製的父程序的虛擬空間的地址),子程序從父程序繼承下列屬性:有效使用者、組號、行程群組號、環境變數、訊號處理方式設定、訊號遮蔽集合、當前工作目錄、根目錄、檔案模式掩碼、檔案大小限制和開啟的檔案描述符(特別注意:共用同一檔案表項)。

共用同一檔案表項就造成了一種現象,父子程序無論誰對檔案進行操作,那麼另外一個程序的檔案表也會受到相同的影響。

從圖中可以看出,雖然在子程序的表項中式複製了關於開啟檔案的資訊,但是他們是共用檔案表的,所以如果一個程序對檔案指標進行移動,那麼肯定會影響到另外的程序。

思考:這是不是和寫時拷貝相違背了,為什麼檔案表就能共用了呢?

要知道在linux原始碼中,每個程序都存在一個PCB結構體,每個PCB中,存放了一個結構體指標指向一個我們理解為檔案描述符的結構體struct file,而這個結構體裡,才存了檔案的id,值得注意的是,這個結構體裡有一個指標才是指向真正檔案的。檔案系統存在於磁碟當中,對磁碟的操作作業系統不會拷貝一份檔案給子程序,相反,像那些臨時建立存放於堆區和棧區的資料,作業系統會採用寫時拷貝,進行復制。

2.5

總結:父子程序共用檔案表,對檔案表進行的任何操作都會對父子程序造成相同的影響,與寫時拷貝進行區分。

父子程序通過建立匿名管道通訊具體過程如下

1.父程序建立管道(管道建立要在程序建立之前)

2.fork建立子程序(子程序繼承父程序的管道檔案描述符)

3.關閉父程序的寫段,子程序的讀端

範例演示: 子程序每隔1秒往管道里面寫資料,父程序每隔1秒往管道里讀資料

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
int main()
{
  int pipefd[2];
  int ret = pipe(pipefd);
  if (ret == -1){
    // 管道建立失敗
    perror("make piep");
    exit(-1);
  }
  pid_t id = fork();
  if (id < 0){
    perror("fork failed");
    exit(-1);
  }
  else if (id == 0){
    // child
    // 關閉讀端
    close(pipefd[0]);
    const char* msg = "I am child...!\n";
    //int count = 0;
    // 寫資料
    while (1){
      ssize_t s = write(pipefd[1], msg, strlen(msg));
      printf("child is sending message...\n");
      sleep(1);
    }
  }
  else{
    // parent
    close(pipefd[1]);
    char buf[64];
    while (1){
      ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1);
      if (s > 0){
        buf[s] = '\0';// 字串後放一個'\0'
        printf("father get message:%s", buf);
      }
      else if (s == 0){
        // 讀到檔案結尾  寫端關閉檔案描述符 讀端會讀到檔案結尾
        printf("father read end of file...\n ");
      }
      sleep(1);
    }
  }
  return 0;
}

執行結果如下:

匿名管道讀寫規則

讀寫規則總結:

  • 當沒有資料可讀時
    O_NONBLOCK disable:read呼叫阻塞,即程序暫停執行,一直等到有資料來到為止。
    O_NONBLOCK enable:read呼叫返回-1,errno值為EAGAIN。
  • 當管道滿的時候
    O_NONBLOCK disable: write呼叫阻塞,直到有程序讀走資料
    O_NONBLOCK enable:呼叫返回-1,errno值為EAGAIN
  • 如果所有管道寫端對應的檔案描述符被關閉,則read返回0
  • 如果所有管道讀端對應的檔案描述符被關閉,則write操作會產生訊號SIGPIPE,進而可能導致write程序退出
  • 當要寫入的資料量不大於PIPE_BUF時,linux將保證寫入的原子性
  • 當要寫入的資料量大於PIPE_BUF時,linux將不再保證寫入的原子性

注意:O_NONBLOCK是非阻塞的標誌位,指定管道對我們的操作要麼成功,要麼立刻返回錯誤,不被阻塞。

管道特點(瞭解)

  • 只能用於具有共同祖先的程序(具有親緣關係的程序)之間進行通訊;通常,一個管道由一個程序創
    建,然後該程序呼叫fork,此後父、子程序之間就可應用該管道。

  • 管道提供流式服務。也就是你想往管道里讀寫多少資料是根據自身來定的

  • 一般而言,程序退出,管道釋放,所以管道的生命週期隨程序

  • 一般而言,核心會對管道操作進行同步與互斥

  • 管道是半雙工的,資料只能向一個方向流動;需要雙方通訊時,需要建立起兩個管道

  • 半雙工是指傳輸過程中同時只能向一個方向傳輸,一方的資料傳輸結束之後,另外一方再回應。雙方傳輸資料是不可以同時進行的

  • 全雙工是指兩方能同時傳送和接受資料。在這種情況下就沒有擁堵的危險,資料的傳輸也就更快

命名管道

概念:無名管道,由於沒有名字,所以只能用於親緣關係的程序通訊。為了克服這個缺點,提出了命名管道(FIFO)。

命名管道不同於無名管道之處在於它提供了一個路徑名與之關聯,以FIFO的檔案形式存在於檔案系統中,這樣,即使與FIFO的建立程序不存在親緣關係的程序,只要可以存取該路徑,就能夠彼此通過FIFO相互通訊,因此,通過FIFO不相關的程序也能交換資料。

  • FIFO在檔案系統(磁碟上)中作為一個特殊檔案而存在,但是FIFO中的內容卻存放在記憶體中。
  • 當使用FIFO的程序退出後,FIFO檔案將繼續儲存在檔案系統中以便以後使用。
  • FIFO有名字,不相關的程序可以通過開啟命名通道進行通訊。

建立命名管道

1.通過命令建立命名管道

mkfifo filename

2.通過函數建立命名管道

int mkfifo(const char *pathname, mode_t mode);

功能:建立命名管道

引數:pathname:普通的路徑名,也就是建立後FIFO的名字。

​ mode:檔案的許可權,與開啟普通檔案的open函數中的mode引數類似。

返回值:成功:0 (狀態碼) 失敗:如果檔案已經存在,則會出錯返回-1

程式碼範例:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#define FIFO "./fifo"
int main()
{
  umask(0);
  // 建立管道
  int ret = mkfifo(FIFO, 0666);
  if (ret == -1){
    perror("make fifo");
    exit(-1);
  }
}

執行結果如下:

上面說過,管道其實就是一種特殊的檔案,管道檔案大小是0,因為上面介紹過,管道檔案的內容都存放在記憶體當中。

命名管道讀寫操作以及注意事項

一旦建立了一個FIFO,就可以用open開啟它,常見的檔案I/O都可以作用於FIFO檔案。

FIFO嚴格的遵循先進先出的原則,對管道以及FIFO的讀總是從開始處返回資料,對它們的寫則是把資料新增到末尾。

  • 一個為唯讀而開啟一個管道的程序會阻塞直到另外一個程序為只寫開啟該管道
  • 一個為只寫而開啟一個管道的程序會阻塞直到另外一個程序為唯讀開啟該管道

讀寫規則

讀管道

  • 管道中有資料,read返回返回實際讀到的位元組數
  • 管道中無資料:(1)若管道寫端被全部關閉,read返回0

​ (2)若寫端沒有全部關閉,read阻塞等待

寫管道

  • 管道讀端全部被關閉,程序異常終止
  • 管道讀端沒有全部關閉:(1)若管道已經滿了。write阻塞

​ (2)若管道沒滿,write將資料寫入,並返回實際寫入的位元組數

使用命名管道進行通訊

接下來我會使用命名管道實現簡單的版本聊天。

talkA.c

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<stdlib.h>
#include<fcntl.h>
//先讀後寫
//以唯讀的方式開啟管道1
//以只寫的方式開啟管道2
define SIZE 1024
int main()
 {
    int fdr = -1;
    int fdw = -1;
    int ret = -1;
    char buf[SIZE];
    //以唯讀的方式開啟管道1
    fdr = open("fifo1",O_RDONLY);
    if(-1==fdr)
    {
      perror("open");
      return 1;
    }
    printf("以唯讀的方式開啟管道1....\n");
    //以只寫的方式開啟管道2
    fdw = open("fifo2",O_WRONLY);
    if(-1==fdw)
    {
      perror("open");
      return 1;
    }
    printf("以只寫的方式開啟管道2....\n");
    //迴圈讀寫
    while(1)
    {
      //讀管道1
      memset(buf,0,SIZE);
      ret = read(fdr,buf,SIZE);
      if(ret<=0)
      {
        perror("read");
        break;
      }
      printf("read:%s\n",buf);
      //寫管道2
      memset(buf,0,SIZE);
     fgets(buf,SIZE,stdin);
     //去掉最後一個換行符
     if('\n'==buf[strlen(buf)-1])
     buf[strlen(buf)-1]=0;
     //寫管道
     ret = write(fdw,buf,strlen(buf));
     if(ret<=0)
     {
        perror("write");
        break;
     }
        printf("write ret:%d\n",ret);
     }
     //關閉檔案描述符
    close(fdr);
    close(fdw); 
}                   

talkB.c

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<stdlib.h>
#include<fcntl.h>
//以唯讀的方式開啟管道2
//以只寫的方式開啟管道1
#define SIZE 1024
int main()
  {
     int fdr = -1;
     int fdw = -1;
     int ret = -1;
     char buf[SIZE];
     //以只寫的方式開啟管道1
     fdw = open("fifo1",O_WRONLY);
     if(-1==fdw)
     {
        perror("open");
        return 1;
     }
     printf("以只寫的方式開啟管道1....\n");
     //以唯讀的方式開啟管道2
     fdr = open("fifo2",O_RDONLY);
     if(-1==fdr)
     {
        perror("open");
        return 1;
     }
     printf("以唯讀的方式開啟管道2....\n");
     //迴圈讀寫
     while(1)
     {
        //寫管道1
        memset(buf,0,SIZE);
        fgets(buf,SIZE,stdin);
        //去掉最後一個換行符
        if('\n'==buf[strlen(buf)-1])
        buf[strlen(buf)-1]=0;
        //寫管道
        ret = write(fdw,buf,strlen(buf));
        if(ret<=0)
        {
           perror("write");
           break;
        }
        printf("write ret:%d\n",ret);
        //讀管道2
        memset(buf,0,SIZE);
        ret = read(fdr,buf,SIZE);
        if(ret<=0)
        {
           perror("read");
           break;
        }
           printf("read:%s\n",buf);
        }
       //關閉檔案描述符
     close(fdr);
     close(fdw);
  }                             

執行結果如下:可以實現阻塞式的資料讀取

當兩個程序通訊的時候,我們檢視fifo的大小

可以發現,管道的大小沒有發生變化。其實兩個程序通訊是在記憶體中進行的,並沒有把資料寫到管道中,因為管道只是一個符號性的檔案。如果是在管道寫資料,那麼IO次數會很多,效率太低了。