目录
1 进程间通信
为什么进程间需要通信呢?这一点我们不难理解,在我们的操作系统中,数据是硬通货,一些需要协同的进程之间就需要数据在不同进程间的传输以及共享,来完成各自的工作以及业务内容。但是我们知道,进程是具有独立性的,那么我们如果想要完成进程之间进行通信,发生数据的交换,那么这么做的代价一定不小。
那么具体一点为什么要进行进程间通信呢?
进程间通信一般有以下目的
数据传输:一个进程需要将他的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源
通知事件:一个进程需要向另一个或一组进程发送消息,通知他们发生了某事(入子进程终止时要通知父进程)
进程控制:有些进程希望完全控制另一个进程的执行(比如我们使用的gdb等),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并且能够及时知道他的状态改变。
这里的重点在数据传输和进程控制上
那么怎么进行进程间通信呢?
这个问题其实操作系统的设计者们就已经想出了解决方案,并且制定了相应的标准,常见的标准有POSIX和System V ,前者让通信过程可以跨主机,而后者则聚焦于本地通信,也就是一个主机下的进程之间的通信。 System V标准下有共享内存、消息队列、信号量等解决方案,但是由于它适用于本地通信的,已经变成一套陈旧的标准了,如今用到的更多的还是POSIX,所以对于System V标准我们在这里只会讲一下共享内存的方案。
同时,除了上述两套标准,我们还有一个常见的通信方式就是管道,管道相信我们已经不是很陌生了,至少我们在前面已经使用过挺多次了,我们一般把他用于对数据的流水线加工,但是,这其实只是管道的一小部分功能,同时这个功能也是依托于管道的通信功能的。 管道是基于文件系统的通信方案,与上面的两套标准没有任何关系,同时管道是Unix(Linux就是类Unix操作系统)中最古老的通信方式,我们一般把一个进程连接到另一个进程的一个数据流称为一个管道。
我们上面说通信的成本一定不低,但是进程间通信又是我们不可避免的,我们该如何理解通信的本质?
首先,通信最基本的就是通信双方的进程将自己的数据交给对方,要怎么实现呢?首先,发送方进程需要将数据放在一个指定的地方,这个指定的地方必须是通信双方发都能看到都能访问的,将数据放好之后,接收方进程就可以在指定的地方将数据拿走,这样就完成了一次简单的通信,我们可以将这样一块存放数据的指定的地方也称之为一块缓冲区。那么这块缓冲区是谁提供的呢?发送方进程?肯定不是,因为进程间是独立的,这就已经限制了两个进程之间是看不到对方所有的数据的,就算发送方进程像操作系统申请了一块空间来访数据,接收方进程也无法访问到。 那么这个缓冲区就必须由非通信双方提供,也就是需要一个第三者,那么谁有这个能力呢?我们知道操作系统是总领全局的,操作系统能够管理所有的软硬件,其中就包括内存资源,于是就由操作系统来提供这份公共的资源,于是两个进程之间的通信就必须满足以下两个条件: 1 操作系统必须直接或者间接给通信双方的进程提供 “内存空间” 2 要通信的进程,必须看到一份公共的资源
不同的通信方式/种类本质的区别就是上面所说的公共资源是OS中的哪一个模块提供的,文件系统提供的方案之所以称为管道通信,就是因为通信所需的公共的资源是由OS的文件系统模块提供的,如果公共资源是由System V相对应的模块提供的话,就称之为System V标准提供的通信方式,这其中如果提供的是一大块内存,就是共享内存的方案,如果是计数器,就是信号量的方案,如果是队列,就是消息队列的方案。
总结一下,为什么说通信的成本不低呢?因为要做两件事, 1 首先要先让两个不同的进程看到同一份资源 2 然后才是开始通信
2 管道
2.1匿名管道
我们知道进程的PCB之中管理了一个 struct fikes_struct 的内和结构体,这个结构体中有一个东西叫做文件描述符表,也就是存放了进程所打开的文件的struct file 结构体的地址,每当打开一个文件中,都会将新打开的文件的struct file的地址填入到文件描述符表中。 而我们知道,当一个进程创建一个子进程,子进程会拷贝他的PCB、虚拟地址空间和页表等,struct files_struct是属于OS进程管理模块的,也就是他也是归进程管理模块管而不是交由文件系统模块,那么子进程是否要拷贝父进程的 struct files_struct 呢? 当然要拷贝,进程管理模块的东西都是需要拷贝给子进程的,这样一来,子进程也就能够映射到父进程所打开的文件了,这样一来是不是意味着父子进程能够看到同一份资源了?我们知道描述文件的内和结构体 struct file 中既包含了文件的所有属性,同时还有一块内存也就是内核缓冲区(struct Page{ }),这就意味着这块内核缓冲区能够被父子进程用来通信。这一份资源是由操作系统的文件系统提供的,有了同一份资源,就达成了通信的前提条件。
那么这时候如果父进程向文件中写数据,紫禁城则从文件中去读取数据,那么此时是不是就完成了将数据从一个进程传输到另一个进程,这也就是进程间通信。我们把这样使用文件的方式来完成进程间通信的方式称为管道,把操作系统给通信双方提供的这个内核级文件称之为管道文件或者管道,管道的本质上就是文件,只不过他是内存级的文件。
管道文件是专门用于进程间通信的,他于普通文件是有很大区别的。我们知道,普通文件从磁盘中加载到内存中之后,会创建struct file结构体,维护文件内核缓冲区,同时还会定时将缓冲区的内容刷新写到磁盘中。
而对于管道文件,进程间通信,意思就是只有两个进程之间传递数据,如果按照普通文件的做法,一个进程将数据从缓冲区刷新到磁盘,然后另一个进程再从磁盘中将文件读进来,这个方法虽然也能够完成基本的通信,但是有一个致命的的缺陷就是,和磁盘进行IO会使得效率很低,操作系统绝对不会容忍这种无意义的于磁盘进行IO,因为通信双方的数据根本不会存到管道文件中,就算要存,也是存在个紫禁城的读书文件中而不会去污染通信的管道。对于通信这个过程而言,是从一个进程到另一个进程,也就是从内存到内存,而不会涉及到磁盘,所有对于管道文件而言,根本就不需要将数据刷新到磁盘,也不会从磁盘中读取数据,因为这样会大大降低双方通信的速度。
既然不需要在磁盘中存储数据,那么是不是意味着,根本就不需要先在磁盘上创建一个管道文件,也就是不需要磁盘上真正存在这样一个文件,只需要在内存中有管道文件的 struct file 和他的内核缓冲区就行了,这样一来我们就不需要使用open去打开一个文件,可是即使只是内存级的文件,我们也是需要struct file中必须存在一些必要的属性比如读写方法和内核缓冲区的,操作系统能不能创建出这些属性呢呢?当然是能的。
所以说管道文件是一种内存级文件,不用关心这个文件在磁盘的是什么路径下以及要不要怕被写入到磁盘,他最重要的就是内核给他创建的读写方法以及内核缓冲区,然后能够将他的struct file的地址填入到通信双方的文件描述符表中,让通信双方能够看到。
struct file中有一个表示类型的字段,是一个联合体,他能够表明文件是普通文件还是管道文件,如果是管道文件,就不需要与磁盘进行IO。
那么如何让两个进程看到同一个管道文件呢?
最好理解的就是,父进程创建打开一个管道文件,然后再fork创建一个子进程,这样子进程就能够继承父进程的文件描述符表,也就能够与父进程看到同一个管道文件。对于父子进程而言,这个管道文件需要有名字或者路径吗? 完全不需要,因为子进程直接就能继承父进程的文件描述符表,根本就不需要名字或者路径来找同一个struct file , 这种没有名字的管道文件被称为匿名管道。
具体的过程如下:
父进程在创建管道式,是分别以读和写方式打开同一个文件的,内存及文件允许这样做,同时父进程会分别得到读方式和写方式打开管道文件返回的的文件描述符。意思就是,我们可以通过一个调用获得两个文件描述符,这里的调用我们马上就能看到,是一个系统调用接口。
然后父进程fork创建子进程
一般而言,管道只能用来单项数据通信,写端为入口,读端为出口,单向通信设计起来更加简单且更可靠,对于多向通信,管道不适合,如果实在有双向通信的需求,倒不如使用两个管道来进行通信。 为了保证单向通信,父子进程需要分别关闭各自所不需要的文件描述符, 数据发送方需要关闭读文件描述符,数据接收方需要关闭写文件描述符,也可以不关闭,但是不建议,避免误操作或者被别人使用影响通信,这样就构建出了一条能够进行单向通信的信道了。
从图中我们就能很好理解,为什么父进程要分别以读和写方式打开管道文件,而不是一次调用以读写方式打开,因为对于通信的任意一方而言,只需要读和写权限中的一种,剩下的文件描述符是需要关闭的,如果只有一个读写文件描述符,那么就不能满足单向通信的要求了。同时,分别以读和写方式打开,还能够是的我们能够在代码编写中来决定发送方与接收方。
上面的整个过程我们只是让两个进程看到了同一份资源,还没有开始通信,其实通信的过程更简单,就是一个进程往公共资源中写数据,另一个进程从公共资源中读数据。
同时我们也能够发现,匿名管道只能用于父子进程之间的通信
那么如何以读和写方式及打开一个管道文件呢?
系统调用pipe
我们可以看到 pipe要传的参数仅仅是一个两个整形的数组,这个数组是用作输出型参数的,也就是用来返回打开管道返回的两个文件描述符的, fd[0] 时读端的文件描述符, fd[1]是写端的文件描述符。
而pipe是否打开成功则要看pipe的返回值
如果打开成功,则会返回0 ,打开失败就会返回 -1。
这时候我们就能够实现基于匿名管道的父子进程之间的通信了。以下是一个简单的父子进程的通信代码。
#include<iostream> #include<unistd.h> #include<cassert> #include<sys/bitypes.h> #include<sys/wait.h> #include<cstdlib> #include<cstring> #include<cstdio> int main() { //1 父进程打开一个管道文件 int fd[2]; int ret=pipe(fd); assert(ret==0); (void)ret; //创建子进程 pid_t id=fork(); //假设子进程接收数据 if(id==0) { //接收方关闭写端 close(fd[1]); char buffer[1024]; while(1) { int s= read(fd[0],buffer,sizeof(buffer)-1); if(s==0) //表示读取结束 break; buffer[strlen(buffer)]='\0'; std::cout<<buffer<<std::endl;//打印接收到的信息 } std::cout<<"child process has exited"<<std::endl; close(fd[0]); exit(0); } //父进程发送数据 close(fd[0]); int cnt=0; char buffer[1024]; while(cnt<10) { cnt++; sprintf(buffer,"this is parent process ! count: %d",cnt);; write(fd[1],buffer,strlen(buffer)); sleep(1); } close(fd[1]); //回收子进程僵尸 pid_t retid=waitpid(id,NULL,0); assert(retid==id); (void)retid; return 0; }
从程序的运行结果上我们能大概了解一些管道的读取特征:
首先,我们上面的代码中,只控制了发送方也就是父进程写入数据的速度,而没有控制子进程读取数据的速度,但是运行的时候我们发现,子进程的读取速度也就是打印数据的速度是与父进程写入文件的速度相匹配的,那么,当父进程在休眠的时候,子进程在干什么呢? 答案子进程是在读取,只是阻塞在了read调用上,默认情况下,比如读取字符终端、网络socket描述字、管道文件等时,read是一种阻塞式调用,也就是如果读不到数据会一直阻塞读取,而不会退出或者返回,直到数据发送方将写文件描述符关闭,这时候 read 才算读到了文件结尾,会返回 0 ,如果发送方的写文件描述符一直不关闭,则 read 就会一直阻塞在读取数据。
那么反过来,如果父进程一直在写入,而子进程读取的速度很慢,这时候会出现什么情况呢?我们只需要稍稍修改一下上面的代码就能看到现象
我们让父进程死循环写入,但是子进程读取休眠时间很长,我们看一下父进程会不会一直写入下去
我们发现当父进程写入到 1800多次之后,就阻塞在了 write 上了,这不难理解,因为管道既然是文件,他的缓冲区的大小肯定是固定的,死循环写入肯定会有写满缓冲区的一颗,也就是写满管道,同时因为管道不具有于磁盘IO的属性,所以缓冲区满了就是满了。 又由于这些数据一直没有被读端读取,父进程就阻塞在了 write 上。 管道是一个先进先出的字节流,读端读取数据之后会消耗管道的数据,也就是读取完之后的数据就会从管道中删除,这样就会给写端空出空间来进行写入
同时,由于管道是面向字节流,那么read读取的时候要么读完数据,要么就会读满缓冲区再返回,所以当写入速度比读取速度快的时候,读取出来的数据可能不止是一条写入数据,而是多条或者一整串的数据。
再来一种情况,就是当父进程只发送一条信息,之后就关闭写文件描述符,这时候会怎么样?
这种情况就很好理解了,当写端未关闭时,子进程read不会读到文件结尾,会一直阻塞读取,但是当写端关闭之后,子进程的read就会读到文件结尾了,也就会返回0 ,然后根据我们之前写的循环跳出条件跳出循环。我们要知道,在通信中,写是主动的,而读是被动的。
最后一种情况就是读端关闭了,写端还能继续写吗?管道是单向通信的,当读端关闭时,写就没有意义了,再继续写入的话就是在浪费系统资源,这时候操作系统会终止写端的进程,也就是以信号的形式来终止进程,具体是哪个信号呢?我们可以让子进程来当写端,然后父进程读一次之后就关闭读端,最后回收子进程僵尸来看一下信号的序号。
13信号是用于终止读端关闭时继续写入的写端进程的,也就是非法写入管道。
通过以上的实验我们大概能够总结出管道的通信特征:
1 管道的生命周期十岁进程的生命周期的,因为管道是一种特殊的文件,进程退出了管道也就被释放了
2 父进程创建多个子进程,兄弟进程之间也是可以利用管道进行通信的。所以,管道不止能够用于父子进程之间通信,只要是有血缘关系的进程,都能够通过一定的方法使用管道通信,常用于父子进程之间通信。
3 管道是面向字节流的,不关心你的数据是什么类型或者什么块状数据,他只读取规定的字节数
4 管道是半双工的(任何一个时刻只允许一个进程向另一个进程发消息)--单向通信是半双工的一种特殊概念。使用互斥与同步机制保护共享资源
到了现在,我们如何理解这样的命令行代码?
cat file.txt | grep "abc"
sleep 10000 | sleep 200
其实竖线两边就是两个子进程,当命令行解释器拿到这样的命令时,首先就会把 | 两边的命令提取出来,创建匿名管道,然后分别创建子进程来执行 | 两边的操作,兄弟进程之间通过管道来进行数据的传递,这其中会对两个进程进行输入或者输出的重定向,重定向到管道文件。
我们上面讲的都是匿名管道进行数据的传递,那么如何使用管道进行进程的控制呢?
按照我们上面的理解,父进程是可以创建多个管道,创建多个子进程,每一个管道都通过对应的管道进行向子进程发送数据的,而子进程则需要根据父进程发来的数据也就是任务码来决定要做什么任务,这就是一个简单的使用管道进行进程控制。在具体的实现中,我们需要所有子进程都阻塞在read中,等待父进程向其发送命令然后执行对应的任务,比如我们规定,父进程向子进程发送4字节也就是一个整形大小的数据来表示任务码,也就是子进程要执行的操作。而对应的子进程需要需要按照四字节为单位进行读取,读到任务码之后,执行相应的操作。如果父进程不想子进程发送消息,那么子进程是阻塞在read的。
要完成这样的设计,首先我们需要创建一批子进程和同样数量的管道,并建立好管道和子进程之间的关系。
1 准备工作:
#include<iostream> #include<cassert> #include<cstring> #include<sys/types.h> #include<sys/wait.h> #include<cstdlib> #include<vector> #include<string> #include<unistd.h> #include<time.h> #include<cstdio> #define NUM 5 //要创建的子进程数 typedef void(*Task)(); //要执行的任务的函数指针 //任务列表 void task1() { std::cout<<"task1"<<std::endl; } void task2() { std::cout<<"task2"<<std::endl; } void task3() { std::cout<<"task3"<<std::endl; } void task4() { std::cout<<"task4"<<std::endl; } void task5() { std::cout<<"task5"<<std::endl; }
2 装配任务:
void loadtask(std::vector<Task>&tasklist) //准备任务 { tasklist.resize(5); tasklist[0]=task1; tasklist[1]=task2; tasklist[2]=task3; tasklist[3]=task4; tasklist[4]=task5; }
3 创建子进程以及管道,控制子进程逻辑
void CreatChildProc(std::vector<int>& fddate,std::vector<pid_t>& childpid, std::vector<Task>const & tasklist)//创建子进程并连接管道,控制子进程逻辑 { int i=0; while(i<NUM) { int fd[2]; int ret=pipe(fd); //创建管道 assert(ret==0); (void)ret; //创建子进程 pid_t id=fork(); assert(id>=0); if(id==0)//子进程逻辑 { while(1) { close(fd[1]); int commandcode=0; int read_ret=read(fd[0],&commandcode,sizeof commandcode ); if(read_ret==0) break; assert(read_ret==4); std::cout <<"child is no."<<i+1 <<" the command code :"<<commandcode<<std::endl;//打印一行提示信息 tasklist[commandcode-1]();//执行派发的任务 } exit(0); } //父进程 ++i; //关闭读端,保存写端 ,保存子进程id close(fd[0]); childpid.push_back(id); fddate.push_back(fd[1]);//这里的子进程 id 和 对应子进程的管道写入描述符是对应的,下标相同 } }
4 发送控制码
void sendCommand(std::vector<pid_t>const& fddate,std::vector<Task>const& tasklist,size_t count) //发送命令码 { while(count--) { //使用随机数来分配任务,使得每个子进程被分配的几率都是一样的,负载均衡 int commandCode=random()%tasklist.size()+1; //对应任务的序号(序号) int pipeindex=random()%fddate.size(); //要执行的子进程序号(下标) int wrret = write(fddate[pipeindex],&commandCode,sizeof commandCode); assert(wrret==4); (void)wrret; sleep(1); } }
5 关闭写端回收子进程
void WaitChildProc(std::vector<pid_t>& childpid,std::vector<int>&fddate)//回收子进程 { for(auto i=0;fddate.size();i++) { close(fddate[i]); pid_t id=waitpid(childpid[i],NULL,0); assert(id==childpid[i]); std::cout<<"回收"<<i<<"号进程"<<id<<"成功"; } }
main函数
void makeRandomSend()//随机数种子 { srand((unsigned int)time(NULL)); } int main() { makeRandomSend(); std::vector<Task> tasklist; loadtask(tasklist); //装载任务列表 //创建子进程并且连接好管道 std::vector<pid_t> childpid; std::vector<int> fddate; CreatChildProc(fddate,childpid,tasklist); sendCommand(fddate,tasklist,10); WaitChildProc(childpid,fddate); }
这段代码实现了控制进程的逻辑,大概逻辑就是:父进程可以通过随机数来生成要发送的任务码以及要执行该任务的子进程的序号,然后发送给子进程,子进程read到任务码之后就执行相应的任务。同时我们设置了要执行的任务总数,父进程发送了一定次数的任务码之后就会不再发送任务,最后父进程逐个关闭写端,并回收子进程。
从总体的逻辑来看我们上面的代码是没有bug的,但是其实我们忽略了一件事,可以通过运行结果来看出来我们的代码中哪里出了问题
问题就出在了最后回收子进程的时候卡死了,与其说是卡死了,不如说是阻塞在了 waitpid ,而没有执行后面的代码。
为什么会阻塞在 waitpid 呢?
这就要回到我们创建子进程的代码了
我们是否忽略了一件事,就是父进程在不断创建子进程的时候,我们父进程的文件描述符表中是不断的在增加新的写端文件描述符的,也就是说,后创建的子进程也会继承前面创建的子进程的管道的写端描述符
以图来理解就是
下图是第一次创建子进程之后的管道图
此时还是正常的,但是父进程第二次创建管道的时候, 对与父进程而言,3 还是管道的读端, 5是管道的写端,4则是一号子进程对应的管道的写端,但是这时候再创建子进程,子进程会将 1 号子进程的写端也继承,但是我们的代码逻辑中却没有做这方面的处理,于是创建完第二个子进程之后就变成了下图这样的情况。
依次类推,这就导致了后面的子进程都持有前面子进程的管道的写端,这会导致什么问题呢?
注意看我们的回收子进程的逻辑,我们第一次只是将父进程的关于一号管道写端关闭了,但是其他的子进程还有一号管道的写端,所以一号子进程还是阻塞在 read ,而不会读到文件结尾再结束,所以导致我们的 一号子进程根本就没有退出,但是我们又采用的是阻塞式的等待,所以就会导致我们阻塞在waitpid上动不了。
解决方案呢有两种,一种是从直接原因上解决,就是我们采用非阻塞式的等待来回收子进程,这样一来我们就需要一个变量来保存目前正在回收的子进程对应的vector的下标,在关闭所有的写端描述符之后,我们还需要对剩下的子进程进行回收。 或者我们直接关闭管道和回收子进程僵尸分开写,写成两个循环而不是一个循环,这也是能够解决问题的
第二种就是从根本原因上解决问题,我们无非就是后续的子进程没有关闭前面的管道的写端描述符,既然这样,我们创建完子进程,子进程首先就关闭不属于自己的文件描述符不就好了?这样也不难做到,我们只需要一个vector来保存属于前面子进程管道的写段描述符就好了,然后关闭这个vector里面的所有描述符。
修改后的代码
void CreatChildProc(std::vector<int>& fddate,std::vector<pid_t>& childpid, std::vector<Task>const & tasklist)//创建子进程并连接管道,控制子进程逻辑 { int i=0; while(i<NUM) { int fd[2]; int ret=pipe(fd); //创建管道 assert(ret==0); (void)ret; //创建子进程 pid_t id=fork(); assert(id>=0); if(id==0)//子进程逻辑 { while(1) { close(fd[1]); int commandcode=0; int read_ret=read(fd[0],&commandcode,sizeof commandcode ); if(read_ret==0) break; assert(read_ret==4); std::cout <<"child is no."<<i+1 <<" the command code :"<<commandcode<<std::endl;//打印一行提示信息 tasklist[commandcode-1]();//执行派发的任务 } exit(0); } //父进程 ++i; //关闭读端,保存写端 ,保存子进程id close(fd[0]); childpid.push_back(id); fddate.push_back(fd[1]);//这里的子进程 id 和 对应子进程的管道写入描述符是对应的,下标相同 } }
从根本上解决问题之后我们就能正常回收子进程了。
这就是一个简单的利用匿名管道进行进程控制的逻辑,我们也可以称之为基于匿名管道的进程池设计。
2.2 命名管道
匿名管道只能用于有亲缘关系的进程之间的通信,那么两个没有血缘关系的进程就不能该怎么办呢?匿名管道通信双方的两个进程是通过继承的方式来看到同一份资源的,但是两个不相干的进程之间是没办法继承的,那么有没有其他办法?命名管道。
既然匿名的管道方案不可行,那么我们就在磁盘上创建一个管道文件不就行了吗?这样一来,这个管道文件就有了自己的路径,那么通过路径加文件名的方式,我们就能够让两个进程都找到相同的文件,也就能够有相同的资源了。要让两个进程都能勾兑文件进行读或者写,那么这个管道文件就必须创建在一个公共的目录下,也就是必须要让两个进程都有权限能够找到并且操作文件。
首先我们要了解一下创建命名管道的方法,在命令行中我们可以使用mkfifo来创建命名管道,mkfifo创建命名管道的时候,如果文件已经存在,mkfifo会报错,而不是更新文件。
管道文件的属性是以 p 开头的,说明他的文件类型是管道文件。同时管道文件是有自己的inode的,说明他在磁盘上是占据的空间的。
管道的特点就是单向通信,一个进程进行写入,另一个进程进行读取。
那么如果我们单纯向文件中写入数据会怎么样呢?会不会真的保存在管道文件中呢?
我们发现如果我们向管道文件中写入的时候,会阻塞在写入的命令上。
这是因为没有文件以读方式打开管道文件,管道通信并没有建立起来,所以就一直阻塞在echo了。
同时由于管道文件是不会于磁盘进行IO的,所以不管如何写入,管道文件的大小都是 0 ,虽然这个文件确实会存在磁盘的某一个目录下,这只是作为他的唯一标识以便通信双方的进程能够找到罢了。数据不管怎么写入,都只会写入到缓冲区,而不会写到磁盘上
那么如何使用命名管道进行通信呢?
首先我们最好要有一个公共的目录,以便我们能够在程序运行起来之后在公共目录下创建命名管道,确保两个进程都能有该目录的权限。
然后在具体的程序的实现中,首先两个程序都需要知道文件的路径以及文件名,这样一来通信的提供者也就是sever 就能够知道该在哪个路径下创建一个什么名字的管道文件,而通信的使用者也能知道它需要打开哪个路径下的哪个文件。
在程序中创建命名管道也是使用mkfifo,mkfifo也是一个C语言中的函数,底层是系统调用mknod,可以用于创建命名管道。
创建成功返回0.创建失败返回-1。
既然这样,我们的sever端的代码就好写了,只需要使用mkfifo创建一个命名管道,然后发送或者接受数据就行了,如果是要发送数据,open就以写的方式打开,如果要接收数据,就以读的方式打开。
sever:相比于client,sever作为通信服务的提供方,需要多一个工作就是创建管道文件,同时在通信服务结束后,要删除管道文件,避免浪费系统资源。删除文件我们使用系统调用unlink
#include<iostream> #include<cstdio> #include<unistd.h> #include<sys/types.h> #include<sys/stat.h> #include<cstring> #include<cstdlib> #include<errno.h> #include<fcntl.h> #include<cassert> #define PATHNAME "/home/ldh/tmp/mypipe" int main() { //创建一个命名管道文件 umask(0000); int ret=mkfifo(PATHNAME,0777); if(ret==-1) { std::cout<<strerror(errno)<<std::endl; exit(-1); } (void)ret; //假设sever用于接收数据 int fd=open(PATHNAME,O_RDONLY); assert(fd!=-1); char buffer[1024]; while(1) { int rd_ret=read(fd,buffer,sizeof buffer -1); if(rd_ret==0) break; buffer[strlen(buffer)]='\0'; std::cout<<buffer<<std::endl; } close(fd); sleep(1); //休眠一下确保两个进程都关闭文件 unlink(PATHNAME); return 0; }
client:
#include<iostream> #include<cstdio> #include<cstdlib> #include<sys/types.h> #include<fcntl.h> #include<cassert> #include<unistd.h> #include<cstring> #define PATHNAME "/home/ldh/tmp/mypipe" int main() { int fd=open(PATHNAME,O_WRONLY); assert(fd!=-1); int cnt=0; const char *msg="hello I am client !"; char buffer[1024]; while(cnt<10) { cnt++; sprintf(buffer,"%s NOW:%d",msg,cnt); write(fd,buffer,strlen(buffer)); sleep(1); } close(fd); return 0; }
当我们只运行sever的时候,我们发现sever是阻塞的,但是具体阻塞在了哪里呢?
我们发现,sever运行起来之后,创建了管道文件,然后就阻塞了,他具体是阻塞在打开文件呢?还是阻塞在read呢?我们可以在代码中打印几行提示信息来
这时候我们就能很直观的发现sever是阻塞在open了。
如何让他继续运行呢?当然是 client以写的方式也打开管道文件,也就是运行client来于sever共同打开这个管道来进行通信。
当client运行起来,执行打开文件的操作,sever也能够成功打开文件了。
然后就能按照上面的代码的逻辑完成单向通信了。
同时程序运行完之后也能够把命名管道文件删除,以便下次运行的时候还能够按照代码逻辑创建命名管道文件。
在这之后,我们也可以使用命名管道来进行进程的控制,也就是参考上面的匿名管道的逻辑写一个控制其他进程的代码。其实思路大体是一样的,无非就是原本是控制子进程,而现在则是控制其他无亲缘关系的进程,或者也可以继续控制子进程。如果继续选择控制子进程,则只需要修改一小部分代码就能完成控制的逻辑。
3 共享内存
共享内存的原理,我们大概是能够联想到一些的。每一个进程都有它的虚拟地址空间以及页表,通过页表映射到物理内存,那么,我们能不能够让多个进程把同一块物理内存空间映射到虚拟地址空间中呢?这样一来,我们的程序对虚拟内存进行操作,通过页表映射到物理内存中,共享这一块内存的其他进程也就能看到我们对这块内存的操作。但是,我们知道,进程之间是独立的,而我们申请内存空间的工作也势必要有其中一个进程来完成,那么如何让其他进程也知道这块由一个独立的进程申请的这块内存呢?这不是破坏了进程的独立性了吗?并不会,因为两个进程都有自己的虚拟地址空间,pcb,页表等,他们彼此间的数据代码等都还是独立的,只不过我们通过将同一块物理内存映射进不同的进程的虚拟地址空间中了,这块物理内存是被多个进程共享了,但是在进程自己看来,这块虚拟地址空间是他所独有的。
那么要实现这样的通信方式,操作系统就必须提供接口让我们的进程能够申请到一块能够进行多进程共享的物理空间,申请完之后,还需要提供接口用于物理空间映射到各个进程的虚拟地址空间,到这里不同进程才能够看到这同一份资源。同时,当最后进程之间不再通信了,我们也需要提供接口能够将这块物理内存到这些进程的虚拟地址空间的映射关系接触,最后还需要能够释放这块内存空间。这就是使用共享内存进行通信的基本的过程。我们把这块申请到的内存成为共享内存,把进程和共享内存建立映射关系称为进程与共享内存挂接,把取消进程与共享内存的映射关系称为去关联。
那么C语言的malloc能够用来申请这块共享内存吗? 不能,虽然malloc能够申请空间有以及将其映射进进程的虚拟地址空间中,但是他是在进程的私有的堆上申请的,堆区的资源是进程所私有的的,而共享内存是需要映射到进程地址空间的共享区的,就如同动态库一样,这块空间是被多个进程所共享的。
讲到这里,我们对共享内存也应该有了一定的认识,共享内存是专门设计用来进行进程间通信的,它能够被不同进程共享。 同时共享内存是一种通信的方式,那意思就是所有相同新的进程都可以使用这套方案,那么我们使用这种方式进行通信时也就不能影响到其他的进程。同时,我们也能想象到,操作系统中一定会同时存在很多的共享内存,那么这些共享内存也一定要通过一些数据结构进行先描述再组织的过程以便于操作系统对资源的管理。
那么具体如何使用共享内存进行通信呢?
首先共享内存(shared memory)是通过让不同的进程看到同一个内存块从而进行通信的方式,那么我们首先就需要能够申请出一块共享内存,既然提供了这种方式给我们,那么操作系统中就一定有对应的系统调用。
shmget
用于申请一块共享内存
他的三个参数,第二个参数就是要申请的内存块的大小。但是第一个参数 key 和第三个参数shmflg是什么呢? shmflg就是标志位或者说是选项,谈到标志位,我们能否想到打开文件的系统接口open的flag,他也是需要穿标记位的,他们的标记为都是一个二进制整数,被定义成了宏,同时标记位的传递也是通过位图的形式来传递的。
那么申请内存的标记位或者说选项有哪些呢?
我们常用的就三个,IPC_CREAT,IPC_EXCL,以及这块内存的权限,这个权限就类似于我们的文件的权限,都是有读写和执行的权限,同样也是用一个八进制数字来设置。
IPC_CREAT很好理解,如果你想要创建的共享内存不存在,就创建出来,如果存在,就直接获取这块共享内存,返回给调用它的进程。这也很好理解,进程间要通过共享内存来通信,那么就必须由一个进程来创建申请这块内存,而其他的进程只需要获取这块内存就行了。
IPC_EXCL,这个选项无法单独使用,它必须要结合IPC_CREAT来使用,结合在一起使用时,如果要创建的内存已经存在,就会报错返回,而如果要创建的内存不存在,那就正常创建。 加上IPC_EXCL选项就能够确保我们的共享内存一定是自己新创建的,而不是获取别人已经创建的共享内存。
当我们传的shmflg位0的时候,它的语义就是不存在就创建,存在就获取,他就是shmget的默认行为。我们可以理解为shmget里面有很多的if和else if来针对不同的选项做出不同的操作,而0就是所有选项都没有匹配的最后的 else 。
shmget的返回值:
如果调用成功,就返回这块内存的标识符,如果调用失败就返回-1,同时设置错误码。其实这个返回的标识符本质上也是一个数组的下标,但是他的文件的标识符是完全不同的了大小,是操作系统中的不同的体系了,没有任何的联系。我们要对这块共享内存进行操作也都是通过这个返回的标识符来进行。
那么最后我们还有一个参数 key 没有讲。
使用共享内存的时候我们有一个问题,就是既然操作系统中同时有很多进程在使用共享内存进行通信,那么我们要如何确保通信双方拿到的共享内存是同一块呢?这个问题我们使用管道时是通过继承或者路径来唯一标识我们的管道文件的,那么共享内存要如何标识它的唯一性呢? 所以这就我们传的key值的作用,key值的数值是多少不重要,重要的就是他要能够唯一标识一快共享内存。那么如何得到一个不容易与其他的key重复的具有唯一性的key呢?我们可以用一个函数来生成,这个函数就是 ftok
使用这个函数生成一个唯一的key,我们需要传一个有效的路径名(不一定是文件所在路径)以及一个项目的标识名称(由我们自己决定),还是那句话,这个生成的key值是多少不重要,重要的它所具有的唯一性,同时这个key也能用于system V标准的其他资源的申请,比如消息队列和信号量资源,也适用于标识唯一性的。
虽然我们能够用ftok来生成一个唯一的key,但是如何确保通信双方的进程调用这个函数所得到的key是相同的呢? 只需要他们调用这个函数传递的参数是一样的,那么通过相同的算法,最终的到的key自然也是一样的。
这样一来我们就能够让通信双方的进程找到同一块共享内存的,那么具体的实现是什么呢?
我们目前能够写出如下接口
#include<iostream> #include<cstring> #include<cerrno> #include<sys/types.h> #include<unistd.h> #include<sys/ipc.h> #include<sys/shm.h> #include<cstdlib> #define PATH "." #define PROJ 0x12121212 key_t Getkey() //获取key的接口 { key_t key=ftok(PATH,PROJ); if(key==-1) { std::cout<<strerror(errno)<<std::endl; exit(0); } return key; } int Getshm(key_t key,size_t size,int shmflg) //最终申请内存的接口 { int shmid=shmget(key,size,shmflg); if(shmid==-1) { std::cout<<strerror(errno)<<std::endl; exit(1); } return shmid; } int Severgetshm(key_t key) //创建内存的进程调用的接口 { //申请创建内存 int shmid=Getshm(key,4096,IPC_CREAT|IPC_EXCL); return shmid; } int Clientgetshm(key_t key) //获取内存的进程调用的接口 { int shmid=Getshm(key,4096,0); return shmid; }
我们可以先验证一下通信双方进程得到的key和shmid是否相同。
我们发现,key值确实都相同,shmid也相同,但是为什么返回的shmid是0呢?shmid为0有问题吗?不用担心,没有问题,只有返回值为-1时才是申请失败,而且在我们写的函数中返回值为-1就会退出进程而不会执行后面的操作。
这时候,我们再执行一次 client 和 sever 进程,
发现了一个问题,就是client还是可以获取资源,但是 sever 无法申请指定key标识的共享内存了,这是为什么?相比我们也很清楚,我们在程序中并没有释放申请下来的共享内存。而我们第二次运行sever无法创建共享内存,报错是共享内存已存在,这就说明了,这种system V标准的ipc通信资源,他们的生命周期不会随着进程退出而结束,他们的生命周期是随操作系统的,如果你不主动去释放或者关机重启操作系统,那么这份资源会一直存在,一直被申请他的进程占用,就算该进程退出,别的进程也还能够获取到这份共享内存。
那么我们怎么释放呢?在命令行中,我们可以通过 ipcs -m 命令来查看我们所申请的共享内存资源
如果是查看消息队列资源就使用 -q选项,信号量就使用 -s 选项。
这时候我们能够看到我们所申请的没有释放的资源,那么如何释放这些资源呢?
在命令行使用 ipcrm -m shmid,这样就能释放指定id的共享内存
同理,如果我们想要释放消息队列资源或则信号量资源,我们就可以使用ipcrm -q和-s选项来进行释放。
虽然我们已经能够申请共享内存了,但是我们还是不能理解,这个 key 和 shmid 有什么关联吗?我们对共享内存进行操作都是用的 shmid ,同时在命令行对其进行释放也是用的 shmid ,那么为什么不直接使用 shmid 作为唯一的标识呢?这个 key 到底有什么意义呢?
我们一个一个来解答这些疑惑,以前我们使用过C语言的malloc和free接口,难道就不疑惑,为什么我们使用malloc申请空间的时候需要指定大小,但是free释放的时候确实需要传空间的起始地址呢?free是怎么知道我们要释放的空间的大小的? 其实我们在申请对空间的时候,并不是说你需要多少个字节,他就只申请这么多,其实他会多申请一些空间,用来维护这块对空间,多申请的空间中存储了你使用的堆空间的权限,大小等属性,所以当你传一个起始地址给free的时候,free首先会根据这个地址去找到多申请的保存属性的那块空间,然后根据空间的属性来进行释放,我们把多申请出来的这块空间的数据叫做cookie数据。同时我们也学过进程的pcb,操作系统为了管理进程,回味每一个进程创建pcb,通过管理pcb来管理进程。
从上面的逻辑来看,我们的共享内存也是需要被操作系统管理起来的,而操作系统的管理又需要先组织再描述的过程。所以我们在申请共享内存的时候,在得到这块内存的同时,操作系统也会为这块内存创建内核数据结构对象来哦维护和管理这块共享内存,保存共享内存的i相关属性,同时我们对共享内存的各种操作其实也需要通过这个数据结构来进行。
我们说key是多少不重要,重要的是必须是唯一的。创建内存的时候,如何保证共享内存在系统中是唯一的?这是必须要进行标识的,其实就是用这个 key 来标识,我们使用shmget函数传过去的key,就是作为了这块共享内存的标识,那么另一个进程如果拿着同一个key来获取共享内存,自然也就能找到同一块内存。
那么key在哪呢?虽然我们现在还不知道具体的实现,但是我们能够肯定的就是,key值一定是被保存在了这块共享内存的内核数据结构中,我们假设这块共享内存由一个struct shm{ }结构体来维护,那么他里面一定有一个字段保存了key的值,另一个进程来获取这一块共享内存时,也不是真的去对照共享内存一块一块找,而是拿着key值在这些数据结构对象中查找对应的key字段看是否相等。
key值是要通过 shmget 函数在申请共享内存的时候就设置进共享内存的属性中的,用来标识共享内存的唯一性。
那么shmid 又和刚刚的key有什么关联呢? shmid 和 key 其实就像我们以前学过的文件的 fd 和文件的inode之间的的关系 ,文件在一个分区内的唯一标识就是inode,但是我们实际操作文件时却不是使用inode,而是使用文件描述符,通过文件描述符在文件描述符表中找到对应的文件的struct file结构体来进行操作。而我们的 shmid 其实也是进程管理的system V 资源的数组的下标,我们在上层使用的都是 shmid ,而在底层操作系统自然会拿着这个结构体中的key值去找对应的内存进行擦作。
那么操作系统为什么要这么做呢?为什么不使用 inode 而要使用 fd,为什么不使用key而非要搞出一个shmid来呢? 用两套机制来标识,一套用于用户层给用户系统接口来操作,一套用于操作系统的底层进行实际操作,这其实是一种解耦,两套机制的话,就算什么时候操作系统的层面进行调整了,比如说这些标识的名字变了,那么在源码中就只需要修改一下 inode 和shmid 的底层的操作,而不会影响用户的使用发生变化。
上面我们了解了在程序中申请共享内存资源,以及如何在命令行中进行资源的释放,但是在命令行中进行释放终究不是权衡之计,我们需要一个接口能够在程序中进行释放共享内存,毕竟由谁申请由谁释放这样的逻辑才更恰当。
操作系统并没有提供专门用来释放 ipc 资源的接口,而是换了另一种说法,叫控制/操作 ipc 资源 释放资源其实就是控制的一种。
shmctl
这个接口有三个参数,第一个是就是我们要操作的共享内存的shmid ,而第二个参数 cmd 则是表示你要进行的操作种类,我们常用的就是 IPC_STAT和IPC_SET以及IPC_RMID ,IPC_STAT是用来获取共享内存的属性的,而IPC_SET则是用于设置共享内存的属性,如何获取和设置呢?这就要看我们的第三个参数,他是一个 struct shmid_ds 的结构体对象的指针,如果是要获取共享内存的属性,他么他就会作为输出型参数,将共享内存的属性带回来,而如果是要设置共享内存的属性,那么我们就需要有一个struct shmid_ds 的对象,然后传递他的指针用来设置。这个结构体中的属性我们在接下来再讲。而 IPC_RMID 自然就是用于删除共享内存的,这时候如果我们不需要获取属性等操作,那么第三个参数可以直接传空指针。 函数如果调用成功,如果是获取状态或者设置状态那么就返回进行操作的共享内存的标识符也就是shmid,如果是进行资源释放就返回0,如果调用失败,就返回-1.
那么现在我们又可以晚餐共享内存的释放的代码了。
void Rmbyshmid(int shmid)//由sever释放ipc资源 { int ret= shmctl(shmid,IPC_RMID,nullptr); if(ret==-1) { std::cout<<strerror(errno)<<std::endl; } }
这时候就不需要我们在命令行去手动释放资源了。
目前我们已经能够申请和释放共享内存,但是我们却还有两个最重要的步骤没有完成,就是挂接和去关联,挂接也就是将共享内存映射进我们的进程地址空间,而去关联就是取消这种映射关系。我们目前只是说申请到了这块空间,但是还不能够进行写如何读取操作,因为还没有映射到我们的虚拟地址空间中,无法通过页表找到物理内存。
那么首先我们需要一个挂接的的函数
shmat
at就是attach的缩写,也就是挂接,链接的意思,
该函数需要传三个参数,返回值是一个void*的地址。shmid参数我们了解,后两个参数是什么呢?
shmaddr这个参数则是我们想要将共享内存映射到那个虚拟地址上,不过我们一般是直接传nullptr,让内核去找一个合适的位置来进行映射,因为我们不是这么了解底层,不清楚具体的地址的布局是什么样的,倒不如直接让内核来找一块合适的空间。
第三个参数则是挂接的选项
我们在该函数的描述中可以看到诸如SHM_RND,SHM_REMAP,SHM_RDONLY等选项,我们似乎只能够大致认识其中的 RDONLY这个选项, 跟open的选项类似,肯定就是读权限的设置。不过我们也不需要担心上面,只需要将选项参数设置为 0 ,默认就是可以都可以写。
最后就是shmat的返回值
如果调用成功,那么返回的就是映射到的虚拟地址空间中的起始地址。而如果调用失败,就返回 -1 ,但是这个-1是一个void*类型的,我们要怎么判断呢?
void* Shmattach(int shmid) //挂接 { void*ret= shmat(shmid,nullptr,0); if(ret ==(void*)-1) { std::cout<<strerror(errno)<<std::endl; exit(2); } return ret; }
但是当我们去访问这个返回的地址时,却发现报错了,没有权限,这是为什么呢?在挂接的时候明明传了0,默认不就是可读可写的吗?其实不是因为这里的权限设置,而是我们在申请这块共享内存的时候,没有为共享内存设置初始权限,所以默认就是不可读写的,我们可以看一下struct shm_ds中的一个字段,struct ipc_perm
其中就有关于权限的属性,那么如何设置呢?当然要在申请内存的时候就通过选项来设置,我们只需要在原来的选项上在 | mode(自己决定权限,需要八进制数字) 。
同时由于我们上一次运行程序权限被拒绝导致进程退出了,我们需要在命令来进行资源释放
挂接完成之后,我们就差最后一个函数,去关联。 虽然我们可以在不去关联的情况下直接进行资源的释放,但是这样过于粗暴,最好还是完成去关联这一步,去关联的作用就是修改页表,将共享内存从我们的页表中删除,回收虚拟空间 。但是他不会去释放物理内存中的共享内存,只是将映射关系切断了,所以我们不要以为有了这个接口就不需要在写释放资源的接口了。
去关联的接口和返回值都很简单
shmdt
跟free一样,我们只需要传要去关联的共享内存的起始地址就行了,也就是我们使用shmat得到的那个地址。
同时它的返回值也很简单,成功就返回0,失败就返回-1
我们直接用下面这段最简单的通信的代码来做一下测试
这段代码中,写端每隔一秒写一次,写完十次之后就退出并且释放共享内存,而读端也是隔一秒就读取一次,但是没有设定多少秒之后就停止,我们来看一下结果
我们发现了两个问题,一个就是写端只写了十次,但是读端却一直在读取打印,第二个就是,我们的sever明明已经正常退出了,也就是说共享内存已经被释放了,为什么读端还能读取?
对于第一个问题,其实很简单,共享内存不像其他的通信方式,比如管道,必须要写端写入了东西才能读取,他只会沉浸在自己的读取自己虚拟地址空间中的那块内存的数据,并不会关心写端在干什么 。同时对于写端而言,他也只需要将数据写到指定的虚拟地址,然后通过页表映射到共享内存中,他也无法得知读端的行为。
而第二个问题也很简单,虽然我们的sever进程确实调用了 shmctl 进行释放共享内存,他的返回值也确实是 0 ,但是由于这块共享内存还有进程client正在使用(还被进程所挂接),这时候操作系统并不会立马释放共享内存,而是在挂接他的进程数为0时再通知操作系统进行释放,我们可以看一下当sever退出,client还在继续读取共享内存数据时,我们的共享内存资源到底有没有被释放
当我们的 client 被终止之后,资源才被释放。
同时这时候,我们也发现了共享内存的一个问题,就是没有同步机制,通信双方并不是只有写端写入之后读端才能读,而是想读就读,想写就写,没有一写一读同步进行。
看完我们的示例代码以及我们学习的理论,总结一下共享内存的特点
优点
共享内存的方案是所有进程间通信中速度最快的,因为内存是双方所共享的,只要发送方把数据写到共享内存,接收方就能立马看到,因为本质上发送方和接受方看到的就是同一块内存,这样做能够大大减少数据的拷贝次数。
减少拷贝次数体现在哪里呢?
我们拿管道通信通信来对比,假设都是从键盘读入,然后写端发送,读端读取,并且打印到显示器上,我们可以来算一下管道和共享内存的通信方式各自需要几次拷贝。
首先是管道通信,我们首先需要将输入缓冲区的数据拷贝到我们自己的缓冲区(数组)中来,这是第一次拷贝,然后需要将数据写入管道,这是第二次拷贝,而读端首先需要从管道中将数据读到自己的缓冲区,这是第三次拷贝,最后将数据写入到输出缓冲区中,这是第四此拷贝,所以管道在这个过程中需要四次数据的拷贝。 当然,我们这里不考虑从键盘的内核缓冲区拷贝到语言的输入缓冲区,以及从语言的输出缓冲区拷贝到显示器的内核缓冲区,这两次无法避免,不计入次数。
而对于共享内存而言,从输入缓冲区经过一次拷贝,写到共享内存中,然后读端从共享内存经过一次拷贝写入到输出缓冲区中,整个过程只需要两次拷贝。
缺点
我们使用共享内存时会发现一个问题,就是加入读取的速度比写入的速度快,那么在新数据写入之前,这段时间读取到的数据都是之前已经读取过的陈旧的数据。而对于管道而言,每次读完一些数据之后,下一次读取就算没有新的数据写入,也不会去读取那些陈旧的数据,而是阻塞在read上。同时,共享内存的写端也没有限制,因为每次都是覆盖式的写在共享内存中,就算没有读端了,也不会主动停下来,这是一种浪费资源的行为。
同时,共享内存是没有给我们作同步和互斥的机制的,比如可能写端的数据还没写完,就被读端读走了,这种情况是有可能发生的,这时候数据就不完整,没有被保护起来。
那么我们怎么才能避免这种事情发生呢?
最简单的办法就是通信双方约定将头部的一个字节或者几个字节作为一种标志位,写端写入时,把标志位置为1,而读端每次都是先读标记位的数据,如果标记位为1,就说明是新的数据,那么读取所有数据,之后把标记为置为 0 ,代表本轮数据已经陈旧。
但是这种方法还是有点掩耳盗铃的感觉,不管怎么说,读端都是一直在读,只不过是读头几个字节还是全部读的区别,还是有效率的损失。
还有一种方法就是通信双方建立一条管道,用来发送通知,每次读端在读取共享内存之前,要进行一次管道的读取,而每次写端在写入数据之后,要往管道中也写入一个标志位, 这样一来,如果写端没有新写入的数据,那么读端就阻塞在了管道的读取上。
同样的,我们还可以再创建一个管道,用于读端给写端发送读取完成的标志,这样一来双方就不会出现没有读完进行写入或者没有写完就进行读取的操作了。
最后,我们再看一下共享内存的内核数据结构,我们前面说了可以使用 shmctl 来获取共享内存的属性,也就是一个结构体对象 shmid_ds,
这个结构体对象是操作系统主动暴露给用户的一部分用户级数据,注意这只是操作系统对共享内存进行管理的数据结构的一部分,操作系统不可能将该共享内存的所有的数据结构都给你放开,尤其是用于管理的字段,他给我们放开的只是属性字段,属性字段只是操作系统管理共享内存的数据结构的一个子集。
shmid_ds的每个字段的含义如下
我们能发现,key字段并不是直接存储在 shmid_ds表面的,而是存在它的第一个字段也就是 ipc_perm结构体中。而ipc_perm的第一个字段就是 key ,所以在内存上来看, key 就是整个shmdi_ds的第一个字段。
最后,共享内存的大小我们建议是 4kb的整数倍,因为系统分配共享内存是以 4kb 为单位的,划分内存块的就基本单位是 4 kb 。
但是就算你申请的是 4097 个字节,操作系统虽然会给你分配 8kb ,这8kb中,你也只有你所申请的4097个字节的使用权,我们要分清楚两个概念就是 内核分配给你的空间和你能使用的空间,这是两个不同的概念。
4 消息队列和信号量
这两个在这一篇文章中我们不做重点讲解,消息队列目前来说很少见,而信号量这种方式我们在后面的多线程才会用的稍微多一些。
4.1 消息队列
消息队列提供了从一个进程向另一个进程发送一个数据块的方法。
每个数据块都被认为是一个类型,接收者进程接受的数据块可以有不同的类型。
这些数据快的类型是自定义的,是用来标识该数据块是哪一方发送的或者是要发给谁的,这需要通信双方约定好。因为消息队列是通信双方都可以读写的,通信双方的进程用的是同一个队列,发送数据就在队列尾部进行插入,接收数据从头部识别类型然后读取,我们可以理解为一个链式的结构。
消息队列是系统提供的一个内核级数据结构。
创建消息队列的接口:
msgget
看到这个就接口是否有些熟悉,熟悉的 key 和熟悉的 flag ,由于消息队列是不需要实现指定大小,动态增长,所以就没有了size参数。返回值就是一个msqid
控制消息队列的接口
msgctl
这个接口和 shmctl 还是十分相似,因为他们都是遵守 system V标准的通信方式,在这些接口等方面都要遵守一定的规则。 我们也发现 msqid_ds 的数据结构对象的布局和shmid_ds的布局也是一样的,msqid_ds的第一个字段也是ipc_perm ,而ipc_perm的第一个字段也是key。
往消息队列中放数据
msgsnd
我们说消息队列的数据是一个数据块,而在这个数据块也是有要求的,首先他的第一个字段必须是数据块的类型,类型是由程序员自己决定的。而除了类型之外就是你要发送的数据了,用一个数组来存放数据
从消息队列中读取数据块
msgrcv
他相比发送数据的接口多了一个参数,标识要读取的数据块的类型,同时他还需要穿一个输出型参数。
消息队列的发送和接受的数据块都必须是指定格式的结构体。
消息队列的属性的数据结构 msqid_ds 的每个字段的含义也可以参考共享内存来类比。
同时命令行中查看消息队列资源用 ipcs -q ,要删除消息队列资源用 ipcrm -q msqid
4.2 信号量
在这里我们主要讲一些信号及其周边的一些概念,不侧重于他的使用,使用放在后续的多线程。
信号量是什么? 我们好像还没有接触过这样的或者类似的概念,
信号量的本质就是一个计数器,用来衡量可用资源数量的多少的问题
那么这个信号量能不能用一个程序中的全局变量来标识呢?当然不能,一个进程的全局变量归根结底是属于该进程自身的,而不能被其他的进程所访问到,而信号量既然要用来表示公共资源的多少,那么肯定是要被多个进程所共享的。
在讲解信号量之前,我们先来接触几个概念:
公共资源:可以被多个进程同时访问的资源,我们前面所学的用于进程间通信的资源都是公共资源。
而访问没有被保护的公共资源,比如我们上面的共享内存,在写方还没有完全写完的时候,读方就开始进行读取,这时候会出现数据不一致的问题。
而被保护起来的公共资源被称为临界资源。
在我们的进程中,大部分资源是独立的,比如进程的堆栈等空间,而公共资源则基本上都是为了和其他进程进行通信而申请的亦或是动态库这样的资源。而我们对临界资源进行操作的那部分代码被称为临界区,其他的大部分都是不访问临界资源的代码,被称为非临界区。
如何保护公共资源?同步与互斥。 同步我们暂时先不讲,互斥很好理解,就是同一时刻,这份公共资源只能被一个进程访问,其他要访问的进程只能等他访问完才能开始访问。互斥一般要通过加锁来实现。
在计算机科学中,我们把只有两态的情况称为原子性,用来标识一组操作要么不做,要么就直接做完,不允许只完成部分操作。比如我们向共享资源中写数据,要么就是不写,要写的话就是直接全部写进去
那么上面的这些概念和信号量有什么关系呢?
前面说了,信号量就是一个用来表示剩余公共资源数量的计数器。对于我们申请下来的公共资源,我们有两种方式来进行使用 :1 作为一个整体被所有共享的进程一起使用 2 将申请下来的大块资源分成一份一份的子资源,参与共享的进程只能访问这个资源中的一部分或者或子资源,不同进程访问的区域不同。
将公共资源划分为不同的子资源的情况下,进程要访问公共资源是需要先申请信号量的。就好比我们现实生活中,电影院就好比一个公共资源,而影院中的每个座位就是其中的子资源,当我们想要去看一场电影时,我们首先需要购买一张电影票,而电影票的内容有电影的时间以及你所申请的座位号,在这场电影期间,该座位号就是属于我们的,不管我们实际上有没有去看这场电影。而我们的进程要访问公共资源,是需要先申请信号量的,申请下来之后,计数器就会自减,进程就相当于预定了其中的一份小资源,就可以访问这份资源,同时进程是没有权限去访问她所预定的这份小资源以外的其他资源的。 而如果申请失败,比如说计数器为0,表示目前所有的资源都正在被占用,这时候进程就无法申请到信号量,也就无法访问到资源,这是一种对正在访问公共资源的进程的一种保护也是对公共资源本身的一种保护。我们就把这种用来保护临界资源的计数器叫做信号量。
按照上面的逻辑,我们可以为申请下来的公共资源设定一个信号量。进程要访问资源就需要先申请信号量,申请成功信号量计数器会减减,信号量的减减就是在预定资源。 而当一个进程访问完公共资源了,这时候信号量就会加加,加加就叫做释放资源,这里的释放不是说释放这块公共资源,而是意味着该资源可以被其他进程申请了。 那么是不是每一个进程要申请资源的时候都需要先访问信号量?不访问信号量怎么知道他的计数器是否为0,能否申请下来资源? 而访问信号量的前提就是所有参与共享的进程必须能够看到同一个信号量,那么信号量自身也就是一份公共资源了。而信号量也需要保证自身的安全,他的安全主要要保证进程对其进行操作的安全性,比如他的加加和减减操作,他的加加和减减操作就必须是原子性的。
我们把信号量的减减操作叫做申请资源,称为P操作。把信号量的加加叫做释放资源,称为V操作。信号量必须支持PV操作。
而如果我们把信号量的初始值设为1呢?就是将资源以整体来访问,同时由于访问之前必须要申请信号量,这就注定了它同一时刻只能被一个进程所访问,这也就是一种互斥。同时我们把这种提供互斥功能也就是只有两种状态的信号量称为二元信号量。
既然申请下来信号量就能够访问其中的一块子资源,那么会不会出现多个进程想要访问同一块子资源的情况呢?这种情况是肯定会有的,因为我们的信号量申请成功只能代表这块公共资源种还有子资源是空闲的,能够被访问,但是没有规定申请成功的进程必须只能访问某一块资源,所以这种情况是可能会发生的。但是还是那句话,信号量申请下来只能代表还有资源能够被访问,但也不是说进程想访问哪一份就访问哪一份的,具体能够访问那一块资源在多进程当中要通过某种方式去区分,这份工作是要有程序员自己完成的,由程序员的代码来确定每个进程应该访问哪一份资源,所以我们也不用担心。
那么如何使用信号量呢?
首先我们要申请一块公共资源,如何申请一份信号量的公共资源呢?
semget
他的参数的key和flag没什么好讲的,第二个参数 nsems 表示要申请的信号量资源的个数。没错,system V 版本的信号量在底层是允许申请多个信号量的,这里的信号量的个数不是指信号量的值,信号量的值就是计数器的数值,而信号量的个数则代表申请的公共资源的个数,每一份公共资源都由一个信号量来控制其中子资源的访问。如果申请成功,semget会返回一个信号量集合的标识符。
控制信号的接口:
semctl
其中,semnum表示的是我们要操作的信号量的下标。我们使用semget申请信号量返回的的信号量集合,信号量可能不止一个,所以需要一个下标来表示要操作哪一个信号量。
保存信号量的属性的数据结构和共享内存以及消息队列还是类型,第一个字段还是一个ipc_perm结构体,而ipc_perm中第一个字段还是key 。system V系列的这些资源的结构体都是这样的设计方式。
信号量的PV操作
semop
sembuf结构体中有三个字段,第一个sem_num表示要操作的信号量的下标,第二个sem_op表示操作的类型,一般是-1对应P操作,1对应V操作。,sem_flg表示进行操作的选项。
第三个参数 nsops第二个参数传sembuf的结构体的个数,我们看到第二个参数用的是指针传参,有可能是传一个结构体数组来对多个信号量进行操作。
system V 标准的通信方式的共性
首先这三种通新方式的接口的相似度就非常高,毕竟是同一个标准,在接口的设置,数据结构的设置等都需要遵循一定的规范。那么第二个相似的自然就是他们的内核数据结构,也就是我们能获取的 struct ..._ds这个数据结构。这个内核数据结构只是操作系统管理申请下来的公共资源的数据结构的一个子集,也就是其中一部分,里面的这些数据都是操作系统放开给我们看的,同时还有一些其他的属性肯定是不对用户开放的。
我们能发现的最明显的就是,这三个数据结构的第一个字段都是 ipc_perm结构体,这样设置有什么好处吗?
操作系统能够在内核中维护一个 struct ipc_perm* 类型的指针数组,来管理这三种通信方式所申请下来的公共资源,指向他们的数据结构的第一个字段,只需要有这个字段的地址,操作系统就能看到整个 _ds 结构体,因为该字段是 _ds结构体的第一个字段,他们的地址在数值上是相等的,当我们想要用这个数组存的指针取查看对应资源的属性时,只需要把该指针强转成对应的 struct semid_ds*或者struct shmid_ds*就能看到震哥哥_ds结构体了,这样做减少了操作系统管理这些资源的成本。但是操作系统是怎么知道每一个ipc_perm指针所指向的对象是 struct semid_ds还是struct_shmid_ds还是struct msqid_ds的结构体的,这也很简单,我们并不一定就是简单的存储一个指针,可能还存储一个变量用来表示 type ,这两个变量放在一个结构体中有操作系统管理就行了。这样一来,操作系统拿到ipc_perm指针,查看他的type就能知道它所指向的对象是什么类型的,这就有点像我们C++的多态,保存的是一个基类的指针,可以根据派生类的不同完成不同的行为。