我不怕风雪,只怕你的坚决,闯入我世界难以磨灭...................................................................
文章目录
前言
本篇博客的主要内容主要介绍了进程之间是如何进行通信的,了解进程间的通信对操作系统的学习相当重要,请耐心观看!
提示:以下是本篇文章正文内容,下面案例可供参考
一、【有关进程间通信的介绍】
1、【进程间通信的概念】
进程间通信简称IPC(Interprocess communication),进程间通信就是在不同进程之间传播或交换信息。
2、【进程间通信的目的】
主要有以下四种目的:
- 数据传输: 一个进程需要将它的数据发送给另一个进程。
- 资源共享: 多个进程之间共享同样的资源。
- 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
- 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
3、【进程间通信的本质】
进程间通信的本质就是,让不同的进程看到同一份资源。
首先我们要知道实现上面那4个目的的前提,就是进程间可以通信起来。由于各个运行的进程之间具有独立性,而这个独立性主要体现在数据层面,而代码逻辑层面可以私有也可以公有(例如父子进程),因此各个进程之间要实现通信是非常困难的。如果数据传输通过数据拷贝的方式进行,也就是将A进程的数据直接拷贝给B进程,这样实际上破坏了进程的独立性。这时就需要一个AB进程都能去到的地方,往这个共享区进行读写,这样就能保证进程独立性的同时,还实现了通信的功能,操作系统就很适合当这个中间人。
所以,各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
4、【进程间通信的分类】
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
二、【管道】
2.1、【什么是管道?】
管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”,在Linux中主要包括匿名管道和命名管道。
在linux命令中,| 就是管道,可以将输出的信息读取给后面的命令处理。
例如,统计我们当前使用云服务器上的登录用户个数。
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
注明: who命令用于查看当前云服务器的登录用户(一行显示一个用户),wc -l用于统计当前的行数。
2.2、【匿名管道】
2.2.1、【匿名管道的原理】
匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。
由于进程间通信的本质就是,让不同的进程看到同一份资源,所以使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
注意:
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
通俗的说,我们知道一个进程有他自己的task_struct,该task_struct里面有一个指向文件结构体(struct_file)的指针,该文件结构体里面存在文件标识符数组,数组0号下标指向键盘(stdin),1指向显示器(stdout),2指向显示器(stderr)。现在,我们新打开一个文件,由于文件标识符的分配规则,该文件的文件标识符就为3,此时我fork创建子进程,子进程要继承父亲的task_struct、虚拟地址空间、页表等等,那么都继承了task_struct了,files_struct也肯定要继承一份(因为如果共用files_struct,那么子进程想打开新的文件,肯定会往文件标识符数组里面写内容,那么父进程也会看见,这岂不是破坏了进程的独立性)。
既然files_struct也继承了,里面指向的新文件也肯定继承了,文件有文件缓冲区,如果父进程往该文件进行写入,子进程对文件进行读取,这样是不是就完成了进程间的通信。(注意:虽然文件缓冲区一般情况是要刷新到磁盘中的,但是这里文件只充当了管道的作用,刷新到磁盘再读取磁盘的效率非常低,因此这里并不会涉及到磁盘,只是内存级别的数据拷贝)。
但是这里还存在一点小问题,就是父进程如果只以只读方式打开文件,那么子进程也只能是只读的方式打开文件,并不能写入,因此需要父进程同时对一个文件进行打开读取和打开写入操作,子进程继承下来后,既可以读取又可以写入,那么到时候,我想让子进程写,父进程读,就只需关闭子进程读,父进程写就可以了,反之亦然。
我们对同一文件进行读和写,在内核数据结构中实际上会生成两个struct_file,只不过这两个struct_file,都指向的是同一个inode,同一个缓冲区。进程关闭文件时,将文件标识符数组清空,file里面的引用计数 -- ,进程就不管文件了,但操作系统还得判断当前文件引用计数是否为0,为0证明没有进程还在使用该文件,就可以关闭,不为0就不会真正的关闭。
这就是管道名字的由来,只支持一边读,另一边写,是半双工的一种特殊方式。 同时这个管道我们并不关心它的名字,因此叫做匿名管道。
2.2.2、【pipe函数】
pipe函数用于创建匿名管道,pip函数的函数原型如下:
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符:
pipe函数调用成功时返回0,调用失败时返回-1,同时错误码也会被设置。
这里我们可以这样理解pipe函数,pipe对调用它的进程使该进程以读和写的方式打开了一个新文件,并将打开该文件所产生的两个文件标识符放在了pipefd[]数组中,其中pipefd[0]对应读方式的文件标识符,pipefd[1]对应写方式的文件标识符。
2.2.3、【匿名管道使用步骤】
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
1、父进程调用pipe函数创建管道。
2、父进程调用fork函数创建子进程。
3、父进程关闭写端,子进程关闭读端(反之亦然)。
注意:
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。
我们也可以站在文件描述符的角度再来看看这三个步骤:
1、父进程调用pipe函数创建管道。
2、父进程创建子进程。
3、父进程关闭写端,子进程关闭读端。
例如,在以下代码当中,子进程向匿名管道当中写入10行数据,父进程从匿名管道当中将数据读出。
//child->write, father->read这里让子进程写,父进程读 #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* msg = "hello father, I am child..."; int count = 10; while (count--){ write(fd[1], msg, strlen(msg));//向写端写入msg sleep(1); } close(fd[1]); //子进程写入完毕,关闭文件 exit(0); } //father close(fd[1]); //父进程关闭写端 //父进程从管道读取数据 char buff[64]; while (1){ ssize_t s = read(fd[0], buff, sizeof(buff));//从读端将数据读入buff中 if (s > 0){ buff[s] = '\0'; printf("child send to father:%s\n", buff); } else if (s == 0){ printf("read file end\n"); break; } else{ printf("read error\n"); break; } } close(fd[0]); //父进程读取完毕,关闭文件 waitpid(id, NULL, 0); return 0; }
运行结果如下:
2.2.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将不再保证写入的原子性。2.2.5、【管道的特点】
(1)管道内部自带同步与互斥机制。
我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或是读取操作,因此管道也就是一种临界资源。
临界资源是需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写、交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
- 同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
- 互斥: 一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
(2)管道的生命周期随进程。
管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。
(3)管道提供的是流式服务。
对于进程A写入管道当中的数据,进程B每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:
- 流式服务: 数据没有明确的分割,不分一定的报文段。
- 数据报服务: 数据有明确的分割,拿数据按报文段拿。
(4)管道是半双工通信的。
在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
2.2.6、【管道的四种特殊情况】
在使用管道时,可能出现以下四种特殊情况:
- 写端进程不写,读端进程一直读,那么此时会因为管道里面没有数据可读,对应的读端进程会被挂起,直到管道里面有数据后,读端进程才会被唤醒。
- 读端进程不读,写端进程一直写,那么当管道被写满后,对应的写端进程会被挂起,直到管道当中的数据被读端进程读取后,写端进程才会被唤醒。
- 写端进程将数据写完后将写端关闭,那么读端进程将管道当中的数据读完后,就会继续执行该进程之后的代码逻辑,而不会被挂起。
- 读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么操作系统会将写端进程杀掉。
其中前面两种情况就能够很好的说明,管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒,可以总结如下:
【1】管道无数据,读端需等待
如果管道中没有数据了,读端需要等待,直到写端写入数据:
【2】管道被写满,写端需等待
管道被写满了,写端需要等待,等读端读走数据才可以继续写。
【3】写端关闭,读端一直读取
第三种情况也很好理解,读端进程已经将管道当中的所有数据都读取出来了,而且此后也不会有写端再进行写入了,那么此时读端进程也就可以执行该进程的其他逻辑了,而不会被挂起:
我们关闭写端,读端一直读取,读端会读到read返回值为0,表示文件结尾。
【4】读端关闭,写端一直写入
第四种情况也不难理解,既然管道当中的数据已经没有进程会读取了,那么写端进程的写入将没有意义,因此操作系统直接将写端进程杀掉。而此时子进程代码都还没跑完就被终止了,属于异常退出,那么子进程必然收到了某种信号:
我们可以通过以下代码看看情况四中,子进程退出时究竟是收到了什么信号。
#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* msg = "hello father, I am child..."; int count = 10; while (count--){ write(fd[1], msg, strlen(msg)); 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; }
运行结果显示,子进程退出时收到的是13号信号。
通过
kill -l
命令可以查看13对应的具体信号。
由此可知,当发生情况四时,操作系统向子进程发送的是
SIGPIPE
信号将子进程终止的。2.2.7、【管道的大小】
管道的容量是有限的,如果管道已满,那么写端将阻塞或失败,那么管道的最大容量是多少呢?
方法一:使用man手册
根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux 2.6.11往后,管道的最大容量是65536字节。
然后我们可以使用uname -r命令,查看自己使用的Linux版本。
根据man手册,我使用的是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 = 'a'; 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字节。
2.2.8、【匿名管道实现进程池】
1、【进程池概念】
我们知道,系统资源的获取是有成本的,比如我们创建进程,需要花一定的时间去完成,假设,现在我们有一些比较重要的任务需要处理,如果等待任务到来,再创建进程去处理任务,时间上会慢一点,如果我们提前将进程创建好,任务到来,我们直接对进程分派任务,这样就能节省时间,这些提前创建好并被管理的进程,有任务来就分派执行,我们可以称之为进程池,除了进程池我们还见过内存池,前面我们在实现STL中的容器时,避免不了的要面对给容器扩容的问题,我们当时所采用的就是将容量扩大为先前容量的1.5或者2倍,这种提前将所需空间预留出来的计数本质上就是内存池。
打个比方,比如说你喝娃哈哈矿泉水,如果你感觉到口渴了,才去外面超市买娃哈哈矿泉水,这样成本是不是比较高,有点浪费时间,但是如果你提前在家里面放上一箱娃哈哈矿泉水,渴了就喝,顺手就拿的事情,效率就提高了,这也相当于把矿泉水进行池化了。
2、【进程池实现】
我们今天要做的,是写一个进程池,就是提前先创建好一批进程,等到有任务来的时候,通过主进程master向通过管道向各子进程发送任务,使得子进程直接可以处理任务:
我们首先把架子搭好:
#include<iostream> #include<unistd.h> const int num = 5;//定义创建管道和子进程的个数 using namespace std; #include<cassert> int main() { //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { //关闭写端 close(pipefd[1]) } //父进程 //关闭读端 close(pipefd[0]); } }
现在我们创建好了进程,但是有个问题,我们并不知道什么时候该往哪个进程发配任务,现在我们的主进程跟我们创建的进程没有任何的关系,这个时候,我们就要用信道:
通过信道(本质上也是一种管道),我们主进程就知道该往哪个进程发配任务了。我们可以创建一个类对它进行管理:
#include<iostream> #include<unistd.h> #include<cstring> #include<vector> const int num = 5; static int channel_number = 1; //信道起始数量 using namespace std; #include<cassert> class channel { public: channel(int fd,pid_t id) :ctrlfd(fd) ,workid(id) { name = "channel->" + to_string(channel_number++); } int ctrlfd; //读写端的fd pid_t workid; //子进程id string name; //管道名字 }; int main() { vector<channel> channels; //信道 //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { //关闭写端 close(pipefd[1]) exit(0); } //父进程 //关闭读端 close(pipefd[0]); channels.push_back(channel(pipefd[1],id)); //往信道写入 } }
然后我们把创建信道的过程抽象出来形成一个函数:
#include<iostream> #include<unistd.h> #include<cstring> #include<vector> const int num = 5; static int channel_number = 1; //信道起始数量 using namespace std; #include<cassert> class channel { public: channel(int fd,pid_t id) :ctrlfd(fd) ,workid(id) { name = "channel->" + to_string(channel_number++); } int ctrlfd; //读写端的fd pid_t workid; //子进程id string name; //管道名字 }; void CreateChannel( vector<channel> *channels)//这里的channels其实就是一种输出型参数,我们需 { //要channels来保存创建好的信道,方便管理 //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { //关闭写端 close(pipefd[1]) exit(0); } //父进程 //关闭读端 close(pipefd[0]); channels->push_back(channel(pipefd[1],id)); //往信道写入 } } int main() { vector<channel> channels; //信道 //创建信道 CreateChannel(&channels); }
这里我们规范一下传参方式:
- 输入参数:const &
- 输出参数:*
- 输入输出参数:&
我们创建一个函数来表示子进程的工作:
#include<iostream> #include<unistd.h> #include<cstring> #include<vector> const int num = 5; static int channel_number = 1; //信道起始数量 using namespace std; #include<cassert> class channel { public: channel(int fd,pid_t id) :ctrlfd(fd) ,workid(id) { name = "channel->" + to_string(channel_number++); } int ctrlfd; //读写端的fd pid_t workid; //子进程id string name; //管道名字 }; void Work()//子进程工作函数 { while(true) { cout<< "I am running "<< getpid() << endl; sleep(1); } } void CreateChannel( vector<channel> *channels) //创建信道 { //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { //关闭写端 close(pipefd[1]); //子进程要完成的工作 Work(); exit(0); } //父进程 //关闭读端 close(pipefd[0]); channels->push_back(channel(pipefd[1],id)); //往信道写入 } } //测试 void PrintChannel(const vector<channel> &channels) //输入型参数 { for(auto e: channels) { cout<<e.name<<", "<<e.ctrlfd<<", "<<e.workid<<endl; } } int main() { vector<channel> channels; //信道 //创建信道 CreateChannel(&channels); PrintChannel(channels); sleep(10); return 0; }
我们可以运行一下看看:
这里我们可能会注意到,这里fork函数之后,父子进程进行分流,那么父子进程各自在做什么呢?
实际上,父子进程都会继续向下执行,子进程会继续向下执行work函数,直到work函数执行完毕,执行exit(0),子进程才会结束,而父进程则会将自己的读端关闭,然后将新构建的channel插入到channels中,最后返回main函数继续向下执行,这样我们未创建信道前main函数内只有一个父进程,创建后也仍然只有一个父进程,所以结果如上图所示,最后构成了下图所示的信道结构:
现在我们建立好了信道,接下来就是接收主进程给我们的任务就可以了,可是子进程如何接收和识别任务呢?我们这里规定:传不同的数字,做不同的任务
首先,我们这里先重定向,从标准输入读取(省略传参):
if(id == 0) //子进程 { //关闭写端 close(pipefd[1]); //子进程要完成的工作 dup2(pipefd[0],0); //将读端重定向到标准输入,使其向标准输入读 Work(); exit(0); }
这里修改work函数使其能够根据父进程写入的code来指示子进程完成相应的工作:
void Work() { while(true) { int code = 0; //任务代码 int n = read(0,&code,sizeof(code)); assert(n == sizeof(code)); //要做的任务 } }
我们可以开一个hpp文件,来模拟我们的任务:
#pragma once #include<iostream> #include<functional> #include<vector> #include <ctime> #include<unistd.h> typedef std::function<void()> task_t; //使用包装器管理任务 void Download() { std::cout << "I am a Download" << " deal with: " << getpid() << std::endl; } void PrintLog() { std::cout << "I am a log" << " deal with: " << getpid() << std::endl; } void PushVideoStream() { std::cout << "I am a vdieo" << " deal with: " << getpid() << std::endl; } class Init { public: // 任务码,领取相应的任务码,做相应的任务 const static int g_download_code = 0; const static int g_printlog_code = 1; const static int g_push_videostream_code = 2; // 任务集合 std::vector<task_t> tasks; public: Init() { tasks.push_back(Download); tasks.push_back(PrintLog); tasks.push_back(PushVideoStream); srand(time(nullptr) ^ getpid()); } bool CheckSafe(int code) { if (code >= 0 && code < tasks.size()) return true; else return false; } void RunTask(int code) //运行任务 { return tasks[code](); } int SelectTask() //选择任务 { return rand() % tasks.size(); } std::string ToDesc(int code) { switch (code) { case g_download_code: return "Download"; case g_printlog_code: return "PrintLog"; case g_push_videostream_code: return "PushVideoStream"; default: return "Unknow"; } } }; Init init; //创建对象
我们相应文件的变化:
#include<iostream> #include<unistd.h> #include<cstring> #include<vector> const int num = 5; static int channel_number = 1; //信道起始数量 using namespace std; #include<cassert> #include"Task.hpp" class channel { public: channel(int fd,pid_t id) :ctrlfd(fd) ,workid(id) { name = "channel->" + to_string(channel_number++); } int ctrlfd; //读写端的fd pid_t workid; //子进程id string name; //管道名字 }; void Work() { while(true) { int code = 0; //任务代码 int n = read(0,&code,sizeof(code)); assert(n == sizeof(code)); //要做的任务 if(!init.CheckSafe(code)) continue; init.RunTask(code); } } void CreateChannel(vector<channel> *channels) { //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { //关闭写端 close(pipefd[1]); //子进程要完成的工作 dup2(pipefd[0],0); //重定向,向标准输入读 Work(); exit(0); } //父进程 //关闭读端 close(pipefd[0]); channels->push_back(channel(pipefd[1],id)); //往信道写入 } } void PrintChannel(const vector<channel> &channels) //输入型参数 { for(auto e: channels) { cout<<e.name<<", "<<e.ctrlfd<<", "<<e.workid<<endl; } } //这里g_always_loop,表示为真时任务一直执行,为假时按照num执行 void SendCommand(const std::vector<channel> &channels, bool flag, int num = -1) { int pos = 0; while (true) { // 1. 选择任务 int command = init.SelectTask(); // 2. 选择信道(进程) const auto &channel = channels[pos++]; pos %= channels.size(); // debug std::cout << "send command " << init.ToDesc(command) << "[" << command << "]" << " in " << channel.name << " worker is : " << channel.workid << std::endl; // 3. 发送任务 write(channel.ctrlfd, &command, sizeof(command)); // 4. 判断是否要退出 if (!flag) { num--; if (num <= 0) break; } sleep(1); } std::cout << "SendCommand done..." << std::endl; } int main() { vector<channel> channels; //信道 //创建信道 CreateChannel(&channels); //PrintChannel(channels); //选择任务,选择信道 const bool g_always_loop = true; //这里g_always_loop,表示为真时任务一直执行,为假时按照10次执行 SendCommand(channels, !g_always_loop, 10); // sleep(10); return 0; }
我们可以运行一下:
这里我们再来叙述一下,像前面那样我们先构建好信道及对应关系,这里有个不同就是,子进程的work函数不再是简单的打印了,而是根据父进程向信道中写入的code完成相应的任务,所以在fork分流以后,父子进程各自向下执行,父进程将形成的信道插入到channels,子进程进入work函数,并从标准输入中读取code,但是这里由于管道的四种特殊情况中的第一种情况(写端没有写,管道中无数据,读端会一直等待),子进程会进入挂起状态,因为父进程此时并未向信道中写入数据,所以子进程会一直保持挂起状态直到父进程向信道中写入数据,父进程回到main函数后,继续向下执行,进行选择任务,并将其写入信道,由于重定向,所以本质上写入了标准输入,子进程读到数据,进而五年成像的任务。
最后我们就要考虑一下进程退出了,其实,我们想让进程退出,就只需要关闭写端就可以了。(此时会读到0,表示已经读到了文件末尾)
所以,我们之前写的代码,要稍微修改一下:
int main() { vector<channel> channels; //信道 //创建信道 CreateChannel(&channels); //PrintChannel(channels); //选择任务,选择信道 const bool g_always_loop = true; SendCommand(channels, !g_always_loop, 10); //进程退出,关闭写端 for(const auto &channel : channels) //关闭写端 { close(channel.ctrlfd); } //sleep(10); return 0; }
我们可以把这几行代码封装起来(顺便回收子进程):
void ReleaseChannels(vector<channel> channels) { for (const auto &channel : channels) { close(channel.ctrlfd); } //回收子进程 for(const auto &channel : channels) { pid_t rid = waitpid(channel.workid,nullptr,0); if(rid == channel.workid) { cout<<"wait child: "<<channel.workid<<" success"<<endl; } } } int main() { vector<channel> channels; //信道 //创建信道 CreateChannel(&channels); //PrintChannel(channels); //选择任务,选择信道 const bool g_always_loop = true; SendCommand(channels, !g_always_loop, 10); //进程退出,关闭写端 ReleaseChannels(channels); //sleep(10); return 0; }
我们可以运行一下:
但是其实我们之前写的创建管道的代码有一点bug:
void CreateChannel(vector<channel> *channels) { //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { //关闭写端 close(pipefd[1]); //子进程要完成的工作 dup2(pipefd[0],0); //重定向,向标准输入读 Work(); exit(0); } //父进程 //关闭读端 close(pipefd[0]); channels->push_back(channel(pipefd[1],id)); //往信道写入 } }
现在我们是结束一个进程,回收一个进程,就会有问题:
void ReleaseChannels(vector<channel> channels) { for (const auto &channel : channels) { close(channel.ctrlfd); waitpid(channel.workid,nullptr,0); //关掉一个收一个 } // //回收子进程 // for(const auto &channel : channels) // { // pid_t rid = waitpid(channel.workid,nullptr,0); // if(rid == channel.workid) // { // cout<<"wait child: "<<channel.workid<<" success"<<endl; // } // } }
这个时候,进程会卡死。这是为什么呢?
其实,第一次创建子进程时,是没有啥问题的:
从第二次开始,每次创建的子进程会继承上一个文件描述符表的写端:
这种情况会一直累积,只有最后一个文件只有一个写端。这样会导致我们的信道不会为空,子进程读不到0,不会退出,发生阻塞。
解决方法也很简单,第一种,我们倒着回收:
void ReleaseChannels(vector<channel> channels) { int num = c.size() - 1; for (; num >= 0; num--) { close(c[num].ctrlfd); waitpid(c[num].workerid, nullptr, 0); } }
第二种,在新的子进程中关闭多余的文件描述符,我们要在创建信道那里做一点小改动:
void CreateChannel(vector<channel> *channels) { vector<int> tmp; //临时记录,用来记录老的fd //创建多个子进程 for(int i = 0; i < num; i++) { //创建管道 int pipefd[2]; int n = pipe(pipefd); //检查是否创建管道成功 assert(n == 0); //创建父子进程 pid_t id = fork(); if(id == 0) //子进程 { if(!tmp.empty()) { for(auto fd : tmp) { close(fd); } PrintFd(tmp); } //关闭写端 close(pipefd[1]); //子进程要完成的工作 dup2(pipefd[0],0); //重定向,向标准输入读 Work(); exit(0); } //父进程 //关闭读端 close(pipefd[0]); channels->push_back(channel(pipefd[1],id)); //往信道写入 tmp.push_back(pipefd[1]); //记录老的文件描述符 } } void PrintFd(const std::vector<int> &fds) //用来打印看看关闭了哪些fd { cout << getpid() << " close fds: "; for(auto fd : fds) { cout << fd << " "; } cout << endl; }
结果:
2.3、【命名管道】
命名管道(Named Pipe),也被称为
FIFO
(First In First Out),是一种特殊的文件类型,和匿名管道不同的是,这种文件存在与磁盘中,因此可以被不同的进程使用,即使这两个进程没有血缘关系。
2.3.1、【命名管道的原理】
匿名管道只能用于具有共同祖先的进程(具有亲缘关系的进程)之间的通信,通常,一个管道由一个进程创建,然后该进程调用fork,此后父子进程之间就可应用该管道。
如果要实现两个毫不相关进程之间的通信,可以使用命名管道来做到。命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。注意:
- 普通文件是很难做到通信的,即便做到通信也无法解决一些安全问题。
- 命名管道和匿名管道一样,都是内存文件,只不过命名管道在磁盘有一个简单的映像,但这个映像的大小永远为0,因为命名管道和匿名管道都不会将通信数据刷新到磁盘当中。
2.3.2、【使用mkfifo创建命名管道】
1、【mkfifo命令】
命名管道指令创建很简单,输入指令 mkfifo + 文件名,就可以创建一个命名管道。命名管道需要被创建在磁盘上,他是有自己的路径的,因为路径是具有唯一性的,所以我们可以使用路径+文件名的方式,来让不同进程看到同一份资源。
mkfifo fifo
使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程(进程A)中用shell脚本每秒向命名管道写入一个字符串,在另一个进程(进程B)当中用cat命令从命名管道当中进行读取。那么两个没有血缘关系的进程,就可以通过命名管道的唯一性找到他,并借此通信。原理其实跟匿名管道没什么区别,区别在于,匿名管道是继承下来文件,命名管道是自己打开的文件。
现象就是当进程A启动后,进程B会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。
之前我们说过,当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。
需要注意的是,在我们管道通信的过程中,fifo管道的大小一直都是0,并不会写入到文件中,这是管道的特性决定了,因为根本没必要将数据拷贝到磁盘中,在缓冲区中就可以了,你写入就写在缓冲区中,你读取也在缓冲区中读取,因此这里的缓冲区是内存级别的。
2、【mkfifo函数】
当然了,我们也可以在程序中创建命名管道使用mkfifo函数,mkfifo函数的函数原型如下:
int mkfifo(const char *pathname, mode_t mode);
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
- 若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。
- 若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义)
mkfifo函数的第二个参数是mode,表示创建命名管道文件的默认权限。
例如,将mode设置为0666,则命名管道文件创建出来的权限如下:
但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
若想创建出来命名管道文件的权限值不受umask的影响,则需要在创建文件前使用
umask
函数将文件默认掩码设置为0。mkfifo函数的返回值。
- 命名管道创建成功,返回0。
- 命名管道创建失败,返回-1,错误码也被设置。
使用以下代码即可在当前路径下,创建出一个名为myfifo的命名管道。
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #define FILE_NAME "myfifo" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } //create success... return 0; }
运行代码后,命名管道myfifo就在当前路径下被创建了。
3、【命名管道的打开规则】
1、如果当前打开操作是为读而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO。
- O_NONBLOCK enable:立刻返回成功。
2、如果当前打开操作是为写而打开FIFO时。
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO。
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO。
2.3.3、【利用命名管道实现进程间的通信】
1、【实现serve&client通信】
我们可以利用命名管道实现服务端(server)和客户端(client)之间的通信,在此之前,我们需要先让服务端运行起来,我们需要让服务端运行后创建一个命名管道文件,然后再以读的方式打开该命名管道文件,然后让客户端向管道中写入数据,最后服务端就可以从该命名管道当中读取客户端发来的通信信息了。
服务端的代码如下:
//server.c #include "comm.h" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件 if (fd < 0){ perror("open"); return 2; } char msg[128]; while (1){ msg[0] = '\0'; //每次读之前将msg清空 //从命名管道当中读取信息存到msg中 ssize_t s = read(fd, msg, sizeof(msg)-1);//将从管道中读取到的数据存放到msg中 if (s > 0){ msg[s] = '\0'; //手动设置'\0',便于输出 printf("client# %s\n", msg); //输出客户端发来的信息 } 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"); return 1; } char msg[128]; while (1){ msg[0] = '\0'; //每次读之前将msg清空 printf("Please Enter# "); //提示客户端输入 fflush(stdout); //从客户端的标准输入流读取信息 ssize_t s = read(0, msg, sizeof(msg)-1);//将要写的信息输入到msg if (s > 0){ msg[s - 1] = '\0'; //将信息写入命名管道 write(fd, msg, strlen(msg));//将msg中的信息输入到管道 } } close(fd); //通信完毕,关闭命名管道文件 return 0; }
对于如何让客户端和服务端使用同一个命名管道文件,这里我们可以让客户端和服务端包含同一个头文件,该头文件当中提供这个共用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了,如下:
//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" //让客户端和服务端使用同一个命名管道
这里同样可以使用Makefile来维护,由于我们需要两个进程共同协作,因此采样依赖的方式,先依赖上要形成的可执行文件,这样我们就可以一次生成两个可执行程序了。
.PHONY:all all:server client server:server.c g++ -o $@ $^ -std=c++11 client:client.c g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f server client
代码编写完毕后,先将服务端进程运行起来,之后我们就能在客户端看到这个已经被创建的命名管道文件。
接着再将客户端也运行起来,此时我们从客户端写入的信息被客户端写入到命名管道当中,服务端再从命名管道当中将信息读取出来打印在服务端的显示器上,该现象说明服务端是能够通过命名管道获取到客户端发来的信息的,换句话说,此时这两个进程之间是能够通信的。
当客户端和服务端运行起来时,我们还可以通过ps命令查看这两个进程的信息,可以发现这两个进程确实是两个毫不相关的进程,因为它们的PID和PPID都不相同。也就证明了,命名管道是可以实现两个毫不相关进程之间的通信的。
2、【服务端和客户端之间的退出关系】
当客户端退出后,服务端将管道当中的数据读完后就再也读不到数据了,那么此时服务端也就会去执行它的其他代码了(在当前代码中是直接退出了)。
当服务端退出后,客户端写入管道的数据就不会被读取了,也就没有意义了,那么当客户端下一次再向管道写入数据时,就会收到操作系统发来的13号信号(SIGPIPE),此时客户端就被操作系统强制杀掉了。
若是我们只让客户端向管道写入数据,而服务端不从管道读取数据,那么这个管道文件的大小会不会发生变化呢?
//server.c #include "comm.h" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件 if (fd < 0){ perror("open"); return 2; } while (1){ //服务端不读取管道信息 } close(fd); //通信完毕,关闭命名管道文件 return 0; }
可以看到,尽管服务端不读取管道当中的数据,但是管道当中的数据并没有被刷新到磁盘,使用
ll
命令看到命名管道文件的大小依旧为0,也就说明了双方进程之间的通信依旧是在内存当中进行的,和匿名管道通信是一样的。
需要注意的是两个进程之间的通信,并不是简单的发送字符串而已,服务端是会对客户端发送过来的信息进行某些处理的。
2.3.4、【用命名管道实现派发计算任务】
这里我们以客户端向服务端派发计算任务为例,客户端通过管道向服务端发送双操作数的计算请求,服务端接收到客户端的信息后需要计算出相应的结果。
这里我们无需更改客户端的代码,只需改变服务端处理通信信息的逻辑即可。
//server.c #include "comm.h" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } int fd = open(FILE_NAME, O_RDONLY); //打开命名管道文件 if (fd < 0){ perror("open"); return 2; } char msg[128]; while (1){ msg[0] = '\0'; //每次读之前将msg清空 //从命名管道当中读取信息 ssize_t s = read(fd, msg, sizeof(msg)-1); if (s > 0){ msg[s] = '\0'; //手动设置'\0',便于输出 printf("client# %s\n", msg); //服务端进行计算任务 char* lable = "+-*/%"; char* p = msg; 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(msg, "+-*/%"); char* data2 = strtok(NULL, "+-*/%"); int num1 = atoi(data1); int num2 = atoi(data2); int ret = 0; switch (flag){ case 0: ret = num1 + num2; break; case 1: ret = num1 - num2; break; case 2: ret = num1 * num2; break; case 3: ret = num1 / num2; break; case 4: ret = num1 % num2; break; } printf("%d %c %d = %d\n", num1, lable[flag], num2, ret); //打印计算结果 } else if (s == 0){ printf("client quit!\n"); break; } else{ printf("read error!\n"); break; } } close(fd); //通信完毕,关闭命名管道文件 return 0; }
此时服务端接收到客户端的信息后,需要进行的处理动作就不是将其打印到显示器了,而是需要将信息经过进一步的处理,从而得到相应的结果。
2.3.5、【用命名管道实现进程遥控】
比较有意思的是,我们可以通过一个进程来控制另一个进程的行为,比如我们从客户端输入命令到管道当中,再让服务端将管道当中的命令读取出来并执行。
下面我们只实现了让服务端执行不带选项的命令,若是想让服务端执行带选项的命令,可以对管道当中获取的命令进行解析处理。这里的实现非常简单,只需让服务端从管道当中读取命令后创建子进程,然后再进行进程程序替换即可。
这里也无需更改客户端的代码,只需改变服务端处理通信信息的逻辑即可。
#include "comm.h" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件 if (fd < 0){ perror("open"); return 2; } char msg[128]; while (1){ msg[0] = '\0'; //每次读之前将msg清空 //从命名管道当中读取信息 ssize_t s = read(fd, msg, sizeof(msg)-1); if (s > 0){ msg[s] = '\0'; //手动设置'\0',便于输出 printf("client# %s\n", msg); if (fork() == 0){ //child execlp(msg, msg, 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; }
此时服务端接收到客户端的信息后,便进行进程程序替换,进而执行客户端发送过来的命令。
2.3.6、【命名管道实现文件拷贝】
这里我们再用命名管道实现一下文件的拷贝。
需要拷贝的文件是
file.txt
,该文件当中的内容如下:
我们要做的就是,让客户端将
file.txt
文件通过管道发送给服务端,在服务端创建一个file-bat.txt
文件,并将从管道获取到的数据写入file-bat.txt
文件当中,至此便实现了file.txt
文件的拷贝。
其中服务端需要做的就是,创建命名管道并以读的方式打开该命名管道,再创建一个名为
file-bat.txt
的文件,之后需要做的就是将从管道当中读取到的数据写入到file-bat.txt
文件当中即可。//server.c #include "comm.h" int main() { umask(0); //将文件默认掩码设置为0 if (mkfifo(FILE_NAME, 0666) < 0){ //使用mkfifo创建命名管道文件 perror("mkfifo"); return 1; } int fd = open(FILE_NAME, O_RDONLY); //以读的方式打开命名管道文件 if (fd < 0){ perror("open"); return 2; } //创建文件file-bat.txt,并以写的方式打开该文件 int fdout = open("file-bat.txt", O_CREAT | O_WRONLY, 0666); if (fdout < 0){ perror("open"); return 3; } char msg[128]; while (1){ msg[0] = '\0'; //每次读之前将msg清空 //从命名管道当中读取信息 ssize_t s = read(fd, msg, sizeof(msg)-1); if (s > 0){ write(fdout, msg, s); //将读取到的信息写入到file-bat.txt文件当中 } else if (s == 0){ printf("client quit!\n"); break; } else{ printf("read error!\n"); break; } } close(fd); //通信完毕,关闭命名管道文件 close(fdout); //数据写入完毕,关闭file-bat.txt文件 return 0; }
而客户端需要做的就是,以写的方式打开这个已经存在的命名管道文件,再以读的方式打开
file.txt
文件,之后需要做的就是将file.txt
文件当中的数据读取出来并写入管道当中即可。//client.c #include "comm.h" int main() { int fd = open(FILE_NAME, O_WRONLY); //以写的方式打开命名管道文件 if (fd < 0){ perror("open"); return 1; } int fdin = open("file.txt", O_RDONLY); //以读的方式打开file.txt文件 if (fdin < 0){ perror("open"); return 2; } char msg[128]; while (1){ //从file.txt文件当中读取数据 ssize_t s = read(fdin, msg, sizeof(msg)); if (s > 0){ write(fd, msg, s); //将读取到的数据写入到命名管道当中 } else if (s == 0){ printf("read end of file!\n"); break; } else{ printf("read error!\n"); break; } } close(fd); //通信完毕,关闭命名管道文件 close(fdin); //数据读取完毕,关闭file.txt文件 return 0; }
编写完代码后,先运行服务端,再运行客户端,一瞬间这两个进程就相继运行结束了。
此时使用
ll
命令就可以看到,已经完成了file.txt
文件的拷贝
使用cat命令打印
file-bat.txt
文件当中的内容,发现和file.txt
文件当中的内容相同,拷贝文件成功。
因为这里是使用管道在本地进行的文件拷贝,所以看似没什么意义,但我们若是将这里的管道想象成“网络”,将客户端想象成“Windows Xshell”,再将服务端想象成“centos服务器”。那我们此时实现的就是文件上传的功能,若是将方向反过来,那么实现的就是文件下载的功能。
2.3.7、【命名管道和匿名管道的区别】
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,由open函数打开。
- FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在于它们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
我们再来看一下命令行当中的管道到底是哪种管道:
现有
data.txt
文件,文件当中的内容如下:
我们可以利用管道(“|”)同时使用cat命令和grep命令,进而实现文本过滤。
cat data.txt | grep dragon
那么在命令行当中的管道(“|”)到底是匿名管道还是命名管道呢?
由于匿名管道只能用于有亲缘关系的进程之间的通信,而命名管道可以用于两个毫不相关的进程之间的通信,因此我们可以先看看命令行当中用管道(“|”)连接起来的各个进程之间是否具有亲缘关系。
下面通过管道(“|”)连接了三个进程,通过ps命令查看这三个进程可以发现,这三个进程的PPID是相同的,也就是说它们是由同一个父进程创建的子进程。
而它们的父进程实际上就是命令行解释器,这里为
bash
。
也就是说,由管道(“|”)连接起来的各个进程是有亲缘关系的,它们之间互为兄弟进程。
现在我们已经知道了,若是两个进程之间采用的是命名管道,那么在磁盘上必须有一个对应的命名管道文件名,而实际上我们在使用命令的时候并不存在类似的命名管道文件名,因此命令行上的管道实际上是匿名管道。
2.3.8、【利用命名管道创建进程池】
这里的进程池,与我们先前使用匿名管道实现的进程池类似,我们都是期望有一个主进程通过发送任务指令到管道中,而其他进程通过从管道中读取相应的任务指令来完成任务。
第一步:创建管道
程池的第一步,你得有管道呀,所以我们编写一个函数来创建一批命名管道:
#pragma once #include<sstream> #include<unistd.h> #include<fcntl.h> #include<sys/stat.h> //这里我们先创建10个命名管道 #define MY_CHANNEL_NUMBER 10 //管道的基本名字 const std::string base_name = "../my_fifo"; //创建管道 void CreateChannels() { for(int i = 0; i < MY_CHANNEL_NUMBER; i++) { std::stringstream ss; //标准流对象,从而动态地生成每个命名管道(FIFO)的文件名 ss<< base_name <<"_"<< i; //这里是将base_name和i写入到ss字符串流对象中在基本名字之后加 //上序号来给管道标号 std::string name = ss.str();//这里的ss.str就是../my_fifo_i if(mkfifo(name.c_str(),0666) == -1) { perror("create channels fail"); continue; } } }
注:
std::stringstream
在内存中创建了一个字符串流,你可以向其中写入数据(就像写入文件一样),也可以从中读取数据(就像从文件读取一样)。这使得它非常适合于在内存中处理字符串数据,而不需要实际创建文件。我们在主函数中测试一下:
#include"ProcessPool.hpp" int main() { CreateChannels(); }
这个时候有一个问题,如果我们的管道已经创建好了,那么重复创建肯定是不行的,这个时候我们用errno(C/C++ 编程中用于表示系统级错误的一个全局变量)来保存我们的错误代号,如果是管道已经存在,errno会储存错误信息,帮助我们判断:
#pragma once #include<sstream> #include<unistd.h> #include<fcntl.h> #include<sys/stat.h> #include<iostream> #include<cerrno> //这里我们先创建10个命名管道 #define MY_CHANNEL_NUMBER 10 //管道的基本名字 const std::string base_name = "../my_fifo"; //创建管道 void CreateChannels() { for(int i = 0; i < MY_CHANNEL_NUMBER; i++) { std::stringstream ss; //标准流对象 ss<< base_name <<"_"<< i; //在基本名字之后加上序号来给管道标号 std::string name = ss.str(); if(mkfifo(name.c_str(),0666) == -1) { if(errno != EEXIST) { perror("create channels fail"); exit(EXIT_FAILURE); } else { std::cout<<"channel has existed"<<std::endl; } } else { //如果你成功什么都不做 } } }
第二步:批量删除管道
现在我们创建好了管道,我们如果不想要了,我们要把它们删除。所以我们编写一个函数来批量删除管道:
这里我们要先认识一个函数unlink:
int unlink(const char *pathname)
:
- 功能:从磁盘中删除这个命名管道文件。
- 参数:
pathname
:指定你想创建的命名管道文件的路径。- 返回值:创建成功返回0,失败返回-1,并设置
errno
。- 头文件:
<unistd.h>
。void DeletePipes() { for(int i = 0; i < MY_CHANNEL_NUMBER; i++) { std::stringstream ss; //标准流对象 ss<< base_name <<"_"<< i; //在基本名字之后加上序号来给管道标号 std::string name = ss.str(); if(unlink(name.c_str())==-1)//按照管道名来进行删除 { if(errno == ENOENT) //如果管道本身不存在 { std::cout<<name<<"not exits"<<std::endl; } else { perror("unlink fail"); exit(EXIT_FAILURE); } } } }
我们测试一下:
#include"ProcessPool.hpp" int main() { //创建管道 CreateChannels(); //删除管道 DeletePipes(); }
看到管道确实被删除了。
第三步:封装管道,使之容易被管理
不知道大家发现没有,我们在创建管道,删除管道的时候还要自己写管道路径,自己控制管道名:
这样子稍微有点麻烦,既然这样,我们还不如直接把管道管理起来设计,也就是对其进行先描述,在组织,设计为为一个类,然后用容器组织起来,方便我们管理:
#pragma once #include<sstream> #include<unistd.h> #include<fcntl.h> #include<sys/stat.h> #include<iostream> #include<cerrno> #include<vector> //这里我们先创建5个命名管道 #define MY_CHANNEL_NUMBER 10 //管道的基本名字 const std::string base_name = "../my_fifo"; //管道类 struct Channel { Channel(const std::string fifo_name) :fifo_path(fifo_name) { } // 管道路径 std::string fifo_path; }; //创建管道 void CreateChannels(std::vector<Channel>* channels) { for(int i = 0; i < MY_CHANNEL_NUMBER; i++) { std::stringstream ss; //标准流对象 ss<< base_name <<"_"<< i; //在基本名字之后加上序号来给管道标号 //创建Channel Channel channel(ss.str()); //初始化一个chennel(管道) if(mkfifo(channel.fifo_path.c_str(),0666) == -1) { if(errno != EEXIST) { perror("create channels fail"); break; } else { std::cout<<"channel has existed"<<std::endl; channels->push_back(channel); //已经存在也要放进去 } } else { //如果你成功,则添加到vector里 channels->push_back(channel); } } } void DeletePipes(std::vector<Channel>& channels) { for(auto& e : channels) { if(unlink(e.fifo_path.c_str())==-1) { if(errno == ENOENT) //如果管道本身不存在 { std::cout<<e.fifo_path<<"not exits"<<std::endl; } else { perror("unlink fail"); exit(EXIT_FAILURE); } } else { std::cout<<"suecessful delete"<<std::endl; } } }
第四步:模拟进程通信
我们现在有了管道,现在我们模拟一下进程之间的通信,这里我们就用父子进程进行通信,父进程往管道写入命令,子进程从管道当中读取并执行命令:
void DoingWork(const std::vector<Channel>& channels,const std::string work_name) { //选择一个管道进行工作 Channel one = ChooseChannel(channels); //创建子进程 pid_t id = fork(); if(id == 0) { if(access(one.fifo_path.c_str(),F_OK) == -1) { std::cout<<"channel has be deleted"<<std::endl; exit(EXIT_SUCCESS); } else { int rfd = open(one.fifo_path.c_str(),O_RDONLY); if(rfd < 0) { perror("read fail"); exit(EXIT_FAILURE); } else { //子进程从管道当中读数据 char buffer[1024]; ssize_t number = read(rfd,buffer,sizeof(buffer)); if(number > 0) { buffer[number] = '\0'; } //读取之后执行任务 Work(buffer); //任务结束后关闭读端 close(rfd); } } } else if(id > 0) // 父进程 { //以写方式打开 if(access(one.fifo_path.c_str(),F_OK) == -1) { std::cout<<"channel has be deleted"<<std::endl; exit(EXIT_SUCCESS);; } else { int wfd = open(one.fifo_path.c_str(),O_WRONLY); if(wfd < 0) { perror("write fail"); exit(EXIT_FAILURE); } else { //往管道里面写入 //我们用一个vector存放string来模拟任务的场景 ssize_t number = write(wfd,work_name.c_str(),strlen(work_name.c_str())); if(number < 0) { perror("write fail"); exit(EXIT_FAILURE); } //关闭读端 close(wfd); //回收子进程 waitpid(id,nullptr,0); } } } else { perror("fork fail"); exit(EXIT_FAILURE); } }
第五步:选择管道函数和选择任务函数
const Channel& ChooseChannel(const std::vector<Channel>& channels) { // 创建一个随机数引擎 std::random_device rd; // 用于获取高质量的随机数种子 std::mt19937 gen(rd()); // 使用随机设备生成的种子初始化MT19937引擎 // 创建一个均匀分布的整数分布对象 std::uniform_int_distribution<> dis(0, channels.size()-1); // 生成随机数并输出 int number = dis(gen); return channels[number]; } const std::string SeleteWork() { // 创建一个随机数引擎 std::random_device rd; // 用于获取高质量的随机数种子 std::mt19937 gen(rd()); // 使用随机设备生成的种子初始化MT19937引擎 // 创建一个均匀分布的整数分布对象 std::uniform_int_distribution<> dis(0, task_list.size()-1); // 生成随机数并输出 int number = dis(gen); return task_list[number]; }
这里为了简便,我使用了vector储存字符串,来模拟任务列表,SeleteWork负责从中挑选工作:
//用来模拟任务 std::vector<std::string> task_list = {"dowload","movie","music","games","learning"};
整体代码:
processpool.hpp:
#pragma once #include<sstream> #include<unistd.h> #include<fcntl.h> #include<sys/stat.h> #include<iostream> #include<cerrno> #include<vector> #include<sys/wait.h> #include<random> #include<cstring> #include<ctime> //这里我们先创建5个命名管道 int MY_CHANNEL_NUMBER = 10; //用来模拟任务 std::vector<std::string> task_list = {"dowload","movie","music","games","learning"}; //管道的基本名字 const std::string base_name = "../my_fifo"; //管道类 struct Channel { Channel(const std::string fifo_name) :fifo_path(fifo_name) { } // 管道路径 std::string fifo_path; }; //创建管道 void CreateChannels(std::vector<Channel>* channels) { for(int i = 0; i < MY_CHANNEL_NUMBER; i++) { std::stringstream ss; //标准流对象 ss<< base_name <<"_"<< i; //在基本名字之后加上序号来给管道标号 //创建Channel Channel channel(ss.str()); //初始化一个chennel(管道) if(mkfifo(channel.fifo_path.c_str(),0666) == -1) { if(errno != EEXIST) { perror("create channels fail"); break; } else { std::cout<<"channel has existed"<<std::endl; channels->push_back(channel); //已经存在也要放进去 } } else { //如果你成功,则添加到vector里 channels->push_back(channel); } } } void DeletePipes(std::vector<Channel>& channels) { for(auto& e : channels) { if(unlink(e.fifo_path.c_str())==-1) { if(errno == ENOENT) //如果管道本身不存在 { std::cout<<e.fifo_path<<" not exits"<<std::endl; } else { perror("unlink fail"); exit(EXIT_FAILURE); } } else { std::cout<<"suecessful delete"<<std::endl; MY_CHANNEL_NUMBER--; if(MY_CHANNEL_NUMBER == 0) { std::cout<<"channels have cleared"<<std::endl; exit(EXIT_SUCCESS); } } } } const Channel& ChooseChannel(const std::vector<Channel>& channels) { // 创建一个随机数引擎 std::random_device rd; // 用于获取高质量的随机数种子 std::mt19937 gen(rd()); // 使用随机设备生成的种子初始化MT19937引擎 // 创建一个均匀分布的整数分布对象 std::uniform_int_distribution<> dis(0, channels.size()-1); // 生成随机数并输出 int number = dis(gen); return channels[number]; } void Work(const char* buffer) { std::cout<<"doing "<<buffer<<std::endl; } const std::string SeleteWork() { // 创建一个随机数引擎 std::random_device rd; // 用于获取高质量的随机数种子 std::mt19937 gen(rd()); // 使用随机设备生成的种子初始化MT19937引擎 // 创建一个均匀分布的整数分布对象 std::uniform_int_distribution<> dis(0, task_list.size()-1); // 生成随机数并输出 int number = dis(gen); return task_list[number]; } void DoingWork(const std::vector<Channel>& channels,const std::string work_name) { //选择一个管道进行工作 Channel one = ChooseChannel(channels); //创建子进程 pid_t id = fork(); if(id == 0) { if(access(one.fifo_path.c_str(),F_OK) == -1) { std::cout<<"channel has be deleted"<<std::endl; exit(EXIT_SUCCESS); } else { int rfd = open(one.fifo_path.c_str(),O_RDONLY); //判断管道是否存在,不存在则退出,存在则打开 if(rfd < 0) { perror("read fail"); exit(EXIT_FAILURE); } else { //子进程从管道当中读数据 char buffer[1024]; ssize_t number = read(rfd,buffer,sizeof(buffer)); if(number > 0) { buffer[number] = '\0'; } //读取之后执行任务 Work(buffer); //任务结束后关闭读端 close(rfd); } } } else if(id > 0) // 父进程 { //以写方式打开 if(access(one.fifo_path.c_str(),F_OK) == -1) { std::cout<<"channel has be deleted"<<std::endl; exit(EXIT_SUCCESS);; } else { int wfd = open(one.fifo_path.c_str(),O_WRONLY); if(wfd < 0) { perror("write fail"); exit(EXIT_FAILURE); } else { //往管道里面写入 //我们用一个vector存放string来模拟任务的场景 ssize_t number = write(wfd,work_name.c_str(),strlen(work_name.c_str())); if(number < 0) { perror("write fail"); exit(EXIT_FAILURE); } //关闭读端 close(wfd); //回收子进程 waitpid(id,nullptr,0); } } } else { perror("fork fail"); exit(EXIT_FAILURE); } }
main.cpp:
#include"ProcessPool.hpp" int main() { srand(time(0)); std::vector<Channel> channels; //创建管道 CreateChannels(&channels); //进行交互 for(int i = 0; i < 10; i++) { std::string work = SeleteWork(); DoingWork(channels,work); } //删除管道 DeletePipes(channels); }
结果:
最后一步:实现类的封装
最后,我们这些过程可以都包含在主程序类中,封装所有函数,方便维护:
class MainProcess //主程序类 { public: private: std::vector<Channel> channels; };
公共接口放我们所有的函数(除了work函数):
#pragma once #include<sstream> #include<unistd.h> #include<fcntl.h> #include<sys/stat.h> #include<iostream> #include<cerrno> #include<vector> #include<sys/wait.h> #include<random> #include<cstring> #include<ctime> //这里我们先创建5个命名管道 int MY_CHANNEL_NUMBER = 10; //用来模拟任务 std::vector<std::string> task_list = {"dowload","movie","music","games","learning"}; //管道的基本名字 const std::string base_name = "../my_fifo"; //管道类 struct Channel { Channel(const std::string fifo_name) :fifo_path(fifo_name) { } // 管道路径 std::string fifo_path; }; void Work(const char* buffer) { std::cout<<"doing "<<buffer<<std::endl; } class MainProcess //主程序类 { public: //创建管道 void CreateChannels() { for(int i = 0; i < MY_CHANNEL_NUMBER; i++) { std::stringstream ss; //标准流对象 ss<< base_name <<"_"<< i; //在基本名字之后加上序号来给管道标号 //创建Channel Channel channel(ss.str()); //初始化一个chennel(管道) if(mkfifo(channel.fifo_path.c_str(),0666) == -1) { if(errno != EEXIST) { perror("create channels fail"); break; } else { std::cout<<"channel has existed"<<std::endl; channels.push_back(channel); //已经存在也要放进去 } } else { //如果你成功,则添加到vector里 channels.push_back(channel); } } } void DeletePipes() { for(auto& e : channels) { if(unlink(e.fifo_path.c_str())==-1) { if(errno == ENOENT) //如果管道本身不存在 { std::cout<<e.fifo_path<<" not exits"<<std::endl; } else { perror("unlink fail"); exit(EXIT_FAILURE); } } else { std::cout<<"suecessful delete"<<std::endl; MY_CHANNEL_NUMBER--; if(MY_CHANNEL_NUMBER == 0) { std::cout<<"channels have cleared"<<std::endl; exit(EXIT_SUCCESS); } } } } const Channel& ChooseChannel() { // 创建一个随机数引擎 std::random_device rd; // 用于获取高质量的随机数种子 std::mt19937 gen(rd()); // 使用随机设备生成的种子初始化MT19937引擎 // 创建一个均匀分布的整数分布对象 std::uniform_int_distribution<> dis(0, channels.size()-1); // 生成随机数并输出 int number = dis(gen); return channels[number]; } const std::string SeleteWork() { // 创建一个随机数引擎 std::random_device rd; // 用于获取高质量的随机数种子 std::mt19937 gen(rd()); // 使用随机设备生成的种子初始化MT19937引擎 // 创建一个均匀分布的整数分布对象 std::uniform_int_distribution<> dis(0, task_list.size()-1); // 生成随机数并输出 int number = dis(gen); return task_list[number]; } void DoingWork(const std::string work_name) { //选择一个管道进行工作 Channel one = ChooseChannel(); //创建子进程 pid_t id = fork(); if(id == 0) { if(access(one.fifo_path.c_str(),F_OK) == -1) //判断管道是否存在,不存在则退出,存在则打开 { std::cout<<"channel has be deleted"<<std::endl; exit(EXIT_SUCCESS); } else { int rfd = open(one.fifo_path.c_str(),O_RDONLY); if(rfd < 0) { perror("read fail"); exit(EXIT_FAILURE); } else { //子进程从管道当中读数据 char buffer[1024]; ssize_t number = read(rfd,buffer,sizeof(buffer)); if(number > 0) { buffer[number] = '\0'; } //读取之后执行任务 Work(buffer); //任务结束后关闭读端 close(rfd); } } } else if(id > 0) // 父进程 { //以写方式打开 if(access(one.fifo_path.c_str(),F_OK) == -1) { std::cout<<"channel has be deleted"<<std::endl; exit(EXIT_SUCCESS);; } else { int wfd = open(one.fifo_path.c_str(),O_WRONLY); if(wfd < 0) { perror("write fail"); exit(EXIT_FAILURE); } else { //往管道里面写入 //我们用一个vector存放string来模拟任务的场景 ssize_t number = write(wfd,work_name.c_str(),strlen(work_name.c_str())); if(number < 0) { perror("write fail"); exit(EXIT_FAILURE); } //关闭读端 close(wfd); //回收子进程 waitpid(id,nullptr,0); } } } else { perror("fork fail"); exit(EXIT_FAILURE); } } private: std::vector<Channel> channels; };
主函数:
#include"ProcessPool.hpp" int main() { srand(time(0)); //std::vector<Channel> channels; //创建管道 //CreateChannels(&channels); MainProcess mainProcess; mainProcess.CreateChannels(); //进行交互 for(int i = 0; i < 10; i++) { std::string work = mainProcess.SeleteWork(); mainProcess.DoingWork(work); } //删除管道 mainProcess.DeletePipes(); }
结果:
三、【system V进程间通信】
管道通信本质是基于文件的,也就是说操作系统并没有为此做过多的设计工作,而system V IPC是操作系统特地设计的一种通信方式。但是不管怎么样,它们的本质都是一样的,都是在想尽办法让不同的进程看到同一份由操作系统提供的资源。
system V IPC提供的通信方式有以下三种:
- system V共享内存
- system V消息队列
- system V信号量
其中,system V共享内存和system V消息队列是以传送数据为目的的,而system V信号量是为了保证进程间的同步与互斥而设计的,虽然system V信号量和通信好像没有直接关系,但属于通信范畴。
说明一下:
system V共享内存和system V消息队列就类似于手机,用于沟通信息;system V信号量就类似于下棋比赛时用的棋钟,用于保证两个棋手之间的同步与互斥。3.1、【system V共享内存】
3.1.1、【共享内存的基本原理】
共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。
注意:
这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。3.1.2、【共享内存数据结构】
在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。
共享内存的数据结构如下:
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 */ };
当我们申请了一块共享内存后,为了让要实现通信的进程能够看到同一个共享内存,因此每一个共享内存被申请时都有一个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; };
记录一下:
共享内存的数据结构shmid_ds
和ipc_perm
结构体分别在/usr/include/linux/shm.h和/usr/include/linux/ipc.h中定义。3.1.3、【共享内存的建立】
共享内存的建立大致包括以下两个过程:
- 在物理内存当中申请共享内存空间。
- 将申请到的共享内存挂接到地址空间,即建立映射关系。
1、【shmget函数创建共享内存】
创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
int shmget(key_t key, size_t size, int shmflg);
shmget函数的参数说明:
- 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
- 第二个参数size,表示待创建共享内存的大小。
- 第三个参数shmflg,表示创建共享内存的方式。
shmget函数的返回值说明:
- shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
- shmget调用失败,返回-1。
注意: 我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作。
大小我们可以根据我们的需求直接进行指定,但是我们应该如何获得key呢?
传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取,ftork函数如下:
key_t ftok(const char *pathname, int proj_id);
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值,称为IPC键值,在使用shmget函数获取共享内存时,这个key值会被填充进维护共享内存的数据结构当中。需要注意的是,pathname所指定的文件必须存在且可存取。
注意:
- 使用ftok函数生成key值可能会产生冲突,此时可以对传入ftok函数的参数进行修改。
- 需要进行通信的各个进程,在使用ftok函数获取key值时,都需要采用同样的路径名和和整数标识符,进而生成同一种key值,然后才能找到同一个共享资源。
现在我们有了前两个参数,那么对于传入shmget函数的第三个参数shmflg,常用的组合方式有以下两种:
换句话说:
- 使用组合IPC_CREAT,一定会获得一个共享内存的句柄,但无法确认该共享内存是否是新建的共享内存。
- 使用组合IPC_CREAT | IPC_EXCL,只有shmget函数调用成功时才会获得共享内存的句柄,并且该共享内存一定是新建的共享内存。
至此我们就可以使用ftok和shmget函数创建一块共享内存了,创建后我们可以将共享内存的key值和句柄进行打印,以便观察,代码如下:
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名 #define PROJ_ID 0x6666 //整数标识符 #define SIZE 4096 //共享内存的大小 int main() { key_t key = ftok(PATHNAME, PROJ_ID); //获取key值 if (key < 0){ perror("ftok"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存 if (shm < 0){ perror("shmget"); return 2; } printf("key: %x\n", key); //打印key值 printf("shm: %d\n", shm); //打印句柄 return 0; }
该代码编写完毕运行后,我们可以看到输出的key值和句柄值:
2、【ipcs命令】
Linux当中,我们可以使用
ipcs
命令查看有关进程间通信设施的信息。
单独使用
ipcs
命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:
- -q:列出消息队列相关信息。
- -m:列出共享内存相关信息。
- -s:列出信号量相关信息。
例如,携带-m选项查看共享内存相关信息:
此时,根据
ipcs
命令的查看结果和我们的输出结果可以确认,共享内存已经创建成功了。
ipcs
命令输出的每列信息的含义如下:
注意: key是在内核层面上保证共享内存唯一性的方式,而shmid是在用户层面上保证共享内存的唯一性,key和shmid之间的关系类似于fd和FILE*之间的的关系。
3、【shmat关联共享内存】
将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmat函数的参数说明:
- 第一个参数shmid,表示待关联共享内存的用户级标识符。
- 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
- 第三个参数shmflg,表示关联共享内存时设置的某些属性。
shmat函数的返回值说明:
- shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
- shmat调用失败,返回(void*)-1。
其中,作为shmat函数的第三个参数传入的常用的选项有以下三个:
这时我们可以尝试使用shmat函数对共享内存进行关联。
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名 #define PROJ_ID 0x6666 //整数标识符 #define SIZE 4096 //共享内存的大小 int main() { key_t key = ftok(PATHNAME, PROJ_ID); //获取key值 if (key < 0){ perror("ftok"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存 if (shm < 0){ perror("shmget"); return 2; } printf("key: %x\n", key); //打印key值 printf("shm: %d\n", shm); //打印句柄 printf("attach begin!\n"); sleep(2); char* mem = shmat(shm, NULL, 0); //关联共享内存 if (mem == (void*)-1){ perror("shmat"); return 1; } printf("attach end!\n"); sleep(2); shmctl(shm, IPC_RMID, NULL); //释放共享内存 return 0; }
代码运行后发现关联失败,主要原因是我们使用shmget函数创建共享内存时,并没有对创建的共享内存设置权限,所以创建出来的共享内存的默认权限为0,即什么权限都没有,因此server进程没有权限关联该共享内存。
我们应该在使用shmget函数创建共享内存时,在其第三个参数处设置共享内存创建后的权限,权限的设置规则与设置文件权限的规则相同。
int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建权限为0666的共享内存
此时再运行程序,即可发现关联该共享内存的进程数由0变成了1,而共享内存的权限显示也不再是0,而是我们设置的666权限。
3.1.4、【共享内存的释放】
共享内存的释放大致包括以下两个过程:
- 将共享内存与地址空间去关联,即取消映射关系。
- 释放共享内存空间,即将物理内存归还给系统。
1、【shmdt函数去关联】
取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:
int shmdt(const void *shmaddr);
shmdt函数的参数说明:
- 待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。
shmdt函数的返回值说明:
- shmdt调用成功,返回0。
- shmdt调用失败,返回-1。
现在我们就能够取消共享内存与进程之间的关联了
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名 #define PROJ_ID 0x6666 //整数标识符 #define SIZE 4096 //共享内存的大小 int main() { key_t key = ftok(PATHNAME, PROJ_ID); //获取key值 if (key < 0){ perror("ftok"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存 if (shm < 0){ perror("shmget"); return 2; } printf("key: %x\n", key); //打印key值 printf("shm: %d\n", shm); //打印句柄 printf("attach begin!\n"); sleep(2); char* mem = shmat(shm, NULL, 0); //关联共享内存 if (mem == (void*)-1){ perror("shmat"); return 1; } printf("attach end!\n"); sleep(2); printf("detach begin!\n"); sleep(2); shmdt(mem); //共享内存去关联 printf("detach end!\n"); sleep(2); shmctl(shm, IPC_RMID, NULL); //释放共享内存 return 0; }
运行程序,通过监控即可发现该共享内存的关联数由1变为0的过程,即取消了共享内存与该进程之间的关联。
注意: 将共享内存段与当前进程脱离不等于删除共享内存,只是取消了当前进程与该共享内存之间的联系。
通过上面创建共享内存的实验可以发现,当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。实际上,管道是生命周期是随进程的,而共享内存的生命周期是随内核的,也就是说进程虽然已经退出,但是曾经创建的共享内存不会随着进程的退出而释放。
这说明,即使我们将创建的共享内存去关联了,如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由内核提供并维护的。
此时我们若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放。
2、【使用ipcrm -m shmid释放共享内存资源】
我们可以使用
ipcrm -m shmid
命令释放指定id的共享内存资源。ipcrm -m 8
注意: 指定删除时使用的是共享内存的用户层id,即列表当中的shmid。
3、【shmctl函数释放共享内存】
控制共享内存我们需要用shmctl函数,shmctl函数的函数原型如下:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmctl函数的参数说明:
- 第一个参数shmid,表示所控制共享内存的用户级标识符。
- 第二个参数cmd,表示具体的控制动作。
- 第三个参数buf,用于获取或设置所控制共享内存的数据结构。
shmctl函数的返回值说明:
- shmctl调用成功,返回0。
- shmctl调用失败,返回-1。
其中,作为shmctl函数的第二个参数传入的常用的选项有以下三个:
例如,在以下代码当中,共享内存被创建,两秒后程序自动移除共享内存,再过两秒程序就会自动退出。
#include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名 #define PROJ_ID 0x6666 //整数标识符 #define SIZE 4096 //共享内存的大小 int main() { key_t key = ftok(PATHNAME, PROJ_ID); //获取key值 if (key < 0){ perror("ftok"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL); //创建新的共享内存 if (shm < 0){ perror("shmget"); return 2; } printf("key: %x\n", key); //打印key值 printf("shm: %d\n", shm); //打印句柄 sleep(2); shmctl(shm, IPC_RMID, NULL); //释放共享内存 sleep(2); return 0; }
我们可以在程序运行时,使用以下监控脚本时刻关注共享内存的资源分配情况:
while :; do ipcs -m;echo "###################################";sleep 1;done
通过监控脚本可以确定共享内存确实创建并且成功释放了。
3.1.5、【利用共享内存实现进程间通信】
在知道了共享内存的创建、关联、去关联以及释放后,我们现在可以尝试让两个进程通过共享内存进行通信了。
1、【实现serve&client通信】
在让两个进程进行通信之前,我们可以先测试一下这两个进程能否成功挂接到同一个共享内存上。这里我们让服务端负责创建共享内存,创建好后将共享内存和服务端进行关联,之后读取共享内存中的数据。
服务端代码如下:
//server.c #include "comm.h" int main() { key_t key = ftok(PATHNAME, PROJ_ID); //获取key值 if (key < 0){ perror("ftok"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT | IPC_EXCL | 0666); //创建新的共享内存 if (shm < 0){ perror("shmget"); return 2; } printf("key: %x\n", key); //打印key值 printf("shm: %d\n", shm); //打印共享内存用户层id char* mem = shmat(shm, NULL, 0); //关联共享内存 while (1) { //服务端不断读取共享内存当中的数据并输出 printf("client# %s\n", mem); sleep(1); } shmdt(mem); //共享内存去关联 shmctl(shm, IPC_RMID, NULL); //释放共享内存 return 0; }
客户端只需要直接和服务端创建的共享内存进行关联即可,之后向共享内存中输入数据。
客户端代码如下:
//client.c #include "comm.h" int main() { key_t key = ftok(PATHNAME, PROJ_ID); //获取与server进程相同的key值 if (key < 0){ perror("ftok"); return 1; } int shm = shmget(key, SIZE, IPC_CREAT); //获取server进程创建的共享内存的用户层id if (shm < 0){ perror("shmget"); return 2; } printf("key: %x\n", key); //打印key值 printf("shm: %d\n", shm); //打印共享内存用户层id char* mem = shmat(shm, NULL, 0); //关联共享内存 int i = 0; while (1) { //服务端不断读取共享内存当中的数据并输出 mem[i] = 'A' + i; i++; mem[i] = '\0'; sleep(1); } shmdt(mem); //共享内存去关联 return 0; }
为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。
共用头文件的代码如下:
//comm.h #include <stdio.h> #include <stdio.h> #include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> #include <unistd.h> #define PATHNAME "/home/cl/Linuxcode/IPC/shm/server.c" //路径名 #define PROJ_ID 0x6666 //整数标识符 #define SIZE 4096 //共享内存的大小
此时我们就可以让服务端和客户端进行通信了,先运行服务端创建共享内存,当我们运行客户端时服务端就开始不断输出数据,说明服务端和客户端是能够正常通信的。
这里确实让两个毫不相干的进程通信起来了,下面让我们来看一个问题。
2、【解决共享内存无法同步的问题】
我们都知道,正常我们通过key和shmget函数能够直接创建一块共享内存,然后通过shmat函数能够将创建好的共享内存挂接到两个进程上,让这两个进程能够看到同一份资源,接着我们让其中一个进程往共享内存中写数据,另一个进程从共享内存中读数据,这样就完成了进程间的通信,最后我们让这两个进程各自将共享内存去关联,最后将共享内存释放,这样就完成了,但是我们看看下面这个场景:
观察运行结果,因为我们设置sleep的原因,写入比较慢,读取比较快,这里我们看到共享内存并不和管道一样,读取会同步,共享内存的通讯读取并不同步。
但是这样有可能读取数据不完整,人家可能还没发完消息,你这边读了一半就直接走了,这样肯定不好,我们可以使用管道的同步机制,server通过管道给client发送消息,说你可以读取了再让client进行读取,具体说就是,我们再创建共享内存后,再创建一个管道,让client再向共享内存中写入数据后,向管道中也写入数据,最后server再从共享内存中读取数据之前,需要先从管道中读取,这样就可以完美解决共享内存不同步的问题了。
运行结果发现,成功读写同步,虽然sleep(3)和sleep(1),但在管道的作用下,依然同步了 :
最后再做一下封装, 代码如下:
comm.hpp:
#pragma once #include <iostream> #include <string> #include <cstring> #include <cstdlib> #include <sys/types.h> #include <sys/ipc.h>//Inter-Process Communication #include <sys/shm.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> using namespace std; const int size = 4096; const string pathname = "/home/kky/centos_test/109/240320_systemV_sharedMemory"; const int proj_id = 0x12345678; key_t GetKey() { key_t key = ftok(pathname.c_str(),proj_id); if(key<0) { cerr<<"错误码:"<<errno<<",错误信息:"<< strerror(errno)<<endl; exit(1); } return key; } int _CreateShm(key_t key,int flag) { int shmid = shmget(key, size, flag); if (shmid < 0) { cerr << "错误码:" << errno << ",错误信息:" << strerror(errno) << endl; return 2; } } int CreateShm(key_t key) { return _CreateShm(key, IPC_CREAT|IPC_EXCL|0664); } int GetShm(key_t key) { return _CreateShm(key, IPC_CREAT); } string ToHex(int id) { char buff[1024]; snprintf(buff,sizeof(buff),"0x%x",id); return buff; } int Creatpipe() { //创建管道 int n = mkfifo("fifo",0666); if(n<0) { cerr << "错误码:" << errno << ",错误信息:" << strerror(errno) << endl; exit(3); } }
server.cc:
#include "comm.hpp" using namespace std; void Communitation(char* s,int fd) { while(true) { int code = 0; ssize_t r = read(fd,&code,sizeof(code)); if(s>0) { cout<<"共享内存中的内容"<<s<<endl; sleep(1); } else if(s==0) { break; } } } int main() { Creatpipe(); //获取key key_t key = GetKey(); cout << "key:" << ToHex(key) << endl; //创建共享内存 int shmid = CreateShm(key); cout<<"shmid:"<<shmid<<endl; //将共享内存挂载到虚拟内存中间 char* s = (char*)shmat(shmid,nullptr,0); cout<<"shm挂载"<<endl; int fd = open("fifo",O_RDONLY); //这里通信 Communitation(s,fd); //将shm从进程地址空间移除 //sleep(5); shmdt(s); cout<<"将shm从进程地址空间移除"<<endl; //从操作系统移除shm //sleep(5); shmctl(shmid,IPC_RMID,nullptr); cout<<"从操作系统移除shm"<<endl; }
client.cc:
#include "comm.hpp" void Communication(char* s,int fd) { // 通信 char c = 'a'; while (c < 'z') { s[c - 'a'] = c; cout << "写入了:" << c << endl; c++; sleep(3); int code = 1; write(fd,&code,sizeof(code)); } } int main() { key_t key = GetKey(); int shmid = GetShm(key); char *s = (char *)shmat(shmid, nullptr, 0); int fd = open("fifo",O_WRONLY); // 通信 Communication(s,fd); //关闭链接 shmdt(s); }
Makefile:
.PHONY:all all:client server server:server.cc g++ -o $@ $^ -std=c++11 client:client.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f server client fifo
3、【共享内存与管道进行对比】
当共享内存创建好后就不再需要调用系统接口进行通信了,而管道创建好后仍需要read、write等系统接口进行通信。实际上,共享内存是所有进程间通信方式中最快的一种通信方式。
我们先来看看管道通信:
从这张图可以看出,使用管道通信的方式,将一个文件从一个进程传输到另一个进程需要进行四次拷贝操作:
- 服务端将信息从输入文件复制到服务端的临时缓冲区中。
- 将服务端临时缓冲区的信息复制到管道中。
- 客户端将信息从管道复制到客户端的缓冲区中。
- 将客户端临时缓冲区的信息复制到输出文件中。
我们再来看看共享内存通信:
从这张图可以看出,使用共享内存进行通信,将一个文件从一个进程传输到另一个进程只需要进行两次拷贝操作:
- 从输入文件到共享内存。
- 从共享内存到输出文件。
所以共享内存是所有进程间通信方式中最快的一种通信方式,因为该通信方式需要进行的拷贝次数最少。
但是共享内存也是有缺点的,我们知道管道是自带同步与互斥机制的,但是共享内存并没有提供任何的保护机制,包括同步与互斥。
3.2、【System V消息队列】
1、【消息队列的基本原理】
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。
其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。
总结一下:
- 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
- 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
- 和共享内存一样,消息队列的资源也必须自行删除,否则不会自动清除,因为system V IPC资源的生命周期是随内核的。
2、【消息队列数据结构】
当然,系统当中也可能会存在大量的消息队列,系统一定也要为消息队列维护相关的内核数据结构。
消息队列的数据结构如下:
struct msqid_ds { struct ipc_perm msg_perm; struct msg *msg_first; /* first message on queue,unused */ struct msg *msg_last; /* last message in queue,unused */ __kernel_time_t msg_stime; /* last msgsnd time */ __kernel_time_t msg_rtime; /* last msgrcv time */ __kernel_time_t msg_ctime; /* last change time */ unsigned long msg_lcbytes; /* Reuse junk fields for 32 bit */ unsigned long msg_lqbytes; /* ditto */ unsigned short msg_cbytes; /* current number of bytes on queue */ unsigned short msg_qnum; /* number of messages in queue */ unsigned short msg_qbytes; /* max number of bytes on queue */ __kernel_ipc_pid_t msg_lspid; /* pid of last msgsnd */ __kernel_ipc_pid_t msg_lrpid; /* last receive pid */ };
可以看到消息队列数据结构的第一个成员是
msg_perm
,它和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; };
记录一下:
共享内存的数据结构msqid_ds
和ipc_perm
结构体分别在/usr/include/linux/msg.h和/usr/include/linux/ipc.h中定义。3、【消息队列的创建】
创建消息队列我们需要用msgget函数,msgget函数的函数原型如下:
int msgget(key_t key, int msgflg);
说明一下:
- 创建消息队列也需要使用ftok函数生成一个key值,这个key值作为msgget函数的第一个参数。
- msgget函数的第二个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 消息队列创建成功时,msgget函数返回的一个有效的消息队列标识符(用户层标识符)。
4、【消息队列的释放】
释放消息队列我们需要用msgctl函数,msgctl函数的函数原型如下:
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
说明一下:
msgctl函数的参数与释放共享内存时使用的shmctl函数的三个参数相同,只不过msgctl函数的第三个参数传入的是消息队列的相关数据结构。5、【向消息队列发送数据】
向消息队列发送数据我们需要用msgsnd函数,msgsnd函数的函数原型如下:
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msgsnd函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示待发送的数据块。
- 第三个参数msgsz,表示所发送数据块的大小
- 第四个参数msgflg,表示发送数据块的方式,一般默认为0即可。
msgsnd函数的返回值说明:
- msgsnd调用成功,返回0。
- msgsnd调用失败,返回-1。
其中msgsnd函数的第二个参数必须为以下结构:
struct msgbuf{ long mtype; /* message type, must be > 0 */ char mtext[1]; /* message data */ };
注意: 该结构当中的第二个成员mtext即为待发送的信息,当我们定义该结构时,mtext的大小可以自己指定。
6、【从消息队列获取数据】
从消息队列获取数据我们需要用msgrcv函数,msgrcv函数的函数原型如下:
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
msgrcv函数的参数说明:
- 第一个参数msqid,表示消息队列的用户级标识符。
- 第二个参数msgp,表示获取到的数据块,是一个输出型参数。
- 第三个参数msgsz,表示要获取数据块的大小
- 第四个参数msgtyp,表示要接收数据块的类型。
msgrcv函数的返回值说明:
- msgsnd调用成功,返回实际获取到mtext数组中的字节数。
- msgsnd调用失败,返回-1。
3.3、【System V信号量】
1、【信号量相关概念】
- 由于进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系叫做进程互斥。
- 系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
- 在进程中涉及到临界资源的程序段叫临界区。
- IPC资源必须删除,否则不会自动删除,因为system V IPC的生命周期随内核。
2、【信号量数据结构】
在系统当中也为信号量维护了相关的内核数据结构。
信号量的数据结构如下:
struct semid_ds { struct ipc_perm sem_perm; /* permissions .. see ipc.h */ __kernel_time_t sem_otime; /* last semop time */ __kernel_time_t sem_ctime; /* last change time */ struct sem *sem_base; /* ptr to first semaphore in array */ struct sem_queue *sem_pending; /* pending operations to be processed */ struct sem_queue **sem_pending_last; /* last pending operation */ struct sem_undo *undo; /* undo requests on this array */ unsigned short sem_nsems; /* no. of semaphores in array */ };
信号量数据结构的第一个成员也是
ipc_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; };
记录一下:
共享内存的数据结构msqid_ds
和ipc_perm
结构体分别在/usr/include/linux/sem.h和/usr/include/linux/ipc.h中定义。3、【信号量相关函数】
信号量集的创建
创建信号量集我们需要用semget函数,semget函数的函数原型如下:
int semget(key_t key, int nsems, int semflg);
说明一下:
- 创建信号量集也需要使用ftok函数生成一个key值,这个key值作为semget函数的第一个参数。
- semget函数的第二个参数nsems,表示创建信号量的个数。
- semget函数的第三个参数,与创建共享内存时使用的shmget函数的第三个参数相同。
- 信号量集创建成功时,semget函数返回的一个有效的信号量集标识符(用户层标识符)。
信号量集的删除
删除信号量集我们需要用semctl函数,semctl函数的函数原型如下:
int semctl(int semid, int semnum, int cmd, ...);
信号量集的操作
对信号量集进行操作我们需要用semop函数,semop函数的函数原型如下:
int semop(int semid, struct sembuf *sops, unsigned nsops);
4、【进程互斥】
进程间通信通过共享资源来实现,这虽然解决了通信的问题,但是也引入了新的问题,那就是通信进程间共用的临界资源,若是不对临界资源进行保护,就可能产生各个进程从临界资源获取的数据不一致等问题。保护临界资源的本质是保护临界区,我们把进程代码中访问临界资源的代码称之为临界区,信号量就是用来保护临界区的,信号量分为二元信号量和多元信号量。
比如当前有一块大小为100字节的资源,我们若是一25字节为一份,那么该资源可以被分为4份,那么此时这块资源可以由4个信号量进行标识。
信号量本质是一个计数器,在二元信号量中,信号量的个数为1(相当于将临界资源看成一整块),二元信号量本质解决了临界资源的互斥问题,以下面的伪代码进行解释:
根据以上代码,当进程A申请访问共享内存资源时,如果此时sem为1(sem代表当前信号量个数),则进程A申请资源成功,此时需要将sem减减,然后进程A就可以对共享内存进行一系列操作,但是在进程A在访问共享内存时,若是进程B申请访问该共享内存资源,此时sem就为0了,那么这时进程B会被挂起,直到进程A访问共享内存结束后将sem加加,此时才会将进程B唤起,然后进程B再对该共享内存进行访问操作。
在这种情况下,无论什么时候都只会有一个进程在对同一份共享内存进行访问操作,也就解决了临界资源的互斥问题。
实际上,代码中计数器sem减减的操作就叫做P操作,而计数器加加的操作就叫做V操作,P操作就是申请信号量,而V操作就是释放信号量。
5、【system V IPC联系】
通过对system V系列进程间通信的学习,可以发现共享内存、消息队列以及信号量,虽然它们内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量。这样设计的好处就是,在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。
也就是说,在内核当中只需要将所有的IPC资源的ipc_perm成员组织成数组的样子,然后用切片的方式获取到该IPC资源的起始地址,然后我们通过将结构体第一个成员的地址就可以访问到该结构体,再通过将起始地址强转对应结构体类型就可以访问该IPC资源的每一个成员了。
总结
本篇博客到这里就结束了,感谢观看!
..........................................................................................................在你的世界,我濒临灭绝
————《雪豹》