一. 是什么进程间通信
进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
每一个进程想要访问物理内存,都是通过访问进程虚拟地址空间当中的虚拟地址,借助页表的映射来访问的。这里的虚拟地址空间和页表都是进程级的,保证了进程之间的数据独立,不会相互干扰。但是,进程之间也是要相互合作的,简单的理解进程间通信就是多个进程对同一份公共资源进行操作,而通信最重要的前提是保证进程能看到同一份资源。
二. 进程间通信的目的
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
三. 进程间通信的实现
1. 通信方式(1) — 管道
1.1 管道介绍
管道是一种特殊的文件,它的实质是一个内核缓冲区,即进程可以看到并操作的“公共资源”。进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次。
我们在写一些命令时会配合管道使用:
使用mkfifo命令创建一个命名管道:
1.2 管道特点
- 管道提供先进先出的流式服务,数据的读写操作的效果类似循环队列的删除和插入节点。但循环队列的结构是数组,而管道是一个特殊的文件。
- 内核会对管道操作进行同步与互斥。
- 进程退出,管道释放,所以管道的生命周期随进程。
- 内核会对管道操作进行同步与互斥。即任何时候只能够一个进程在使用公共资源。
- 管道是半双工的,数据只能向一个方向流动;需要双方同时相互通信时,需要建立起两个管道。
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:接收标准输入输入的数据,并把数据写入命名管道中。
#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阻塞
- write写完后关闭,read读完管道内容后返回0(我们判断这个返回值做后续处理,比如把读端关闭)。
- 不read,一直write,管道满了,write阻塞
- 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 ./mytest”这样,这个管道的类型我们可以推测一下。
我们让三个进程同时打开一个命令中的管道,并观察它们相互之间pid信息的关系:
有共同的父进程,说明这三个进程之间是兄弟关系,而这个父进程就是命令行解释器bash。
若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。
2. 通讯方式(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包括三种进程间通信方式:
- 共享内存
- 消息队列
- 信号量
消息队列、信号量、共享内存这三种方式完全被Linux系统继承和兼容,常用在Linux服务端编程的进程间通信环境中。而此三类编程函数在实际项目中都是用System V IPC函数实现的。System V IPC函数名称和说明如下表所示:
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操作System V IPC对象以达到进程间通信的目的。
3.System V IPC对象具有内核持久性。哪怕创建System V IPC对象的进程已经退出,哪怕有一段时间没有任何进程打开该IPC对象,只要不执行删除操作或系统重启,后面启动的进程依然可以使用之前创建的System V IPC对象来通信。
4.此外,我们无法像操作文件一样来操作System V IPC对象。System V IPC对象在文件系统中没有实体文件与之关联。我们不能用文件相关的操作函数来访问它或修改它的属性。所以不得不提供专门的系统调用(如msgctl、semop等)来操作这些对象。在shell中无法用ls查看存在的IPC对象,无法用rm将其删除,也无法用chmod来修改它们的访问权限。幸好Linux提供了ipcs、ipcrm和ipcmk等命令来操作这些对象。
2.1 key_t键和ftok函数
2.1.1 ftok函数介绍
函数ftok把一个已存在的路径名和一个整数标识符转换成一个key_t值,称为IPC键值。ftok函数原型及说明如下:
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位的key值,类型为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"// 任意存在的路径 #define PROJ_ID 0x66 // 任意值 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 key键值。
- ftok利用同一文件最多可得到IPC key键值0xff(即256)个,因为ftok只取proj_id值二进制的后8位,即16进制的后两位与文件信息合成IPC key键值。
关于ftok()函数的一个陷阱
在使用ftok()函数时,里面有两个参数,即pathname和id,pathname为指定的文件名,而proj_id为子序列号,这个函数的返回值就是key键值,它与指定的文件的inode编号和子序列号id有关,这样就会给我们一个误解,即只要文件的路径,名称和子序列号不变,那么得到的key值永远就不会变。
事实上,这种认识是错误的,想想一下,假如存在这样一种情况:在访问同一共享内存的多个进程先后调用ftok()时间段中,如果pathnamee指向的文件或者目录被删除而且又重新创建,那么文件系统会赋予这个同名文件新的i节点信息,于是这些进程调用的ftok()都能正常返回,但键值key却不一定相同了。由此可能造成的后果是,原本这些进程意图访问一个相同的共享内存对象,然而由于它们各自得到的键值不同,实际上进程指向的共享内存不再一致;如果这些共享内存都得到创建,则在整个应用运行的过程中表面上不会报出任何错误,然而通过一个共享内存对象进行数据传输的目 的将无法实现。
所以要确保key值不变,要么确保ftok()的文件不被删除,要么不用ftok(),指定一个固定的key值。
2.2 System V IPC对象的创建
在用户层上System V IPC对象是靠标识符ID来识别和操作的。该标识符要具有系统唯一性。这和文件描述符不同,文件描述符是进程级的,一个进程内的文件描述符4和另一个进程的文件描述符4可能毫不相关。但是IPC的标识符ID是系统级的全局变量,只要知道该值且有相应的权限,任何进程都可以通过标识符进行进程间通信。
三种IPC对象操作的起点都是调用相应的ipcget函数来获取标识符ID的,IPC的get函数将key转换成相应的IPC标识符。根据IPC get函数中的第二个参数oflag的不同,会有不同的控制逻辑。:
// 共享内存 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函数有三个相同点
- 返回值都是一个整型的标识符ID
- 第一个参数都是键值
- 最后一个参数都是权限
另外,这三种System V IPC对象有很多共性,不仅仅get函数的调用相似,而且调用后描述IPC对象的结构体也相似(进程每次调用ipcget函数就会生成一个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对象的序列号 */ } ;
再回到ipcget函数,因为key可以产生IPC标识符,就是同一个key调用IPC的get函数总是返回同一个整形值。实际上并非如此。在IPC对象的生命周期中,key到标识符ID的映射是稳定不变的,即同一个key调用get函数,总是返回相同的标识符ID。但是一旦key对应的IPC对象被删除或系统重启后,则重新使用key创建的新的IPC对象分配的标识符很可能是不同的。
2.2.1 pidget系列函数介绍
第一个参数key值的选择
不同进程可通过同一个key获取标识符ID,进而操作同一个System V IPC对象。那么现在问题就演变成了如何选择key。
对于ipcget函数里的key值,有如下三种选择:
- 调用ftok,给它传递pathname和proj_id,操作系统根据两者合成key值。
- 指定key为IPC_PRIVATE(IPC_PRIVATE为宏定义,其值等于0),内核保证创建一个新的、唯一的IPC对象,IPC标识符与内存中的标识符不会冲突,从这个角度看将其称之为IPC_NEW或许更合理。不过,使用IPC_PRIVATE来得到IPC标识符会存在一个问题,即不相干的进程无法通过key值来得到同一个IPC标识符。因为IPC_PRIVATE总是创建一个新的IPC对象。因此IPC_PRIVATE一般用于父子进程,父进程调用fork之前创建IPC对象,创建子进程后,子进程也就继承了IPC标识符,从而父子进程可以通信。当然无亲缘关系的进程也可以使用IPC_PRIVATE,只是稍微麻烦了一点,IPC对象的创建者必须想办法将IPC标识符共享出来,让其他进程有办法获取到,从而通过IPC标识符进行通信。
- 指定key为大于0的常数,这需要用户自行保证生成的IPC key值不与系统中存在的冲突。要防止无意中选择了重复的key值,从而导致不需要通信的进程之间意外通信,以至发生程序混乱。而前两种是操作系统保证的。
PS:key键值被存储在struct ipc_perm类型的对象中
返回值IPC标识符ID
给semget、msgget、shmget传入key键值,它们返回的都是相应的IPC对象的标识符ID,创建失败返回-1。注意IPC键值和IPC标识符是两个概念,虽然都是唯一标识IPC对象的,但在用户层操作IPC对象我们使用的都是标识符ID。从ipcget函数调用上来说:标识符ID是建立在key键值之上生成的。
下图画出了从IPC键值生成IPC标识符图,其中key为IPC键值,由ftok函数生成;ipc_id为IPC标识符,由semget、msgget、shmget函数生成。ipc_id在信号量函数中称为semid,在消息队列函数中称为msgid,在共享内存函数中称为shmid,它们表示的是各自IPC对象标识符ID。
PS:既然key键值和标识符ID都是唯一标识IPC对象的,那么描述IPC对象的数据结构中只保存一个就够了(保存的是key键值),而标识符ID对外暴露给用户使用,进程只用拿到这个标识符ID就可以通信。
最后一个参数ipcflg
msgget、semget、shmget函数最右边的形参ipcflg(msgget中为msgflg、semget中为semflg、shmget中shmflg)为IPC对象创建权限,三种xxxget函数中ipcflg的作用基本相同。
IPC对象创建权限,格式为0xxxxx,其中0x表示8位制,低三位为用户、属组、其他的读、写、执行权限(执行位不使用)。既然执行位不用,只剩下读写权限,对应操作者的权限表示如下图所示:
此外,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创建模式标志 ,它们的组合作用如下表所示:
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
列出的第一行标志的意义:
补充: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:共享内存是最快的通信机制
一个进程向共享内存写入数据,共享这个区域的所有进程就可以立即看到其中的内容。我们可以拿共享内存和管道比较。
两个进程通过管道通信,数据要经过四次拷贝:
- 数据从起始文件拷贝到写端缓冲区
- 数据从写端缓冲区拷贝到管道文件中
- 数据从管道文件拷贝到读端缓冲区
- 数据从读端缓冲区拷贝到目标文件
如果两个进程通过共享内存通信,数据仅需拷贝两次。因为连个进程都有共享内存的首元素地址,整个通信过程就是起始文件把数据拷贝到共享内存,然后共享内存把这些数据拷贝到目标文件。
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操作。