目录
1. 进程间通信目的
进程间通信的本质是让不同进程看到同一份数据。
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
2. 管道
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
管道的本质就是OS中的管道的本质是内核中的缓冲区,通过内核缓冲区实现通信。
- 管道创建函数:
匿名管道只能父子进程间通信
创建无名管道:
参数fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端;用来读到打开的两个fd
返回值:成功返回0,失败返回错误代码
2.1 管道特性(匿名管道)
2.1.1 单向通信
管道是一个只能单向通信的通信信道
管道存在的原因:由于进程是独立的,那么想要实现进程间的通信成本就会比较大,所以首要解决的问题就是如何使两个进程看到同一份资源。
通过管道就可以实现:利用子进程继承父进程资源的特性,把管道继承下来,达到让不同的进程看到同一份资源的目的。
首先创建无名管道:
int pipefd[2];
if(pipe(pipefd) != 0){
perror("pipe failed!\n");
exit(1);
}
printf("pipefd[0] = %d\n",pipefd[0]);//0
printf("pipefd[1] = %d\n",pipefd[1]);//1
其中0是读端,1是写端;若要实现子进程写入,父进程读出,首先要关闭子进程的读出端,也就是pipefd[0]:
if(fork() == 0){
//child
//0是读端
close(pipefd[0]);
const char* msg = "aaaaaa\n";
while(1){
sleep(1);
write(pipefd[1],msg,strlen(msg));
}
exit(0);
}
再关闭父进程的写端pipefd[1]:
//parent
close(pipefd[1]);
while(1){
//sleep(1);
char buffer[64] = {0};
ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1);
if(s <= 0){
break;
}
else{
buffer[s] = 0;
printf("child said to father# %s",buffer);
}
}
上述代码形成的结果:
2.1.2 面向字节流
管道的传输是通过字节方式
上述代码运行结果:
可以看见,每隔一秒子进程写入,随后父进程读出,打印在屏幕上,也就是每隔一秒钟会打印一次。
但是如果不让子进程休眠,而让父进程每隔一秒读一次:
可以看见每隔一秒读出来的数据是很多行,这是因为一秒内子进程往缓冲区写入了这么多的数据,而没有识别到分隔符的话,能打印多少取决于子进程在这个过程中能打印多少字节,这便是面向字节流。
管道的读写有四种情况:
- 读端不读或者读的慢,写端要等读端;
- 读端关闭,写端收到SIGPIPE信号直接终止;
- 写段不写或者写的慢,读端要等写端;
- 写端关闭,读端读完pipe内部的数据然后在读,会读到0,表示读到文件结尾。
2.2 管道的大小
修改上述代码,每次子进程写入字符串a,父进程依旧死循环,但是不读:
int count = 0;
if(fork() == 0){
close(pipefd[0]);
const char* msg = "aaaaaa\n";
while(1){
write(pipefd[1],"a",1);
count++;
printf("count = %d\n",count);
}
exit(0);
}
//parent
close(pipefd[1]);
while(1){}
return 0;
}
可以看到输出结果是count = 65536后,不再增加:
- 结论
65536正好是64KB,这说明管道的大小正是664KB。
2.3 命名管道
创建命名管道函数mkfifo:
参数:第一个参数代表需要创建命名管道的文件的路径,第二个代表管道文件的权限。
返回值:返回值等于零创建成功,-1则创建失败。
命名管道可以实现两个进程之间的通信。
如果有一个进程创建了管道,那么另一个进程可以直接使用该管道来进行通信。
举例:实现进程间通信
- 客户端
- 服务端
- 头文件
可以看见在make以后,不仅产生了两个可执行文件,还产生了fifo文件,这个文件就是管道文件,由服务端的mkfifo函数调用生成,权限是自己设置的:
运行结果就是在客户端可以发送信息给服务端接收,本质就是通过管道完成的:
- 提示:
命名管道之所以叫命名管道,是因为进程间通信的方式是通过管道名,也就是说这个管道一定要有名字;而对于匿名管道,可以没有名字的原因是:它是通过父子间进程继承的方式看到同一份资源,并不需要通过管道文件名。
命名管道的文件名只是标识符,并非其真实的通信介质,只是用来让不同进程找到同一块缓冲区。所以磁盘空间大小并不决定通信内容的大小,而由缓冲区决定。
3. system V进程间通信
进程间通信有三个内容:共享内存、消息队列、信号量。这里解释共享内存。
上述方式都是基于文件的进程间通信,而system V进程间通信是基于OS层面专门给进程间通信设计的一种方案。可以说,同一主机间的进程间通信方案,就是system V方案。
其中有一个部分叫做共享内存;
共享内存区是最快的IPC形式。 一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
system V进程间通信原理
我们知道,进程地址空间是通过页表映射到物理地址上的,每个进程映射的空间不一样,就像在一把刻度尺上,有各自的刻度范围。
那么如果将两个进程的地址空间映射到同一个物理地址呢?不就可以看到同一份物理内存了吗?
这里的实现有两个过程:
- 通过某种调用,在内存中创建一份空间;
- 通过某种调用,让多个进程“挂接”到这份新开辟的内存空间上。
这就能让不同进程看到同一份资源。
在此之前,还有一些准备工作:
- OS内,可能存在多对进程,同时在使用这样的共享内存,OS如何管理?
OS通过数据结构,将其相关内存数据存放在里面,方便管理。
- 上述的“不同进程看到同一份进程”具体是如何保证的呢?
根据前面的知识可以知道的是,共享内存一定要有一定的标识唯一性的ID,方便让不同的进程能够识别同一个共享内存资源。这个ID存在哪里?就存在于刚才说的数据结构。
3.1 shmget函数
- 创建共享内存函数shmget
- 参数
- 第一个参数是标识符,作为“共同”的一个共享内存,需要有独特的、公有的标志来表示:
前面说过,不同进程通过某一块内存来进行通信,而不同进程看到这个 “某一块内存” 是通过某个ID来完成,这个ID就是key值。
按照前面说的,这个ID就在shm在内核中的数据结构中。
我们也可以自己设置,但是一般是采用ftok函数来获得:两个参数分别代表自定义路径名和自定义项目id,若设置失败则返回-1:
生成key值:
#include "commend.h"
#define PATH_NAME "./"
#define PROJ_ID 0x6666
int main()
{
key_t key = ftok(PATH_NAME,PROJ_ID);
if(key < 0){
perror("ftok");
return 1;
}
printf("%u\n",key);
return 0;
}
输出结果:
此时如果另一个进程想要与此进程进行通信,必须执行与之相同的代码生成相同的key值。
-
第二个参数是申请共享内存的大小,建议是4KB的整数倍(系统分配的单位)
-
第三个参数是标志位,如果单独使用IPC_CREAT,或者flag为0:不存在共享内存就会创建一个,如果创建的共享内存已存在,就会直接返回当前已存在的共享内存。也即是说,使用这个标志位不会空手而归。
对于IPC_EXCL,单独使用没有意义,但是一起使用的话,上述的规则就变成:不存在共享内存则创建之;如果已经有了,则返回出错。
其意义是:如果调用成功,那么这一定是个全新的、没人使用过的共享内存。
在刚才的代码基础上,申请共享内存:
#include "commend.h"
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATH_NAME "./"
#define PROJ_ID 0x6666
#define SIZE 4066
int main()
{
//申请key代码省略
//创建全新的id,如已存在则报错
int shmid = shmget(key,SIZE,IPC_CREAT|IPC_EXCL);
if(shmid < 0){
perror("shmget");
return 2;
}
printf("key:%u,shmid:%d\n",key,shmid);
return 0;
}
输出结果:
可以看到shmid是从0开始的,此时继续运行该可执行程序会显示shimd已存在。
ipcs指令是查看system资源的指令,默认查看三个内容:消息队列、共享内存、信号量:
执行ipcs -m以后,单独查看共享内存:
可以看见,执行完可执行文件以后(进程退出),此时的系统仍然存在共享内存,并没有被释放。
这说明system V的IPC资源,生命周期是随内核的,只能通过程序员的指令或者是OS重启来进行释放。 (删除指令ipcrm -m + shmid,不加shmid默认删除第一个)
这和文件不一样,文件关闭后所有相关资源都将被释放。
3.1.1 key VS shmid
key:只是用来在系统层面进行标识唯一性的,不能用来管理共享内存;
shmid:是OS给用户提供的id,用来在用户层进行共享内存管理。
- 删除共享内存为什么不用key而用shmid
刚才删除共享内存时,是在命令行操作的,命令行操作肯定是属于用户层,那么后面添加的id当然就是shmid而不是key了。
对于这两个概念,key可以类比为struct file,也就是fd的地址,具有唯一性;而shmid类比成fd,用来管理文件。
通过上述表达,知道了想要保证不同进程看到的是同一个共享内存,需要我们形成的key的算法和原始数据是一样的,就能形成同一个ID,达到目的。
这里的key同时也会被设置进入 内核中的关于共享内存的结构数据中。
3.2 shmctl函数
- 控制共享内存函数shmctl
对于参数cmd,表示将要采取的动作,有三个可取值:
-
IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值;也就是将ds结构中的共享内存相关数据输出;
-
IPC_SET :在进程有足够权限的前提下,把共享内存的当前关联值设置为shmid_ds数据结构中给出的值;也就是自己设置共享内存的属性;
-
IPC_RMID:删除共享内存段。
对于第三个参数,代表的就是控制共享内存的数据结构,是在用户层的;里面包含了key,每个进程能找到相同的key,就能找到对应的共享内存:
删除共享内存:
shmctl(shmid,IPC_RMID,NULL);
测试指令:
while :; do ipcs -m;sleep 1;echo "###############"; done
每次删除又启动,会发现shmid即使被删除了,但是每次都是递增的。也就是第一次创建是0,接着是1、2、3…其实这是数组下标
3.3 shmat函数 VS shmdt函数:
挂接函数(shmat)与去挂接(shmdt)函数
- 对于挂接函数来说
参数:shmaddr表示要挂接的共享内存的起始地址,shmflg代表对应标志位;(shmaddr为NULL,核心自动选择一个地址)
返回值:可以理解为挂接成功以后就相当于申请到了一块连续的地址空间,就像malloc一样。 (申请的都是虚拟空间)所以返回的指针变量用来存放共享内存起始地址,失败返回(void*)-1.
- 对于去挂接函数来说
参数:由shmat所返回的指针
返回值:成功返回0,失败返回-1;(可以类比malloc函数的返回值)
去挂接作用是让进程和共享内存去挂接,而不是清除共享内存。
获取数据的方式
在system V进程间通信中,双方进程是可以直接获取到共享内存区的数据的,因为此时的通信的空间就像malloc的空间一样,已经映射到了各自的进程地址空间里了,所以不需要像管道那样调用任何系统调用接口。
调用系统调用接口其实本质是从内核态拷贝到用户态,或者用户态拷贝到内核态,需要的时间比这种方式长。
所以这种通信方式是最快的一种。
//获取key值
key_t key = ftok(PATH_NAME,PROJ_ID);
if(key < 0){
perror("ftok");
return 1;
}
//获取共享内存
int shmid = shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666);
if(shmid < 0){
perror("shmget");
return 2;
}
//printf("key:%u,shmid:%d\n",key,shmid);
//sleep(10)
//挂接
char* mem = (char*)shmat(shmid,NULL,0);
//去挂接
shmdt(mem);
//控制共享内存(删除)
shmctl(shmid,IPC_RMID,NULL);
3.4 测试
在服务端申请并且挂接内存,然后往打印:
//如果client端不进行写入
//server端进行读取
//获取key值
key_t key = ftok(PATH_NAME,PROJ_ID);
if(key < 0){
perror("ftok");
return 1;
}
//获取共享内存
int shmid = shmget(key,SIZE,IPC_CREAT|IPC_EXCL|0666);
if(shmid < 0){
perror("shmget");
return 2;
}
//挂接
char* mem = (char*)shmat(shmid,NULL,0);
while(1){
printf("%s\n",mem);
sleep(2);
}
//去挂接
shmdt(mem);
//控制共享内存(删除)
shmctl(shmid,IPC_RMID,NULL);
运行结果:
可以看出,当client端没有写入的时候,server端依旧在读取,并不会等待client写入,只不过读取的是空白字符。
但是当client端进行写入:
key_t key = ftok(PATH_NAME,PROJ_ID);
int shmid = shmget(key,SIZE,0);
//挂接
char* mem = (char*)shmat(shmid,NULL,0);
while(1){
sleep(2);
strcpy(mem,"i am process A\n");
}
//去挂接
shmdt(mem);
shmctl(shmid,IPC_RMID,NULL);
可以看见server端可以接收到client端写来的消息。
虽然是进行通信,但是不使用read等系统接口,是如何做到将进程A的数据给到进程B并且进程B将其打印出来的?
本质原因:在这个过程中并没有像管道通信那样调用系统接口read或者write(这两个接口的本质是将数据从内核拷贝到用户,或者从用户拷贝到内核),所以,共享内存一旦建立好并映射进自己进程的地址空间,该进程就可以直接看到共享内存,就如malloc空间一般,不需要任何系统调用接口。
这里虽然使用了字符串拷贝函数,但是也可以直接通过下标操作修改地址mem对应的值。
由此,共享内存是所有进程空间通信中速度最快的。