0


进程间通信详解

一.进程间通信介绍

1.进程间通信的目的

  • 数据传输:一个进程需要将它的数据发送给另一个进程(cat log.txt | grep hello)
  • 资源共享:多个进程之间共享同样的资源。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变

2.进程间通信的本质

进程间通信的本质是让 不同的进程看到同一份资源(内存 , 文件,内核缓冲等)

资源由谁(OS的哪些模块)提供 , 就有了不同的进程间通信方式!

这里的模块可以是: (文件–管道) , (OS内核IPC提供- SystemV IPC) , (网络–套接字)

a.进程运行的时候是具有独立性的!(数据层面) , 因此进程之间要实现通信是非常困难的。
b.进程间通信,一般一定要借助第三方(OS)资源。
c.通信的本质就是”数据的拷贝“,

** 3.进程间通信分类**

管道

  • 匿名管道
  • 命名管道

System V IPC

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

POSIX IPC

  • 消息队列
  • 共享内存
  • 信号量
  • 互斥量
  • 条件变量
  • 读写锁

二.什么是管道

管道是Unix中最古老的进程间通信的形式 , 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道" .

①统计云服务器用户登陆个数

who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数

②解释

三.匿名管道

1. 匿名管道只能用于具有亲缘关系的进程之间进行通信,常用于父子。

进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行读写草在,进而实现父子进程间通信

  • 子进程拷贝父进程的fd_array,父子进程看到同一份文件 , 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝(文件并不存在磁盘,只在内存中存在)。
  • 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一 一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在

** 2.pipe函数**

功能 : 创建一个无名管道

原型 : int pipe(int fd[2]);

参数 : fd:文件描述符数组,其中fd[0]表示读端 , fd[1]表示写端 (0->嘴:读 , 1->笔:写)

返回值:成功返回0,失败返回错误代码

3. 匿名管道的使用

①代码+结果

  1 #include <stdio.h>                                                                        
  2 #include <unistd.h>
  3 #include <string.h>
  4 #include <stdlib.h>
  5 #include <sys/types.h>
  6 #include <sys/wait.h>
  7 
  8 
  9 //child -> write ; father -> read
 10 int main()
 11 {
 12   int fd[2] = { 0 };
 13   if (pipe(fd) < 0){ //使用pipe创建匿名管道
 14     perror("pipe");
 15     return 1;
 16   }
 17 
 18   pid_t id = fork();
 19   if (id == 0){ //child
 20 
 21     close(fd[0]); //子进程关闭读端
 22     
 23     //子进程向管道写入数据
 24     const char* buf = "hello father, I am child...";
 25     int count = 5;
 26     while (count--){
 27       write(fd[1], buf, strlen(buf));
 28       sleep(1); //每隔一秒写一条数据
 29     }
 30     close(fd[1]); //子进程写入完毕,关闭文件
 31     exit(0);
 32   }                                                                                       
 33 
 34   //father
 35   close(fd[1]); //父进程关闭写端
 36 
 37   //父进程从管道读取数据
 38   char buff[64];
 39   while (1){
 40     ssize_t s = read(fd[0], buff, sizeof(buff));
 41     if (s > 0){
 42       buff[s] = '\0'; //C语言读写规则
 43       printf("child send to father:%s\n", buff);
 44     }
 45     else if (s == 0){
 46       printf("read file end\n");
 47       break;
 48     }
 49     else{
 50       printf("read error\n");
 51       break;                                                                              
 52     }
 53   }
 54 
 55   close(fd[0]); //父进程读取完毕,关闭文件
 56   waitpid(id, NULL, 0);
 57   return 0;
 58 }

②创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:

1)父进程调用pipe函数

2)fork创建子进程

3)子进程关闭读端,父进程关闭写端

  • 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。

  • 从管道写端写入的数据会存在内核缓冲,直到数据从管道中被读取。

  • 父进程读写端都打开本质是为了让子进程继承下来,最后为什么要关闭呢,本质是管道只能进行单向通信

  • 父子进程通信可不可以创建全局缓冲区来完成通信呢? 不可以 ! 进程运行具有独立性,写时拷贝

                                      ​​​​​​​
    

③站在文件描述符角度深入理解管道

1)父进程调用pipe函数

2)fork创建子进程

3)子进程关闭读端,父进程关闭写端

4.管道的读写规则

pipe2函数与pipe函数类似,用于创建匿名管道,其函数原型如下:

int pipe2(int pipefd[2], int flags);

pipe2函数的第二个参数用于设置选项。

1、当没有数据可读时:

  • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来为止。
  • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

2、当管道满的时候:

  • O_NONBLOCK disable:write调用阻塞,直到有进程读走数据。
  • O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。

3、如果所有管道写端对应的文件描述符被关闭,则read返回0。
4、如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5、当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6、当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。

5.管道的特点,

①管道内部自带同步与互斥机制。

  • 我们将一次只允许一个进程使用的资源,称为临界资源。
  • 管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
  • 临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
  • 为了避免这些问题,内核会对管道操作进行同步与互斥
  • 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
  • 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
  • 实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
  • 也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
  • 子进程往管道里面写入,子进程去读取的时候,有数据就拿上来,没数据就不在读取而是阻塞式的等待管道数据写入,并非父进程sleep了,而是因为子进程写的慢,父进程必须等,而引起好像父进程sleep了,这种—个等另一个的现象叫做同步。

②管道的生命周期随进程。

管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。

③管道提供的是流式服务。

我们一般所谓的流式概念就是,给你提供一个通信的信道,你的写端就直接写,读端直接读,但是具体写多少,读多少完全有上层决定。底层就只是提供一个数据通信的信道就完了,它不关心数据本身的一些细节格式,这叫做面向字节流。

流式服务: 数据没有明确的分割,一次拿多少数据都行。
数据报服务: 数据有明确的分割,拿数据按报文段拿。

    ​​​​​​​        ​​​​​​​        ​​​​​​​        

④管道是半双工通信的。

在数据通信中,数据在线路上的传送方式可以分为以下三种:

  • 单工通信:单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
  • 半双工通信:半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
  • 全双工通信:全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
  • 管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。

**6.管道的4中特殊情况 **

  • ①写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被阻塞挂起,直到管道里面有数据后,读端进程才会被唤醒。
  • ②读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被阻塞挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
  • ③写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
  • ④读端进程将读端关闭,而写端进程还在一直向管道写入数据,没有进程读取,那么写入的数据就没有意义,那么操作系统会将写端进程杀掉。

1)前面的①②两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。

  1. 如何理解阻塞挂起 ?唤醒?
  • 进程先立即停止执行,然后将PCB的状态改为阻塞状态,并将PCB插入相应的阻塞队列。
  • 当被阻塞进程所期待的事情发生,将阻塞进程从阻塞队列中移出,将其PCB的状态改为就绪状态(R),然后将PCB插入到就绪队列中.
  1. ③理解,读端进程已经将管道当中的所有数据都读取出来了(读端就会read返回值0,代表文件结束),而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起。

  2. ④理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号。

5)管道是单向通信,如果读端不读数据且把文件描述符关闭,那么写端做的就没有意义了。
写端相当于废弃的动作,浪费资源,所以OS直接将子进程干掉。为什么?OS不做不做任何浪费空间或者低效的事情,只要发现OS一定要把这个事情修正了。

6)验证OS发送信号杀掉进程

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

int main()
{
    int fd[2] = { 0 };

    if (pipe(fd) < 0){ //使用pipe创建匿名管道
        perror("pipe");
        return 1;
    }

    pid_t id = fork(); //使用fork创建子进程
    if (id == 0){     //child
        close(fd[0]); //子进程关闭读端

        //子进程向管道写入数据
        const char* buf = "I am child...";
        int count = 5;
        while (count--){
            write(fd[1], buf, strlen(buf));
            sleep(1);
        }

        close(fd[1]); //子进程写入完毕,关闭文件
        exit(0);
    }

    //father
    close(fd[1]); //关闭写端
    close(fd[0]); //关闭读端

    int status = 0;
    waitpid(id, &status, 0);
    printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
    return 0;
}

7)使用命令查看信号 kill - l

** 7.验证管道的大小**

管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,需要了解一下管道的大小

①方法一:使用man手册

根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。

查看Linux系统版本

这里使用的是Linux 2.6.11之后的版本,因此管道的最大容量是65536字节

②方法二:使用ulimit命令

可以使用ulimit -a 命令,查看当前资源限制的设定, 管道的最大容量是 512 × 8 = 4096 字节

③写代码验证管道容量

  • 根据man手册得到的管道容量与使用ulimit命令得到的管道容量不同,测试验证
  • 代码概述: 读进程一直不读,写进程一直写,直到管道被写满
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>

int main()
{
    int fd[2] = { 0 };

    if (pipe(fd) < 0){ //使用pipe创建匿名管道
        perror("pipe");
        return 1;
    }

    pid_t id = fork(); //使用fork创建子进程
    if (id == 0){ //child 
        close(fd[0]); //子进程关闭读端

        char c = '.';
        int count = 0;
        while (1){
            write(fd[1], &c, 1);
            count++;
            printf("%d\n", count); //打印当前写入的字节数
        }

        close(fd[1]);
        exit(0);
    }

    //father
    close(fd[1]); //父进程关闭写端
    
    //父进程不读取数据

    waitpid(id, NULL, 0);
    close(fd[0]);
    return 0;
}
  • 在读端进程不进行读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起了,也就是说,我当前Linux版本中管道的最大容量是65536字节

四.命名管道

1. 基本概念

  • 匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。
  • 命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
  • 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题
  • 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。

** 2.创建命名管道**

(1)使用命令创建

[gsx@VM-0-2-centos 220621]$ mkfifo pipe

(2)通过命令使用命名管道

①一个进程通过shell脚本重定向到管道 ,不断地往 pipe管道里面写数据 ;另一个进程使用cat命令 不断地读数据。这两个毫不相关的进程可以通过命名管道进行数据传输,完成了通信。

②使用cat命令的读进程主动退出,另一个写进程就被杀掉了; 因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。(当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉)

(3)使用函数创建命名管道

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

参数 : mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。

  • 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。

  • 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)

       mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
    

返回值: 命名管道创建成功,返回0 ; 命名管道创建失败,返回-1。

3.命名管道的打开规则

①如果当前打开操作是为读而打开FIFO时。

O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
O_NONBLOCK enable:立刻返回成功。

②如果当前打开操作是为写而打开FIFO时。

O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。

**4.命名管道的四个使用示例 **

(1)通过命名管道 ,client & server进行通信

①comm.h中包含一些头文件供client 和 server使用

//comm.h
#pragma once

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

#define FILE_NAME "myfifo" //让客户端和服务端使用同一个命名管道

②server.c 代码

实现服务端(server)和客户端(client)之间的通信之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,之后服务端就可以从该命名管道当中读取客户端发来的通信信息了。

#include "comm.h"

int main()
{
  if(mkfifo(FILE_NAME , 0644) < 0){ //创建管道文件
    perror("mkfifo");
  }

 int fd = open(FILE_NAME , O_RDONLY); //只读形式打开
 if(fd < 0){
   perror("open error!\n");
   return 2;
 }

 char buf[128];
 while(1){
   ssize_t s = read(fd ,buf, sizeof(buf)-1); //开始读数据
   if(s > 0){
      buf[s] = 0;
      printf("client# %s\n" , buf);
   }
   else if(s == 0){
      printf("client quit!\n");
      break;
   }
   else{
      printf("read error!\n");
      break;
   }
 }

 close(fd);
  return 0;
}

③client.c 代码

而对于客户端来说,因为服务端运行起来后命名管道文件就已经被创建了,所以客户端只需以写的方式打开该命名管道文件,之后客户端就可以将通信信息写入到命名管道文件当中,进而实现和服务端的通信。

#include "comm.h"

int main()
{
  int fd = open(FILE_NAME , O_WRONLY); //以写的方式打开文件
  if(fd < 0){
    perror("open error!\n");
    return 1;
  }

  char buf[128];
  while(1){
    printf("Please Enter# ");  //提示语句
    fflush(stdout);
    ssize_t s = read(0 , buf , sizeof(buf));  //从键盘中读取数据
   if(s > 0){
     buf[s-1] = 0; //输入时多了一个\n
     write(fd , buf ,strlen(buf)); //把读取到的数据写到管道中
   }

  }

  return 0;
}

④运行结果

客户端写入的信息进入命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的,换句话说,此时这两个进程之间是能够通信的。

通过ps命令查看这两个进程的信息,可以发现这两个进程确实是两个毫不相关的进程,因为它们的PID和PPID都不相同

⑤client 和 server 谁先退出问题

  1. 客户端先退出,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)。

2)服务端先退出,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。

⑥通信是在内存当中进行的

client端代码不变 ,server端只是以读的方式打开,但是不读取数据 :

运行程序前后两次查看myfifo管道文件的大小始终为0,说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。

(2)通过命名管道,派发计算任务

①两个进程之间的通信,并不是简单的发送字符串而已,服务端是会对客户端发送过来的信息进行某些处理的 ; 这里我们的client端发送计算任务,server端将数据计算出来并打印到显示器上。

②client端的代码没有发生变化,只是server端读取数据时对数据进行了一些处理:

#include "comm.h"

int main()
{
  if(mkfifo(FILE_NAME , 0644) < 0){ //创建管道文件
    perror("mkfifo");
  }

 int fd = open(FILE_NAME , O_RDONLY); //只读形式打开
 if(fd < 0){
   perror("open error!\n");
   return 2;
 }

 char buf[128];
 while(1){
   ssize_t s = read(fd ,buf, sizeof(buf)-1); //开始读数据
   if(s > 0){
      buf[s] = 0;

     //简单计算
     char *p = buf;
     const char *lable="+-*/%";
     int flag = 0; //记录计算的符号,利用下标 
     while(*p){
         switch(*p){
             case '+':
                 flag = 0;
                 break;
             case '-':
                 flag = 1;
                 break;
             case '*':
                 flag = 2;
                 break;
             case '/':
                 flag = 3;
                 break;
             case '%':
                 flag = 4;
                 break;
         }
         p++;
     }

     char *data1 = strtok(buf, "+-*/%"); //通过算数符号将左右两个数字分开
     char *data2 = strtok(NULL, "+-*/%");
     int x = atoi(data1); //将字符转整形计算
     int y = atoi(data2);
     int z = 0;
     switch(flag){
         case 0:
             z = x + y;
             break;
         case 1:
             z = x - y;
             break;
         case 2:
             z = x * y;
             break;
         case 3:
             z = x / y;
             break;
         case 4:
             z = x % y;
             break;
     }

     printf("%d %c %d = %d\n", x,lable[flag], y, z);
   }
   else if(s == 0){
      printf("client quit!\n");
      break;
   }
   else{
      printf("read error!\n");
      break;
   }
 }

 close(fd);
  return 0;
}

③结果

(3)通过命名管道,进行命令操作

①我们可以通过一个进程来控制另一个进程的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行。简单实现了让服务端执行不带选项的命令,若是想让服务端执行带选项的命令,可以对管道当中获取的命令进行进一步的解析处理。

②client端的代码不变,server端对于输入的数据进行解析,如果有命令则执行:

#include "comm.h"

int main()
{
  if(mkfifo(FILE_NAME , 0644) < 0){ //创建管道文件
    perror("mkfifo");
  }

 int fd = open(FILE_NAME , O_RDONLY); //只读形式打开
 if(fd < 0){
   perror("open error!\n");
   return 2;
 }

 char buf[128];
 while(1){
   ssize_t s = read(fd ,buf, sizeof(buf)-1); //开始读数据
   if(s > 0){
      buf[s] = 0;
      printf("client# %s\n" , buf);

     //执行命令
      if(fork() == 0){ //child
         execlp(buf , buf ,NULL); //进程替换
         exit(1);
      }

      waitpid(-1 , NULL ,0); //进程等待

   }
   else if(s == 0){
      printf("client quit!\n");
      break;
   }
   else{
      printf("read error!\n");
      break;
   }
 }

 close(fd);
  return 0;
}

③结果

(4)通过命名管道,进行文件拷贝

①大致思路是,client端将log.txt 文件通过管道发送给server端,server端读取管道中的数据创建一个本地文件,将数据拷贝到本地文件中,以此来实现文件的拷贝。(本实验是在同一个机器上,且在同一个目录下,所以发送文件的文件名不能和接受文件的文件名重复)

②client端代码 : 以读的方式打开log.txt文件 , 以写的方式打开管道文件 ,将log.txt文件中的数据写到管道中.

#include "comm.h"

int main()
{
  int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件
    if (fd < 0){
        perror("open");
        return 1;
    }

    int fdin = open("log.txt", O_RDONLY); //以读的方式打开log.txt文件
    if (fdin < 0){
        perror("open");
        return 2;
    }

    char buf[128];
    while (1){
        //从log.txt文件当中读取数据
        ssize_t s = read(fdin, buf, sizeof(buf));
        if (s > 0){
            write(fd, buf, s); //将读取到的数据写入到命名管道当中
        }
        else if (s == 0){
            printf("read end of file!\n");
             break;
        }
        else{
            printf("read error!\n");
            break;
        }
    }

    close(fd); //通信完毕,关闭命名管道文件
    close(fdin); //数据读取完毕,关闭log.txt文件
    return 0;
}

③server端代码 : 以读的方式打开管道文件(没有管道文件创建) , 以写的方式在本地创建一个log-bat.txt文件,并将管道中的数据写到log-bat.txt文件中。

#include "comm.h"

int main()
{
  if(mkfifo(FILE_NAME , 0644) < 0){ //创建管道文件
    perror("mkfifo");
  }

 int fd = open(FILE_NAME , O_RDONLY); //只读形式打开
 if(fd < 0){
   perror("open error!\n");
   return 2;
 }
 
 int fdout = open("log-bat.txt" , O_WRONLY|O_CREAT , 0644);
 if(fdout < 0){
   perror("open error!\n");
   return 3;
 }

 char buf[128];
 while(1){
   ssize_t s = read(fd ,buf, sizeof(buf)-1); //开始读数据
   if(s > 0){
    write(fdout , buf , s); //将数据从管道写入文件
   }
   else if(s == 0){
     printf("client quit!\n");
     break;
   }
   else{
     printf("read error!\n");
     break;
   }
 }

  close(fd); //通信关闭,关闭管道文件描述符
  close(fdout); //数据写入完毕
  return 0;
}

④结果

**⑤进一步理解文件拷贝 **

使用管道在本地进行的文件拷贝,所以看似没什么意义,但我们若是将这里的管道想象成“网络”,将客户端想象成“Windows Xshell”,再将服务端想象成“centos服务器”, 那我们此时实现的就是文件上传的功能,若是将方向反过来,那么实现的就是文件下载的功能。

5. 命名管道和匿名管道的区别

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,由open函数打开。
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。

6.命令行中的管道理解

(1)使用cat 和 grep 命令 , 利用 管道 “ | ” 对信息进行过滤。

(2)管道“ | ” 是匿名管道还是命名管道 ?

①由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。

②通过管道连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。

③三个sleep进程的父进程是bash ,三个sleep进程互为兄弟

④结论

若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。

五.system V进程间通信

1.管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份资源。

  1. system V IPC提供的通信方式有以下三种:
  • system V共享内存
  • system V消息队列
  • system V信号量

3.其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。

六.system V共享内存

1.共享内存示意图

共享内存让不同进程看到同一份资源的方式 : 在物理内存当中申请一块内存空间,然后将这块内存空间分别与需要进行进程间通信的进程的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

物理内存映射到地址空间中:

①如何实现: 本质:就是修改页表,虚拟地址空间中开辟空间
②是谁做的: 开辟物理空间开辟虚拟地址、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。

2.共享内存数据结构

(1)在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。

struct shmid_ds {
    struct ipc_perm     shm_perm;   /* operation perms */
    int         shm_segsz;  /* size of segment (bytes) */
    __kernel_time_t     shm_atime;  /* last attach time */
    __kernel_time_t     shm_dtime;  /* last detach time */
    __kernel_time_t     shm_ctime;  /* last change time */
    __kernel_ipc_pid_t  shm_cpid;   /* pid of creator */
    __kernel_ipc_pid_t  shm_lpid;   /* pid of last operator */
    unsigned short      shm_nattch; /* no. of current attaches */
    unsigned short      shm_unused; /* compatibility */
    void            *shm_unused2;   /* ditto - used by DIPC */
    void            *shm_unused3;   /* unused */
};

(2)当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个key值,这个key值用于标识系统中共享内存的唯一性(让两个进程看到的是同一个共享内存)。

上面共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:

struct ipc_perm{
    __kernel_key_t  key;
    __kernel_uid_t  uid;
    __kernel_gid_t  gid;
    __kernel_uid_t  cuid;
    __kernel_gid_t  cgid;
    __kernel_mode_t mode; //权限
    unsigned short  seq;
};

(3)在结构体的定义在Linux的具体目录下面

3.共享内存的主体使用逻辑

共享内存的建立大致包括以下两个过程:

  1. 在物理内存当中申请共享内存空间。
  2. 将申请到的共享内存挂接到地址空间,即建立映射关系。

共享内存的释放大致包括以下两个过程:

  1. 将共享内存与地址空间去关联,即取消映射关系。
  2. 释放共享内存空间,即将物理内存归还给系统。

4.共享内存的创建

函数原型: int shmget(key_t key, size_t size, int shmflg);

参数说明:

  • 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
  • 第二个参数size,表示待创建共享内存的大小。
  • 第三个参数shmflg,表示创建共享内存的方式。

返回值说明:

  • shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
  • shmget调用失败,返回-1。

注意:我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。

(1) 共享内存唯一标识符key需要通过函数获取

key_t ftok(const char *pathname, int proj_id);
  • ftok函数的作用: 将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。(将pathname 和 proj_id当成数据通过算法生成了一个序号id)
  • 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
  • 如果生成key失败,可以通过改变proj_id的值再次尝试

(2)传入shmget函数的第三个参数shmflg,常用的组合方式:

  • IPC_CREAT 如果不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄
  • IPC_CREAT | IPC_EXCL 如果不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回

使用结果:

  • 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,调用成功情况下无法确认该共享内存是否是新建的共享内存。
  • 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存

(3)使用ftok 和 shmget 函数测试

#include <stdio.h>
#include <sys/types.h> 
#include <sys/ipc.h> 
#include <sys/shm.h> 
#include <unistd.h>
        
#define PATHNAME "/home/gsx/daily/220625/share_test/server.c" //路径名

#define PROJ_ID 0x1234 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
  key_t k  = ftok(PATHNAME, PROJ_ID); //生成唯一key
  if(k < 0){
    perror("ftok error!\n");
    return 1;
  }

  printf("key: %x\n" , k);

  int shmid = shmget(k ,SIZE , IPC_CREAT|IPC_EXCL|0644); //创建共享内存
  if(shmid < 0){
    perror("shmget");
    return 2;
  }

  printf("shmid: %d\n" , shmid);

 return 0;
}

结果

(4)查看共享内存信息

①使用ipcs命令查看

单独使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:

  • -q:列出消息队列相关信息。
  • -m:列出共享内存相关信息。
  • -s:列出信号量相关信息。

②使用ipcs -m查看共享内存


标题含义key系统区别共享内存的唯一标识shmid共享内存的用户层id(句柄)owner共享内存的拥有者perms共享内存的权限bytes共享内存的大小nattch关联共享内存的进程数status共享内存的状态
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。

**(5)关于第二个参数SIZE **

如果size设置成4097 ,在OS底层给你分配了2页(按页对齐),但是你要4097字节那么我就只让你看到4097个字节的空间,绝对不少给你但也不多给你,少给了可能会出问题,多给了也可能出问题,用户要我怎么办我就怎么办,严格按照用户来 ; 所以最好设置4096的整数倍。

5.共享内存的释放

  • 通过上面创建共享内存的实验可以发现,进程退出后,申请的共享内存依旧存在,并没有被操作系统释放。
  • 管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
  • 这说明,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源一定是由内核提供并维护的。

(1)使用命令删除共享内存

使用ipcrm -m shmid命令释放指定id的共享内存资源

[gsx@VM-0-2-centos share_test]$ ipcrm -m 2

(2)使用函数释放共享内存

函数 : int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数:

  • 第一个参数shmid,表示所控制共享内存的用户级标识符。
  • 第二个参数cmd,表示具体的控制动作。
  • 第三个参数buf,用于获取或设置所控制共享内存的数据结构。

返回值:

  • shmctl调用成功,返回0。
  • shmctl调用失败,返回-1。

①shmctl函数的第二个参数传入的常用的选项:

  • IPC_STAT 获取共享内存的当前关联值,此时参数buf作为输出型参数
  • IPC_SET 在进程有足够权限的前提下,将共享内存的当前关联值设置为buf所指的数据结构中的值
  • IPC_RMID 删除共享内存段

②代码测试

#include <stdio.h>
#include <sys/types.h> 
#include <sys/ipc.h> 
#include <sys/shm.h> 
#include <unistd.h>
        
#define PATHNAME "/home/gsx/daily/220625/share_test/server.c" //路径名

#define PROJ_ID 0x1234 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
  key_t k  = ftok(PATHNAME, PROJ_ID); //生成唯一key
  if(k < 0){
    perror("ftok error!\n");
    return 1;
  }

  printf("key: %x\n" , k);

  int shmid = shmget(k ,SIZE , IPC_CREAT|IPC_EXCL|0644); //创建共享内存
  if(shmid < 0){
    perror("shmget");
    return 2;
  }

  printf("shmid: %d\n" , shmid);

  sleep(2);

  shmctl(shmid , IPC_RMID , NULL); //释放共享内存

  return 0;
}

运行server , 2s后释放共享内存 ,使用shell脚本监视:

[gsx@VM-0-2-centos share_test]$ while :; do ipcs -m; echo "############" ; sleep 1 ;done

6.共享内存进行关联

**函数 : void shmat(int shmid, const void shmaddr, int shmflg);

参数:

  • 第一个参数shmid,表示待关联共享内存的用户级标识符。
  • 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
  • 第三个参数shmflg,表示关联共享内存时设置的某些属性。

返回值:**(和malloc很像)**

  • shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
  • shmat调用失败,返回(void*)-1。

第三个参数shmflg传入的常用的选项:

  • SHM_RDONLY 关联共享内存后只进行读取操作
  • SHM_RND 若shmaddr不为NULL,则关联地址自动向下调整为SHMLBA的整数倍。公式:shmaddr-(shmaddr%SHMLBA)
  • 0 默认为读写权限

代码测试

#include <stdio.h>
#include <sys/types.h> 
#include <sys/ipc.h> 
#include <sys/shm.h> 
#include <unistd.h>
        
#define PATHNAME "/home/gsx/daily/220625/share_test/server.c" //路径名

#define PROJ_ID 0x1234 //整数标识符
#define SIZE 4096 //共享内存的大小

int main()
{
  key_t k  = ftok(PATHNAME, PROJ_ID); //生成唯一key
  if(k < 0){
    perror("ftok error!\n");
    return 1;
  }

  printf("key: %x\n" , k);

  int shmid = shmget(k ,SIZE , IPC_CREAT|IPC_EXCL|0644); //创建共享内存
  if(shmid < 0){
    perror("shmget");
    return 2;
  }

  printf("shmid: %d\n" , shmid);

  printf("attach begin!\n");

  char* mem = shmat(shmid , NULL , 0); //关联共享内存
  if(mem == (void*)-1){
    perror("shmat");
    return 3;
  }
  printf("attach end!\n");

  sleep(5);

  shmctl(shmid , IPC_RMID , NULL); //释放共享内存
  
  return 0;
}

结果:

注意事项:

  • 使用shmget函数创建共享内存时,要对创建的共享内存设置权限 ; 如果没有设置权限默认权限为0,即什么权限都没有,即server进程没有权限,无法关联共享内存。
  • 设置好权限后,我们建立关联,nattch关联数0 -> 1

7.共享内存去关联

函数 : int shmdt(const void *shmaddr);

参数

  • 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。

返回值

  • shmdt调用成功,返回0。
  • shmdt调用失败,返回-1。

将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。

8.client 和 serve进行共享内存通信

(1)comm.h共同包含的头文件

为了让client和server在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享内存进行挂接。

#pragma once

#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include<sys/ipc.h>
#include<sys/shm.h>

#define PATHNAME "/home/gsx/daily/220625/share"
#define PROJ_ID 0x123
#define SIZE 4096

(2)server.c 建立共享内存,并建立关联,从共享内存中接收数据

#include "comm.h"

int main()
{
  key_t k  = ftok(PATHNAME , PROJ_ID); //生成唯一key
  if(k < 0){
    perror("ftok error!\n");
    return 1;
  }

  printf("key: %x\n" , k);

  int shmid = shmget(k ,SIZE , IPC_CREAT|IPC_EXCL|0644); //创建共享内存
  if(shmid < 0){
    perror("shmget");
    return 2;
  }

  printf("shmid: %d\n" , shmid);

  char* mem =(char*)shmat(shmid , NULL , 0);//和共享内存进行关联 
  printf("%p\n" , mem);

  //TO DO
  int count = 10;
  while(count--){
    printf("client mesg: %s \n" , mem);
    sleep(1);
  }

  shmdt(mem);//取消共享内存的关联

  shmctl(shmid , IPC_RMID , NULL); //删除共享内存

  return 0;
}

(3)client.c 通过k找到共享内存建立关联,向共享内存中发送数据

#include "comm.h"

int main()
{
 key_t k = ftok(PATHNAME , PROJ_ID);
 if(k < 0){
   perror("ftok");
   return 1;
 }

 printf("key:%x\n" , k);

 int shmid = shmget(k , SIZE , IPC_CREAT);//创建共享内存
 if(shmid < 0){
   perror("shmget");
   return 2;
 }

 printf("shmid: %d\n" , shmid);

 char* mem = (char*)shmat(shmid , NULL ,0);//建立关联

 
 //TO DO
 int i = 0;
 int count = 10;
 while(count--){
    mem[i] = 'A'+ i;
    sleep(1);
    i++;
    mem[i] = '\0';
 }

 shmdt(mem);//去关联

  return 0;
}

(4)结果

七.共享内存和管道的比较

1.通信速度比较

当共享内存创建好后就不再需要调用系统接口进行通信了(直接对地址空间进行操作),而管道创建好后仍需要read、write等系统接口进行通信。实际上,**共享内存是所有进程间通信方式中最快的一种通信方式 **

2.管道的数据拷贝过程

read是把数据从内核缓冲区复制到进程缓冲区 , write是把进程缓冲区复制到内核缓冲区

3.共享内存的数据拷贝过程

  • 写进程直接将数据写到共享内存中。
  • 读进程从共享内存中读数据

①所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。

②但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。

4.为什么共享内存是速度最快的IPC方法?

  • ① 共享内存的拷贝次数少
  • ② 在使用共享内存时不涉及系统调用接口(也就是不会有内核态到用户态之间的转化,因为都是在用户层进行操作的)
  • ③ 不提供任何保护机制(没有同步与互斥)

5.为什么共享内存的拷贝次数少?

共享内存的使用方法就和使用堆空间类似,直接向共享内存中写入,另一个进程直接就能看到。它与管道不同,管道还需要拷贝数据到管道,另一个进程再从管道中拷贝数据到自己当中。

八.System V消息队列

消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型

总结一下:

  1. 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
  2. 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
  3. 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。

九.System V信号量

1.理解信号量的相关概念

  • 由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥。
  • 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
  • 在进程中涉及到临界资源的程序段叫临界区。
  • IPC资源必须删除,否则不会自动删除,因为system V IPC的生命周期随内核

2.同步和互斥

(1)进程间通信通过共享资源来实现,这虽然解决了通信的问题,但是也引入了新的问题,那就是通信进程间共用的临界资源,若是不对临界资源进行保护,就可能产生各个进程从临界资源获取的数据不一致等问题。

(2)保护临界资源的本质是保护临界区,我们把进程代码中访问临界资源的代码称之为临界区,信号量就是用来保护临界区的,信号量分为二元信号量和多元信号量。

(3)例如现在有一份500字节的资源,平均分成5份,每份100字节,每一份用一个信号量进行标识,总共有5个信号量。

(4)信号量本质是一个计数器,在二元信号量中,信号量的个数为1(相当于将临界资源看成一整块),二元信号量本质解决了临界资源的互斥问题 , 通过伪代码进行理解:

大致意思 : 当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时这个资源就被A进程占有,此时需要将sem- -,然后进程A就可以对共享内存进行一系列操作,如果在进程A在访问共享内存时,进程B想要申请访问该共享内存资源,此时sem就为0了,那么这时进程B会被挂起,直到进程A访问共享内存结束后将sem加加,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作。

在这种情况下,无论什么时候都只会有一个进程在对同一份共享内存进行访问操作,也就解决了临界资源的互斥问题。

(5) PV操作,P操作就是申请信号量,而V操作就是释放信号量。


本文转载自: https://blog.csdn.net/m0_52169086/article/details/125375655
版权归原作者 GSX_M 所有, 如有侵权,请联系我们删除。

“进程间通信详解”的评论:

还没有评论