【Linux】进程间通信

一. 什么是什么进程间通信?

进程间通信简称 IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。

进程想要访问真实的物理内存,先要访问进程虚拟地址空间当中的虚拟地址,然后借助页表的映射才能访问到物理空间。这里的虚拟地址空间和页表都是进程级的,保证了进程之间的数据独立,不会相互干扰。但是,进程之间有时也是要相互合作和共享数据的,简单的理解进程间通信就是多个进程对同一份公共资源进行操作,而通信最重要的前提是保证通信的各个进程能看到同一份资源

二. 进程间通信的目的

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

三. 进程间通信的实现

1. 管道

1.1 管道介绍

管道是一种特殊的文件,它本质是一个内核缓冲区,它是进程可以看到并操作的“公共资源”。

进程以先进先出的方式在缓冲区中存取数据:管道一端的进程顺序地将数据写入缓冲区,另一端的进程则顺序地读取相应的数据,该缓冲区可以看做是一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次。

一般我们在写 ps 命令时,都会用到管道,它可以帮助我们完成数据的筛选:
在这里插入图片描述

使用 mkfifo 管道名称 创建一个命名管道:在这里插入图片描述

1.2 管道特点

  1. 管道提供先进先出的流式服务,其中数据的读写操作效果类似于循环队列的删除和插入节点。但循环队列的结构是一个数组,而管道是一个特殊的文件,其本质是一个内核缓冲区。

  2. 管道的生命周期随进程

  3. x

  4. 管道是单工的,数据只能向一个方向流动;如果需要全双工通信,则需建立两个管道。

PS:单工、半双工和全双工是电信计算机网络中的三种通信信道。这些通信信道可以提供信息传达的途径。
在这里插入图片描述

1.3 管道分类

在这里插入图片描述

1.4 匿名管道

在匿名管道中数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系:父进程通过 pipe 函数创建管道,并在管道中写入数据,而子进程从管道读出数据。

匿名管道的创建 — pipe 函数

头文件:#include<unistd.h>
函数原型:int pipe(int fd[2]);
  • 功能:创建一个管道,以实现进程间的通信

  • 返回值:创建成功时返回 0,并将一对打开的文件描述符的值填入 fd 参数指向的数组中。失败时返回 -1 并设置 errno。

  • 参数:fd 是一个元素个数为 2 的整型数组,作为输出型参数传入

PS: 通过 pipe 函数创建的这两个文件描述符 fd[0] 和 fd[1] 分别构成管道的两端,往 fd[1] 写入的数据可以从 fd[0] 读出,并且 fd[1] 一端只能进行写操作,而 fd[0] 一端只能进行读操作,不能反过来使用。要实现双向数据传输(全双工),可以创建两个匿名管道。
在这里插入图片描述

匿名管道通信原理

匿名管道可以理解为我们用 pipe 函数创建了两个文件(写端文件和读端文件),我们把数据写入到写端文件中,这些写入的数据只能通过读端文件读取出来。
在这里插入图片描述
第一步:父进程创建管道
在这里插入图片描述

第二步:fork() 创建子进程
在这里插入图片描述

第三步:父进程关闭 fd[1],子进程关闭 fd[0]。让子进程写数据,父进程读数据。
在这里插入图片描述

问题补充:既然进程间通信的本质是:让不同的进程看到同一份资源。那么父子进程可不可以创建全局缓冲区(比如全局数组)来完成通信呢?

答:不可以,因为父子之间继承的数据具有独立性,虽然刚把子进程创建出来时,父子进程共用这块全局缓冲区,但如果其中一方对这个“全局”缓冲区写入的话,会发生写实拷贝,此时这个全局缓冲区会 分离。

匿名管道的使用

子进程写入数据到匿名管道,父进程从匿名管道中读取数据并打印出来。

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

int main()
{
  int fd[2] = {0};
  // 1、创建管道
  if(pipe(fd) == -1)
  {
    perror("pipe error\n");
    return 1;
  }
  //2、fork() 创建子进程
  pid_t id = fork();
  if(id == 0)// child -> write
  {
    close(fd[0]);
    const char* str = "hello Linux";
    size_t i = 0;
	for(i = 0; i < 10; ++i)
    {
      write(fd[1], str, strlen(str));
      sleep(1);
    }
    close(fd[1]);
  }
  else if(id > 0)// father -> read 
  {
    close(fd[1]);
    char buf[64] = {0};
    while(1)
    {
      ssize_t len = read(fd[0], buf, sizeof(buf) - 1);
      if(len > 0)                                                                                                     
      {
        buf[len] = 0;// 要按照C语言字符串的格式输出,最后一个位置置为0
        printf("recevie:%s\n", buf);
      }
      else if(len == 0)
        printf("********** read end **********\n");
        break;
      }
      else 
      {
        perror("read error\n");
        break;
      }
    }
    close(fd[0]);
  }
  else 
  {
    perror("fork error/n");
    return 2;
  }
  return 0;
}      

编译运行
在这里插入图片描述
PS:在进程运行时,我们可以查看父子进程的打开文件描述符表,查看各自的管道文件打开关闭情况。
在这里插入图片描述

1.5 命名管道

和匿名管道的主要区别在于,命名管道有一个名字,命名管道的名字对应一个磁盘索引节点,有了这个文件名,任何进程有相应的权限都可以对它进行访问。而匿名管道却不同,进程只能访问自己或祖先创建的管道,而不能访任意访问已经存在的管道——因为没有名字。

二者的创建方式也不同:匿名管道使用 pipe 函数创建,而命名管道可以使用 mkfifo 命令或函数创建。

命名管道的创建 — mkfifo

方法一:使用 mkfifo 命令

mkfifo xxx

作用:在当前目录下创建一个命名管道
在这里插入图片描述

方法二:使用 mkfifo 函数

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
  • 功能:在指定路径下创建一个指定名称的命名管道

  • 返回值:创建成功返回 0,失败返回 -1 并设置 errno

  • 参数: 第一个参数传指定路径和管道名称(不写路径的话默认在当前目录下创建),第二个参数传想要设置的管道的权限。

在当前目录下创建一个命名管道:

#include <stdio.h>    
#include <sys/types.h>    
#include <sys/stat.h>    
    
int main()    
{    
  umask(0);    
  int ret = mkfifo("fifo", 0664);    
  if(ret == -1)    
  {    
    perror("mkfifo error");    
    return 1;    
  }    
  printf("ret = %d\n", ret);                                                                                          
  return 0;    
}  

在这里插入图片描述

命名管道通信原理

第一步:创建命名管道在这里插入图片描述

第二步:创建两个进程分别以读和写的方式打开管道文件,其中一个进程把数据写入管道,另外一个进程从管道中读取数据。
在这里插入图片描述

命名管道的使用

下面我们用命名管道来实现一个类似网上聊天的服务:客户端进程给命名管道写入数据,服务端进程负责创建命名管道和接收客户端写入命名管道的数据。

common.h:里面写两个进程所需要的公共头文件和命名管道的名字(因为 open 函数打开命名管道时需要用到管道的名字)

#include <stdio.h>    
#include <fcntl.h>    
#include <sys/types.h>    
#include <sys/stat.h>    
#include <string.h>    
#include <unistd.h>                                                                                                   
    
#define PIPE_NAME "fifo"   

client.c:接收从 stdin 中输入的数据,并把数据写入到命名管道中

#include "common.h"                                                                                                   
                                         
int main()        
{      
  // 1、以“只写”的方式打开管道            
  int fd = open(PIPE_NAME, O_WRONLY);
  if(fd == -1)
  {                                                 
    perror("client open error\n");
    return 1; 
  }    
  // 2、读取标准输入中的数据,把这些数据写到管道中
  char buf[64] = {0};  
  while(1)                                      
  {                
    printf("[client input]: ");
    fflush(stdout);           
    ssize_t len = read(0, buf, sizeof(buf));
    if(len > 0)
    {
      write(fd, buf, len);
      buf[0] = 0;                       
    }              
    else 
    { perror("client read error\n");
      return 2;
    }
  }
  return 0;
}

server.c:该部分负责创建命名管道和接收管道里的数据,我们把这些接收到的数据以字符串的形式打印到显示器上。

#include "common.h"    
    
int main()    
{    
  // 1、创建管道    
  umask(0);    
  if(mkfifo(PIPE_NAME, 0664) == -1)    
  {    
    perror("mkfifo error\n");    
    return 1;    
  }    
  // 2、以只读方式打开管道    
  int fd = open(PIPE_NAME, O_RDONLY);    
  if(fd == -1)    
  {    
    perror("server open reero\n");                                                                                    
    return 2;    
  }    
  // 3、从管道中读取数据    
  char buf[64] = {0};    
  while(1)    
  {    
    ssize_t len = read(fd, buf, sizeof(buf) - 1);    
    if(len > 0)    
    { 
      buf[len] = 0;
      printf("server receive]: %s", buf);
      buf[0] = 0;
    }
    else if(len == 0)
    {
      printf("******* client end *******\n");
      break;
    }
    else 
    {
      perror("server read error\n");
      return 3;
    }
  }
  return 0;
}   

效果演示:我们复制一个 ssh,然后两个 ssh 分别运行 server 和 client 两个进程,可以看到我们在 client 写的数据都可以在 server 接收到。
在这里插入图片描述

1.6 关于管道的几点补充

管道的大小

可以使用ulimit –a 命令来查看当前系统中创建管道文件所对应的内核缓冲区大小。通常为:pipe size 4K,即一个页面大小。
在这里插入图片描述

管道操作同步与互斥具体情况的分析

  • 不 write,一直 read】:管道为空后,read 阻塞(阻塞说明 read 函数还没有结束,只是执行流暂停在函数体中)
  • 不 read,一直 write】:管道满了,write 阻塞
  • write 先关闭,read 还在读】:read 读完管道中的内容后会返回 0
  • read 先关闭,write 还在写】:操作系统会给写端发送 (13)SIGPIPE 信号,把写端终止掉

写入管道的数据是在内存中还是磁盘中?

我们可以测试一下:创建一个命名管道,然后一直写入数据,但是不读取。观察管道文件的大小有什么变化。

#include <string.h>    
#include <fcntl.h>    
#include <sys/stat.h>    
#include <sys/types.h>    
    
int main()    
{    
  umask(0);    
  // 1、创建命名管道 - “fifo”    
  if(mkfifo("fifo", 0664) == -1)    
  {    
    perror("mkfifo error\n");    
    return 1;    
  }    
  // 2、打开管道    
  int fd = open("fifo", O_WRONLY);    
  open("fifo", O_RDONLY);    
  // 3、一直给管道写入数据,但是不读取    
  const char* str = "hello Linux";    
  while(1)    
  {    
    write(fd, str, strlen(str));    
  }    
  return 0;    
} 

编译运行后,发现管道文件的大小一直是0
在这里插入图片描述

可以发现即使一直不读取管道内存放的数据,管道中的数据也从来没有被刷新到磁盘上(管道文件的磁盘大小一直为0),说明管道的数据是存放在内存当中的,即以管道进行的进程间通信是是在内存当中进行的。

命令中所使用的管道(|)是匿名管道还是命名管道?

在敲命令时我们也经常用到管道,就是一条竖划线,比如 “ps axj | grep ./a.out | grep -v grep” 这样,这个管道的类型我们可以推测一下。

我们让三个进程同时打开一个命令行管道,并观察它们之间的 pid 信息关系:
在这里插入图片描述

有共同的父进程,说明这三个进程之间是兄弟关系,而这个父进程就是命令行解释器 bash。
在这里插入图片描述

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

2. System V 通信

System V, 曾经也被称为 AT&T System V,是 Unix 操作系统众多版本中的一支。它最初由 AT&T 开发,在 1983 年第一次发布。一共发行了 4 个 System V 的主要版本:版本 1、2、3 和 4。其中 System V Release 4,或者称为 SVR4,是最成功的版本,它成为一些 UNIX 共同特性的源头。

System V IPC 包括三种进程间通信方式:

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

消息队列、信号量、共享内存这三种 IPC 方式完全被 Linux 系统继承和兼容,常用在 Linux 服务端编程的进程间通信环境中。

三种 System V IPC 对象的函数说明如下表所示:

消息队列信号量共享内存
头文件<sys/msg.h><sys/sem.h><sys/shm.h>
创建或打开操作msggetsemgetshmget
控制操作msgctlsemgetshmget
关联操作msgsnd
msgrcv
semopshmat
shmdt

System V 通信的特点

  1. System V IPC 未遵循传统 UNIX 中“一切都是文件”的哲学,而是采用标识符ID和键值来标识一个 System V IPC 对象。每种 System V IPC 对象都有一个相关的 get 调用函数,该函数会返回一个整形标识符ID,System V IPC 后续的函数操作都要作用在这个标识符ID 上。
  2. System V IPC 对象的作用范围是整个操作系统,内核没有维护引用计数。调用各种 get 函数返回的 ID 是系统级的标识符ID,对于任何进程而言,无论是否存在亲缘关系,只要有相应的权限,都可以通过标识符ID访问对应的 System V IPC 对象以达到进程间通信的目的。
  3. System V IPC 对象是系统级的。哪怕创建 System V IPC 对象的进程已经退出,哪怕有一段时间没有任何进程打开该 IPC 对象,只要不执行删除操作或系统重启,后面启动的进程依然可以使用之前创建的 System V IPC 对象来通信。
  4. 此外,我们无法像操作文件一样,通过 open、read、write 等函数和文件描述符来操作 System V IPC 对象。System V IPC 对象在文件系统中没有实体文件与之关联。我们不能用文件相关的操作函数来访问它或修改它的属性。对于它们,只能通过专门的系统调用(如msgctl、semop 等)来控制和操作它们。在 Shell 中无法用 ls 查看存在的 IPC 对象,无法用 rm 命令将其删除,也无法用 chmod 命令来修改它们的权限。不过 Linux 提供了 ipcs、ipcrm 和 ipcmk 等命令可以来操作这些 IPC 对象。

2.1 key_t 键和 ftok 函数

2.1.1 ftok(…) 函数介绍

作用:把一个文件名及其路径还有一个整数标识符结合在一起转换成一个 key_t 类型的值,这个值称为 IPC 键值。

ftok 函数原型及说明如下:

函数原型key_t ftok(const char *pathname, int proj_id)
所需头文件#include <sys/types.h>
#include <sys/ipc.h>
函数说明把从 pathname 导出的信息与 id 的低序 8 位组合成一个整数 IPC 键值
函数传入值pathname:指定的文件及其所在路径,此文件必须存在
proj_id:计划代号(project ID)
函数返回值成功:返回 IPC 键值
出错:返回 -1,错误原因存于 error 中
附加说明key_t 其实就是 32 位整形的重定义
2.1.2 ftok(…) 函数底层实现原理

ftok 的底层实现就是调用 stat 函数,然后组合以下三个值:

  • pathname 所在的文件系统的信息(对应 stat 结构的 st_dev 成员)。
  • 该文件在本文件系统内的索引节点号(对应 stat 结构的 st_ino 成员)。
  • proj_id 的低序 8 位(不能全为 0)。

【总结】这个函数在 Linux 上的实现过程为:按照给定的路径名,获取到文件的 stat 信息,从 stat 信息中取出 st_dev 和 st_ino,然后结合给出的 proj_id,按照下图所示的算法得到 32 位的 IPC 键值,类型为key_t。
在这里插入图片描述

2.1.3 ftok(…) 函数使用举例
#include <sys/types.h>    
#include <sys/ipc.h>
#include <stdio.h>

#define PATH "/home/ljj/Linux_code/test_2022_2_15/System_V_IPC"// pathname
#define PROJ_ID 0x66 // proj_id

int main()
{
  key_t key = ftok(PATH, PROJ_ID);
  if(key == -1)
  {
    perror("ftok error\n");
    return 1;
  }
  else 
  {
    printf("key:%d\n", key);
  }
  return 0;
}          

编译运行,生成系统级的 key 键值:
在这里插入图片描述

2.1.4 关于 ftok(…) 的几点补充

ftok 参数这样设计的意义

  • proj_id 值存在的的意义是让一个文件能同时生成多个 IPC 键值。
  • ftok 对于同一个文件最多可得到 IPC 键值的数量为 0xff(即 256 个),因为 ftok 只取 proj_id 二进制值的后 8 位,即 16 进制的后两位与文件信息一起组成 IPC 键值。

关于 ftok 函数的一个陷阱

在使用 ftok 函数时,里面有两个参数,即 pathname 和 proj_id,pathname 为指定的文件名和路径,而 proj_id 为子序列号,这个函数的返回值就是 IPC 键值,它与指定文件的 inode 编号和子序列号 id 有关,这样就会给我们一个误解,即只要文件的路径,名称和子序列号不变,那么得到的 IPC 键值也就永远不变。

事实上,这种认识是错误的,想想一下,假如存在这样一种情况:在访问同一共享内存的多个进程先后调用 ftok 的时间段中,如果 pathname 指向的文件或者目录被删除而且又重新创建,那么文件系统会赋予这个同名文件新的 inode 节点信息,于是这些进程调用的 ftok 都能正常返回,但 IPC 键值却不一定相同了。由此可能造成的后果是,原本这些进程意图访问一个相同的共享内存对象,然而由于它们各自得到的键值不同,它们指向的共享内存不再一致;如果这些共享内存都得到创建,则在整个应用运行的过程中表面上不会报出任何错误,然而通过一个共享内存对象进行进程间通信的目的将无法实现。

所以要确保 IPC 键值不变,要么确保多个进程在进行 ftok 期间,对应路径的文件不被删除或者重新创建。

2.2 System V IPC 对象的创建

在用户层上 System V IPC 对象是靠标识符ID来识别和操作的。该标识符要具有系统唯一性。这和文件描述符不同,文件描述符是进程级的,一个进程内的文件描述符4和另一个进程的文件描述符4可能毫不相关。但是 IPC 的标识符ID是系统级的全局变量,只要知道该值且有相应的权限,任何进程都可以通过标识符进行进程间通信。

三种 IPC 对象操作的初始操作都是调用相应的 ipcget 函数来获取标识符ID的,IPC 对象的 get 函数将 ftok 生成的 IPC 键值转换成相应的 IPC 标识符,其中根据 IPC get 函数中的最后一个参数 IPCflag 的不同,会有不同的控制逻辑。

// 共享内存
int shmget(key_t key, size_t size, int shmflg);
// 消息队列
int msgget(key_t key, int msgflg);
// 信号量
int semget(key_t key, int nsems, int semflg);

这三个 IPCget 函数有三个相同点

  • 返回值都是一个 int 类型的标识符ID
  • 第一个参数都是 IPC 键值
  • 最后一个参数都是权限

另外,这三种 System V IPC 对象有很多共性,不仅仅它们的 get 函数调用相似,而且调用后描述具体生产的 IPC 对象的结构体也是相似的(进程每次调用 IPCget 函数就会生成一个 struct IPCid_ds 类型的对象),它们里面都有一个描述权限的类型为 struct ipc_perm 的成员:

// 描述消息队列控制相关的结构体
struct msqid_ds {
struct ipc_perm msg_perm;
...
}
// 描述信号量控制相关的结构体
struct semid_ds {
struct ipc_perm sem_perm;
...
}
// 描述共享内存控制相关的结构体
struct shmid_ds {
struct ipc_perm shm_perm;
...
}

下面来看看这个 struct ipc_perm 结构体的完整定义:

struct ipc_perm {
key_t   key ;          /* 此IPC对象的key键 */
uid_t   uid ;          /* 此IPC对象用户ID */
gid_t   gid ;          /* 此IPC对象组ID */
uid_t   cuid ;         /* IPC对象创建进程的有效用户ID */
gid_t   cgid ;         /* IPC对象创建进程的有效组ID */
mode_t   mode ;        /* 此IPC的读写权限 */
ulong_t  seq ;         /* IPC对象的序列号 */
} ;

PS:实际在 IPC 对象的生命周期中,IPC 键值到标识符ID的映射是大部分是稳定不变的,即同一个 IPC 键值调用 IPCget 函数,总是返回相同的标识符ID。但是一旦 IPC 键值对应的 IPC 对象被删除或系统重启后,则重新使用 key 创建的新的 IPC 对象分配的标识符很可能是不同的。

2.2.1 IPCget 系列函数介绍

第一个参数 key 值的选择

不同进程可通过同一个IPC 键值获得同一个标识符ID,进而操作同一个 System V IPC 对象。那么现在问题就演变成了如何选择 key。

对于 IPCget 函数里的 key 值,有如下三种选择:

  • 调用 ftok,给它传递 pathname 和 proj_id,操作系会统根据两者的值合成 key 值。
  • 调用 IPCget 函数时指定 key 为 IPC_PRIVATE(IPC_PRIVATE 为宏定义,其值等于0),这时内核会保证创建一个新的、唯一的 IPC 对象,这时的 IPC 标识符与内存中的标识符不会冲突,从这个角度看将其称之为 IPC_NEW 或许更合理。不过,使用 IPC_PRIVATE 来得到 IPC标识符会存在一个问题,即不相干的进程无法通过 key 值来得到同一个 IPC 标识符来完成进程间通信了。因为 IPC_PRIVATE 是操作系统自动帮你创建一个新的 IPC 对象,因此 IPC_PRIVATE 一般用于父子进程,父进程调用 fork 之前使用 IPC_PRIVATE 创建好 IPC 对象,子进程创建完成后,它也就继承了其父的 IPC 标识符,从而父子进程可以通信。当然无亲缘关系的进程也可以使用 IPC_PRIVATE 完成通信,只是稍微麻烦了一点,IPC 对象的创建者必须想办法将 IPC 标识符共享出来,让其它进程有办法获取到,从而通过这个 IPC 标识符进行进程间通信。

PS:key 键值被存储在 struct ipc_perm 类型的成员中

在这里插入图片描述

IPCget 系列函数的返回值

给 semget、msgget、shmget 传入 key 键值,它们返回的都是相应的 IPC 对象的标识符ID,创建失败返回 -1。

注意 IPC 键值和 IPC 标识符是两个概念,虽然它们都是唯一标识 IPC 对象的,但在用户层的操作上,我们操作 IPC 对象使用的都是标识符ID。从 IPCget 函数调用上来说:标识符ID是建立在 key 键值之上生成的。

下图画出了从 IPC 键值生成 IPC 标识符的流程图,其中 key 为调用 ftok 函数生产的 IPC 键值;ipc_id 为I PC 标识符,由 IPCget 系列函数通过 key 的传参生成。ipc_id 在信号量函数中称为 semid,在消息队列函数中称为 msgid,在共享内存函数中称为 shmid,它们表示的是各自 IPC 对象的标识符ID。
在这里插入图片描述

PS:既然 key 值和标识符ID都是唯一标识 IPC 对象的,那么描述 IPC 对象的数据结构中只保存它们中的一个就够了(保存的是 key 键值),而把标识符ID对外暴露给用户使用,这样多一层封装也就可以多一层保护,可以考虑为 key 值是更加偏向于底层的一个标识,它的权限会更大些,为了不让用户恶意操作,提供权限更小的标识符ID来让我们操作 IPC 对象。

最后一个参数 IPCflg

msgget、semget、shmget函数中的最后一个参数 IPCflg(msgget 中为msgflg、semget 中为 semflg、shmget 中为 shmflg)为 IPC 对象的权限设置值,三种 IPCget 函数中 IPCflg 的作用基本相同。

IPCflag 为 IPC 对象创建权限,其格式为 0xxxxx,其中 0x 表示 8 进制的意思,其中低三位分别为拥有者、所数组和其他人的读、写、执行权限(执行位不使用)。既然执行位不用,只剩下读写权限,对应操作者的权限表示如下图所示:

可读可写
拥有者040002000600
所属组004000200060
其他人000400020006

此外,IPC 对象存取权限常与 IPC_CREAT、IPC_EXCL 两种标志进行或运算完成对 IPC 对象创建权限的管理,在这里姑且把 IPC_CREAT、IPC_EXCL 两种标志称为 IPC 创建模式标志。下面是两种创建模式标志在 <sys/ipc.h> 头文件中的宏定义。

#define IPC_CREAT    01000    /* Create key if key does not exist. */
#define IPC_EXCL     02000     /* Fail if key exists.  */

关于 IPC_CREAT 和 IPC_EXCL 这两种 IPC 创建模式标志 ,它们的组合作用如下表所示:

IPC创建模式标志不存在已存在
无特殊标志出错,errno=ENOENT成功,引用已存在对象
IPC_CREAT成功,创建新对象成功,引用已存在对象
IPC_CREAT l PC_EXCL成功,创建新对象出错,errno=EEXIST

PS:第三个参数传入的权限组合被存储在ipc_perm 结构中的 mode 成员里
在这里插入图片描述

2.2.2 ipcget 系列函数的总结

下画出了 semget、msgget、shmget 函数创建或打开一个 IPC 对象的逻辑流程图,它说明了内核创建和访问 IPC 对象的流程:
在这里插入图片描述

【总结】使用 IPCget 函数时:如果确认 IPC 对象已存在且已经知道 key,那么第三个权限参数传 0 就行;如果确认 IPC 对象不存在且已经知道 key,想要创建一个全新的 IPC 对象,那么权限参数使用 IPC_CREAT|IPC_EXCL 和 访问权限(如0664)的组合。

2.3 共享内存

共享内存是被多个进程共享的一部份物理内存。如果多个进程都把该内存区域映射到自己的虚拟地址空间。则这些进程就都可以直接访问该共享内存区域,从而可以通过该区域进行通信。共享内存是进程间共享数据的一种最快方法,一个进程向共享内存区域写入了数据,共享这个内存区域的就可以立刻看到其中的内容。
在这里插入图片描述

2.3.1 共享内存内核结构定义

每一个新创建的共享内存对象都可以用一个struct shmid_ds数据结构来表达,它描述了这个共享内存区的访问信息,字节大小,最后一次挂接时间、分离时间、改变时间,创建该共享内存区域的进程ID,最后一次对它操作的进程ID,当前有多少个进程挂接该共享内存等。其定义如下:

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.3.2 共享内存的使用介绍

在这里插入图片描述

第一步:申请共享内存 — shmget()

共享内存的申请使用的是shmget()函数,该函数的使用需要配合到ftok()函数(上文已做介绍)。下面是shmget函数的介绍:
在这里插入图片描述


如果用shmget()创建了一个新的共享内存对象,则shmid_ds结构成员变量的值设置如下:

  • shm_lpid、shm_nattch、shm_atime、shm_dtime设置为0
  • shm_ctime设置为当前时间
  • shm_segsz设成创建共享内存的大小
  • shmflg的读写权限放在shm_perm.mode中
  • shm_perm结构的uid和cuid成员被设置成当前进程的有效用户ID,gid和cuid成员被设置成当前进程的有效组ID

在这里插入图片描述

shmget使用举例

#include <stdio.h>    
#include <sys/types.h>    
#include <sys/ipc.h>    
#include <sys/shm.h>    
    
#define PATH "/home/ljj/Linux_code/test_2022_2_15/System_V_IPC/shm"// 任意已存在的路径    
#define PROJ_ID 3// 任意数字    
#define SIZE 4096// 共享内存大小
    
int main()    
{    
  // 1、ftok获得唯一的key键值    
  key_t key = ftok(PATH, PROJ_ID);    
  if(key == -1)    
  {    
    perror("ftok error\n");    
  }    
  else    
  {    
    printf("key:%d\n", key);                                                                                          
  }    
  // 2、shmget申请共享一块全新的共享内存    
  int shmid = shmget(key, SIZE, 0664|IPC_CREAT|IPC_EXCL);    
  if(shmid == -1)    
  {   
  	perror("shmget error\n");
  }
  else 
  {
    printf("shmid:%d\n", shmid);
  }
  return 0;
}      

编译运行得到结果:
在这里插入图片描述

我们申请到共享内存后,可以通过ipcs -m 命令查看申请到的共享内存信息:
在这里插入图片描述
下面是ipcs -m列出的第一行标志的意义:

标志意义
key共享内存的key键值
shmid共享内存的标识符ID
owner创建的用户
perms权限
bytes创建的大小
nattch该共享内存当前的挂接数
status共享内存的状态

补充:ipcs查看进程间通信资源的选项

  • -m 针对共享内存的操作
  • -q 针对消息队列的操作
  • -s 针对信号量的操作
  • -a 针对所有资源的操作

第二步:把共享内存挂接到进程的虚拟地址空间 — shmat()

上一步虽然成功创建了共享内存,但进程还未与共享内存关联(挂接)起来,所以还不能访问共享内存来进行进程间通信,我们需要使用shmat()来把共享内存内存与进程关联起来,这里的at是attach的缩写。
在这里插入图片描述


在这里插入图片描述

既然共享内存已经创建,那么shmget()的作用就是获取到已经存在的共享内存对象的标识符ID,对之前shmget()调用部分的参数进行修改:
在这里插入图片描述
下面一段代码演示shmat()的使用,挂接成功该函数会返回该共享内存对象的地址,我们可以直接通过该地址在共享内存里写入或读取数据:

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

#define PATH "/home/ljj/Linux_code/test_2022_2_15/System_V_IPC/shm"// 任意已存在的路径
#define PROJ_ID 3// 任意数字                                                                                          
#define SIZE 4096// 共享内存大小

int main()
{
  // 1、ftok获得唯一的key键值
  key_t key = ftok(PATH, PROJ_ID);
  if(key == -1)
  {
    perror("ftok error\n");
  }
  // 2、shmget得到已存在共享内存对象的标识符ID
  int shmid = shmget(key, SIZE, 0);
  if(shmid == -1)
  {
    perror("shmget error\n");
  }
  // 3、shmat把进程和共享内存挂接 
  printf("attach begin\n");
  sleep(10);

  void* shmaddr = shmat(shmid, NULL, 0);
  if(shmaddr == (void*)-1)
  {
    perror("shmat error\n");
  }
  // do something about IPC

  sleep(5);
  printf("attach end\n");
  return 0;
}     

结果检测:
在这里插入图片描述

第三步:去关联共享内存 — shmdt()

进程运行结束后,系统会自动取消与进程挂接的共享内存,另外我们也可以在进程中调用shmdt()来取消特定共享内存对象的关联,函数名中dt的detach的缩写。
在这里插入图片描述
在这里插入图片描述
shmdt()的调用很简单,只需传shmat()返回的共享内存对象的虚拟地址即可,这里不再演示。

第四步:释放共享内存空间 — shmctl()

我们在一个进程中利用shmget()申请一个全新的共享内存对象,即使进程退出了,这个共享内存对象依然存在,想要输出这个共享内存有两种方法:命令和函数。

方法一:使用命令ipcrm -m 标识符ID删除共享内存对象

下面我们使用这个命令删除已存在的shmid为5的共享内存对象:
在这里插入图片描述
需要注意的是使用命令删除时最后跟的是共享内存对象的标识符ID(即shmid),而不是key键值。在用户层操作共享内存对象都是通过shmid来进行的,key关键字仅仅在shmget()时才会被用到,而key关键字本身是由ftok()调用得来的。

方法二:调用shmctl()删除共享内存对象

shmctl()不仅仅可以删除共享内存对象,而且可以得到或修改共享内存对象的信息,关于该函数的具体说明如下图:
在这里插入图片描述
下面代码演示删除一个已存在的标识符ID为6的共享内存对象,只是释放空间的话shmctl()最后一个参数只需传NULL即可:

#include <stdio.h>    
#include <unistd.h>    
#include <sys/types.h>    
#include <sys/ipc.h>    
#include <sys/shm.h>    
    
#define PATH "/home/ljj/Linux_code/test_2022_2_15/System_V_IPC/shm"// 任意已存在的路径    
#define PROJ_ID 3// 任意数字    
#define SIZE 4096// 共享内存大小    
    
int main()    
{    
  // 1、ftok获得唯一的key键值    
  key_t key = ftok(PATH, PROJ_ID);    
  if(key == -1)    
  {    
    perror("ftok error\n");    
  }    
  // 2、shmget得到已存在共享内存对象的标识符ID    
  int shmid = shmget(key, SIZE, 0);                                                                                   
  if(shmid == -1)    
  {    
    perror("shmget error\n");    
  }    
  // 3、shmat把进程和共享内存挂接     
  void* shmaddr = shmat(shmid, NULL, 0);
  if(shmaddr == (void*)-1)
  {
    perror("shmat error\n");
  }
  // 4、shmdt取消进程与共享内存之间的挂接
  if(shmdt(shmaddr) == -1)
  {
    perror("shmdt error\n");
  }
  // 5、shmctl释放共享内存空间
  printf("clean begin\n");
  sleep(5);
  shmctl(shmid, IPC_RMID, NULL);                                                                                                                   
  sleep(5);
  printf("clean end\n");
  return 0;
}

我们在另一个对话里检测共享内存对象的情况,发现最终标识符ID为6的共享内存对象被清除:
在这里插入图片描述

2.3.3 用共享内存实现serve&client通信

我们想要实现使用代码创建一个共享内存, 支持两个进程进行通信:

  • server:创建共享内存并接受数据,并负责最后释放共享内存空间。
  • client:向共享内存中写入数据。

comon.h
存放两个进程公共的头文件和需要使用到的宏定义常量。

#pragma once     
    
#include <stdio.h>                                                                                                    
#include <unistd.h>    
#include <sys/types.h>    
#include <sys/ipc.h>    
#include <sys/shm.h>    
    
#define PATH "/home/ljj/Linux_code/test_2022_2_15/System_V_IPC/shm"// 任意已存在的路径    
#define PROJ_ID 3// 任意数字    
#define SIZE 4096// 共享内存大小    

server.c
负责创建共享内存并接受、打印数据,和最后释放共享内存空间,持续十秒。

#include "common.h"                                                                                                   
                   
int main()    
{                              
  // 1、ftok获得唯一的key键值    
  key_t key = ftok(PATH, PROJ_ID);          
  if(key == -1)                                              
  {                  
    perror("ftok error\n");    
  }                              
  // 2、shmget创建一个全新的共享内存对象    
  int shmid = shmget(key, SIZE, 0664|IPC_CREAT|IPC_EXCL);    
  if(shmid == -1)                           
  {                           
    perror("shmget error\n");    
  }                             
  // 3、shmat把进程和共享内存挂接     
  void* shmaddr = shmat(shmid, NULL, 0);    
  if(shmaddr == (void*)-1)    
  {    
    perror("shmat error\n");    
  }                                           
  // 4、读取共享内存对象当中的数据    
  int i = 0;    
  for(i = 0; i < 10; ++i)  
  {
    printf("server:%s\n", (char*)shmaddr);
    sleep(1);
  }
  // 5、shmdt取消进程与共享内存之间的挂接
  if(shmdt(shmaddr) == -1)
  {
    perror("shmdt error\n");
  }
  // 6、shmctl释放共享内存空间
  shmctl(shmid, IPC_RMID, NULL);
  return 0;
}    

client.c
向共享内存中写入"A - Z"字符串序列,从’A’开始每秒添加一个字母,持续十秒。

#include "common.h"                                                                                                   
                   
int main()    
{                              
  // 1、ftok获得唯一的key键值    
  key_t key = ftok(PATH, PROJ_ID);                
  if(key == -1)                        
  {                  
    perror("ftok error\n");    
  }                              
  // 2、shmget得到已存在共享内存对象的标识符ID    
  int shmid = shmget(key, SIZE, 0);    
  if(shmid == -1)                           
  {                           
    perror("shmget error\n");    
  }                             
  // 3、shmat把进程和共享内存挂接     
  void* shmaddr = shmat(shmid, NULL, 0);    
  if(shmaddr == (void*)-1)    
  {    
    perror("shmat error\n");    
  }                                         
  // 4、client端向共享内存对象写入数据    
  int size = 0;    
  for(size = 0; size < 10; ++size) 
  {
    ((char*)shmaddr)[size] = 'A' + size;
    ((char*)shmaddr)[size + 1] = '\0';
    sleep(1);
  }
  // 5、shmdt取消进程与共享内存之间的挂接
  if(shmdt(shmaddr) == -1)
  {
    perror("shmdt error\n");
  }
  return 0;
}        

创建两个会话,分别运行server和client,观察结果:
在这里插入图片描述

2.3.4 共享内存通信的特点

特点1:共享内存的生命周期随内核

共享内存只需要一个进程创建就行,其他进程拿到该共享内存对象的标识符ID即可通信,后面即使所有相关的进程都退出了,这个共享内存对象依然存在。

而管道的生命周期是随进程的,只有当所有打开过这个管道的进程都退出了,管道才会彻底被清除。

特点2:共享内存无同步无互斥

当两个或多个进程使用共享内存进行通信时,同步问题的解决显得尤为重要,否则就会造成因不同进程同时读写一块共享内存中的数据而发生混乱。在通常的情况下,通过使用信号量来实现进程的同步。对比一下管道是提供同步与互斥的。

特点3:共享内存是最快的通信机制

一个进程向共享内存写入数据,共享这个区域的所有进程就可以立即看到其中的内容。我们可以拿共享内存和管道比较。

两个进程通过管道通信,数据要经过四次拷贝:

  1. 数据从起始文件拷贝到写端缓冲区
  2. 数据从写端缓冲区拷贝到管道文件中
  3. 数据从管道文件拷贝到读端缓冲区
  4. 数据从读端缓冲区拷贝到目标文件

在这里插入图片描述
如果两个进程通过共享内存通信,数据仅需拷贝两次。因为连个进程都有共享内存的首元素地址,整个通信过程就是起始文件把数据拷贝到共享内存,然后共享内存把这些数据拷贝到目标文件。

2.4 消息队列

一个或多个进程向消息队列写入信息,另一个或多个进程从消息队列中读取信息,这种进程通信机制通常使用在请求/服务模型中,请求进程向服务进程发送请求的消息,服务进程读取消息并执行相应的操作。在许多微内核结构的操作系统中,内核和各组件之间的基本通信方式就是消息队列。例如,在Minix操作系统中,内核、I/O任务,服务器进程和用户进程之间就是通过消息队列实现通信的。

2.4.1 消息队列的内核数据结构

当在系统中创建每一个消息队列时,内核创建、存储及维护着msqid_ds结构的一个实例。msqid_ds结构的具体说明如下:

struct msqid_ds {
	struct ipc_perm msg_perm;
	struct msg *msg_first;		/* 消息队列中的第一条消息,即链表头  */
	struct msg *msg_last;		/* 队列中的最后一条信息,即链表尾 */
	__kernel_time_t msg_stime;	/* 发送给队列的最后一条消息的时间 */
	__kernel_time_t msg_rtime;	/* 从消息队列接收到的最后一条消息的时间 */
	__kernel_time_t msg_ctime;	/* 最后修改队列的时间 */
	unsigned short msg_cbytes;	/* 队列上所有消息总的字节数 */
	unsigned short msg_qnum;	/* 在当前队列上消息的个数 */
	unsigned short msg_qbytes;	/* 队列最大字节数 */
	__kernel_ipc_pid_t msg_lspid;	/* 发送最后一条消息的进程的pid */
	__kernel_ipc_pid_t msg_lrpid;	/* 接收最后一条消息的进程的pid */
};
2.4.2 消息队列通信原理

消息队列常使用在两个进程之间收发送消息的场合。如下图所示,一个进程向消息队列发送消息,而另一个进程从消息队列收取消息。
在这里插入图片描述

2.5 信号量

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

2.5.1 什么是信号量?

信号量(也称信号灯)与其他进程间通信的方式不大相同,它主要提供对进程间共享资源(临界资源)的访问控制机制,相当于内存中的一个标志,进程可以根据它判断是否能够访问某些共享资源。从而实现多个进程对某些临界资源的互斥访问;同时,进程也可以修改该标志。信号量除了用于访问控制外,还可以用于同步。由于一个信号量标识符指向的是一组信号量,所以在这里把信号量称为信号量集,一个信号量集使用同一个信号量标识符(或称信号量集标识符),管理的是一组信号量。这样实现避免了系统中有过多的信号量对象,而且易于编程。用户使用信号量时以信号量集中的每一个信号量为操作单位,所以,操作信号量时要指明信号量标识符和该信号量在信号量集中的编号。

2.5.2 信号量内核结构定义

一个semid_ds数据结构描述了一个信号量集,一个信号量集可以管理一组信号量。semid_ds结构定义如下:

struct semid_ds {
	struct ipc_perm	sem_perm;		/* 包含信号量集资源的属主和访问权限 */
	__kernel_time_t	sem_otime;		/* 最后一次操作时间 */
	__kernel_time_t	sem_ctime;		/* 最后一次修改时间 */
	struct sem	*sem_base;		/* 指向信号量集合的指针 */
	struct sem_queue *sem_pending;		/* 挂起信号量操作队列 */
	struct sem_queue **sem_pending_last;	/* 最后挂起信号量操作队列 */
	struct sem_undo	*undo;			/* undo 标志信号量列表指针 */
	unsigned short	sem_nsems;		/* 此信号量集中信号量的个数 */
};

下图展示了信号量集的实现方法。从图中可以看出,sem_base指向的是一组信号量,所以一个信号量集管理的是一组信号量,有关该信号量集中信号的个数,用户可以实际需要自行制定。
在这里插入图片描述

2.5.3 为什么要使用信号量?

共享内存就是通过两个进程访问一块物理空间中的公共资源,而产生的的进程间通信,但是,若是在访问这块公共资源时,二者没有互斥与同步机制,那么必然会造成对 公共资源的访问出现问题。

  • 互斥

假如现在有只有一台电脑,而有两个以上的人需要使用电脑,那么实际情况应该怎么分配呢?很明显,只能等一个人使用完之后,另一个人再使用。为了不被人打扰,前一个人在使用的时候,给旁边立个牌子,写上:正在使用,请勿打扰。然后第二个人就一直在等(在进程中就是阻塞式等待)。现在的重点在这块牌子上,这就保证了二者没有同一时间使用,而不会引发二个人的冲突。这就是互斥机制。

  • 同步

那假如前一个人一次只使用两分钟,或更短时间,每次结束后,第二个人刚准备去使用电脑,前一个人又重新坐下,反复这个动作,第二个人就无法使用,但是在互斥机制上也完全符合规则。同步机制就在于解决将这种情况,只要前一个人起身,不使用电脑了,那么就只能重新排队,换下一个人使用。

同样,在进程中,也是如此,我们不妨把两个人的角色改为两个进程,那个电脑作为公共资源,则完全符合我们对于进程的认识。
  
所以,信号量就相当于一个第三方的同步互斥机制来保护一块公共资源,因为两个进程都可以看到信号量,那么信号量也是一个公共资源,则我们必须保证信号量的原子操作。防止在信号量的访问中,另外有进程来访问信号量,更改信号量的值,而造成第二个进程执行完后返回第一个进程造成的信号量的变化而导致的对于公共资源(临界资源)的保护失效。我们可以假设此时只有一份临界资源,那么信号量的值为1时代表可用,为0时代表邻界资源正在有进程使用,当为1时,有进程来需要使用这个邻界资源,那么要对信号量减一将其变为0,用完后加一变成1。

2.5.3 信号量通信原理

在这里插入图片描述
进程A想要操作临界资源,此时sem=1,表示临界资源可以申请,这时进程A令sem减一后开始操作临界资源。这时进程B也想要操作临界资源,发现信号量sem=0,只能挂起等待信号量等于1之后才能操作临界资源。
在这里插入图片描述
最后我们把申请信号量(sem减一)的操作叫做P操作,是否信号量(sem加一)的操作叫做V操作。

  • 6
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值