赞
踩
目录
hello,大家好呀。今天我们来学习关于进程间通信的内容,我们知道操作系统中会同时存在多个进程,这些进程有可能会共同完成一个任务,所以就需要通信。本文将讲解几种常见的进程间通信的方式。相信大家已经迫不及待的想要学习了,那我们就开始啦!
本节重点:进程间通信介绍,管道,消息队列,共享内存,信号量
- 数据传输:数据一个进程需要将它的数据发送给另一个进程数据传输:
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另个进程的所有陷入和异常,并能够及时知道它的状态改变
其中,重要的目的是:数据传输,通知事件,进程控制。
在没有正式接触通信之前,我们就应该知道:进程具有独立性,今天我们需要完成进程间相互通信,成本一定不低。
进程间通信的场景应该是这样的:一个进程将数据放入一块固定的区域中,另外一个进程从这块区域中读取数据。所以这块区域对通信双方来说,应该是一个公共资源。所以这块区域一定不能让两个进程提供(无论是哪个进程提供,都不会让另一个进程看到,因为进程具有独立性);这块区域只能由操作系统提供。
所以,通信的本质是什么?
- 操作系统必须直接或者间接的为通信双方提供可以交换数据的"内存空间"。
- 要通信的进程,必须看到一份公共资源。
- 不同的通信种类:本质就是上面的公共资源是操作系统的哪一个模块提供的。
总结来说,要完成通信,必须做好两件事情:
根据操作系统给我们提供的公共资源属于操作系统中的哪一部分,前辈大佬们设计出了不同的通信方式。
管道通信:由文件系统提供
System V IPC:聚焦在本机通信
POSIX IPC:实现跨主机通信
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道。例如我们在命令行中的“|”。
匿名管道是基于文件系统来实现的。
我们在学习文件系统时学到:一个进程会默认打开3个文件描述符,0号指向标准输入流,1号指向标准输出流,2号指向标准错误流。我们使用的文件描述符一般从3号开始。
如果对一个文件分别以“r”和“w”的形式打开,操作系统会分别为其分配不同的文件描述符来指向这个文件。一个文件仅有一个缓冲区。所以不管是对该文件进行读还是写操作,数据都会经过该缓冲区。
我们使用fork函数创建子进程时,操作系统会为子进程拷贝一份父进程的PCB,程序地址空间,列表等等。
但是父子进程会共用一个文件描述符数组吗? 不会,因为父子进程可能会打开不同的文件,为了确保独立性,并让父进程可以操作文件,操作系统会为子进程创建一个独立的文件描述符数组。
如果父子进程同时打开一个文件,这个文件就可以当做父子进程双方的共享资源,如果父子进程想要通信的话,就可以利用该文件进行通信(因为这个文件对父子进程来说都是可见的区域)。父子进程分别以读的方式和写的方式打开这个文件。一个进程向这个文件缓冲区中写入,另一个进程就可以从这个文件缓冲区中读取数据。这就是匿名管道的实现原理。采取匿名管道的方式通信利用的公共资源就是文件。
我们将操作系统提供的这个供进程间通信的文件就做管道文件。
问:刚刚,我们有提到:管道通信依据的是struct file结构体给文件提供的缓冲区来进行通信。为什么不让缓冲区内的数据刷新到磁盘上,然后再从磁盘中读取数据呢?这种方式可以吗?
答:这种方式是可以的,但是没有必要。因为这种通信太慢了。
管道通信依赖的仅仅是struct file结构体中的内核级的缓冲区。我们知道:一个文件被加载到内存,首先就是要为其创建struct file结构体,那操作系统可不可以为一个根本不存在的文件在内存中创建struct file结构体呢?可以。所以管道文件实际上是一个内存文件,要么这个文件根本不存在,要么即使存在,也不管新它在磁盘中的位置。
问:如何让父子进程看到同一个文件呢?
答:父进程打开文件,然后fork创建子进程,子进程继承文件描述符表,文件描述符中指向同一个文件的struct file结构体地址,所以父子进程看到同一个文件。这种看到同一个文件的方式不需要文件名的参与,所以这个这种管道又被称为匿名管道
总结来说,创建管道的过程是:
问:为什么让父进程分别以读和写的方式打开同一个文件。
答:为了满足通信,通信双方会分别以读和写的方式打开同一个文件。父进程分别以读和写的方式打开同一个文件,子进程通过继承也会以读和写的方式打开同一个文件,这样一来,父子进程就可以选择数据传输的方向。
问:管道进行数据传输为什么是单项的?
答:这种通信方式之所以被命名为管道,是因为它符合现实生活中管道进行单向资源传输的特点。
设计出双向的管道在技术上是可行的,但也以为会更加麻烦,会添加更多的标定信息。如果我们想进行双向传输数据的话,我们可以创建两个管道来解决问题。
目前,匿名管道用来父子进程间通信。
pipe函数
pipe()函数可用于创建一个管道,以实现进程间的通信。
pipe()函数的定义如下:
- #include<unistd.h>
-
- /* @param fd,经参数fd返回的两个文件描述符
- * fd[0]为读而打开,fd[1]为写而打开
- * fd[1]的输出是fd[0]的输入
- * @return 若成功,返回0;若出错,返回-1并设置errno
- */
- int pipe(int fd[2]);
pipe函数定义中的fd参数是一个大小为2的数组类型指针。为输出型参数。
通过pipe函数创建的这两个文件描述符fd[0]和fd[1]分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出,并且fd[1]一端只能进行写操作,fd[0]一端只能进行读操作,不能反过来使用。要实现双向数据传输,可以使用两个管道。
默认情况下,这一对文件描述符都是阻塞的。此时,如果我们用read系统调用来读取一个空的管道,则read将被阻塞,直到管道内有数据可读;如果我们用write系统调用往一个满的管道中写数据,则write也将被阻塞,直到管道内有足够的空闲空间可用(read读取数据后管道中将清除读走的数据)。当然,用户可以将fd[0]和fd[1]设置为非阻塞的。
写一段小的测试代码:
- #include <iostream>
- #include <unistd.h>
- #include <sys/types.h>
- #include <sys/stat.h>
- #include <sys/wait.h>
- #include <fcntl.h>
- #include <cassert>
- #include<cstring>
- int main()
- {
- // 创建管道
- int fd[2];
- int n = pipe(fd);
- assert(n == 0);
- // fd[0]为读端
- // fd[1]为写端
-
- // 创建子进程
- pid_t fds = fork();
- assert(fds >= 0);
- const char *msg = "我是子进程,我正在给你发消息";
- int cnt = 0;
- if (fds == n)
- {
- // 子进程
- // 关闭文件描述符
- // 子进程进行写入
- while (1)
- {
- cnt++;
- close(fd[0]);
- char buffer[1024];
- snprintf(buffer, sizeof buffer, "子进程->父进程:%d[%s]", cnt, msg);
- write(fd[1], buffer, strlen(buffer));
- sleep(1);
- }
- exit(1);
- }
- close(fd[1]);
- char readbuffer[1024];
- while(1)
- {
- read(fd[0],readbuffer,sizeof readbuffer-1);
- std::cout<<readbuffer<<std::endl;
- sleep(1);
- }
-
- int m = waitpid(fds, nullptr, 0);
- assert(m = fds);
- }
整个过程是严格按照我们刚刚的步骤进行的,运行一下:
整个过程非常流畅。
情况1:
管道写的快,读的慢。现在我们让子进程一直在写,父进程每隔5秒钟读一次,我们还是使用上面的测试代码:
综合打印结果,我们发现:读端从管道中读取数据时,当管道中数据足够多时, 读端会将缓冲区读满。所以读端就会一次性读取1023个字节的数据。
总结:读端读取数据,如果管道中数据足够多时,读端就会读满设定的缓冲区。如果管道中数据不够填满给读端准备的缓冲区时,读端就会一次性的把所有数据给读完。
情况2:
写端写入数据的速度非常慢,每10秒钟写入一条数据,读端一直读取。
在写端休眠的10秒中,读端一直阻塞在read函数那里,等待写端将数据写入。
结论:当管道中没有数据时,且写端没有关闭写文件描述符时,读端会一直阻塞等待,直到写端有数据写入。
情况3
写端正常写入,读端每10秒钟读取一次数据。当管道被写满时,写端在做什么?
管道瞬间被写满 ,然后写段会阻塞在那里,等待读端读取数据。
总结:当管道被写满时,写端会阻塞等待读端将数据读取。
情况4
读端正常读取,写端在写入过程中突然将写文件描述符关闭
总结:当写端不再写入,并且关闭了pipe,那么读端将会把管道内的内容读完,最后就会读到返回值为0,表示读取结束,类似于读到了文件的结尾。
情况5
写端正常写入,但是读端正常读取过程中突然将读文件描述符关闭。
我们发现:当读端关闭读文件描述符的同时,写文件描述符也同时被关闭了。这是因为没有进程从管道读取数据了 ,所以往管道中写入的数据就是没有利用价值的,操作系统不会出现这种毫无价值的写入。
总结:当读端不再进行读取操作,并且关闭自己的文件描述符fd,此时的写就没有意义了。那么OS就会通过信号13(SIGPIPE)的方式直接终止写端的进程。
如何证明写进程是被13号信号杀死的呢?由于子进程退出后,父进程可以通过进程等待查到子进程的退出信息。所以我们:
所以,的确是操作系统向子进程发送13号信号,来终止写进程的。
根据管道的几种特殊读写的情况,也间接创造出了管道的5个特征。
- 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。同样,兄弟进程,爷孙进程都可以利用管道进行通信。
- 管道提供流式服务。管道并不关系管道传输的是什么数据。
- 一般而言,进程退出,管道释放,所以管道的生命周期随进程
- 一般而言,内核会对管道操作进行同步与互斥
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
目标:父进程通过管道控制子进程。
实现原理:
如图所示:创建若干子进程和管道,父子进程之间通过管道进行链接,父进程写入数据,子进程读取数据。然后子进程做特定的操作。
如下代码:
- #include <iostream>
- #include <string>
- #include <vector>
- #include <cstdlib>
- #include <cassert>
- #include <ctime>
- #include <sys/types.h>
- #include <sys/wait.h>
- #include <unistd.h>
-
- #define MakeSeed() srand((unsigned long)time(nullptr) ^ getpid() ^ 0x171237 ^ rand() % 1234)
-
- #define PROCSS_NUM 10
-
- ///子进程要完成的某种任务 -- 模拟一下/
- // 函数指针 类型
- typedef void (*func_t)();
-
- void downLoadTask()
- {
- std::cout << getpid() << ": 下载任务\n"
- << std::endl;
- sleep(1);
- }
-
- void ioTask()
- {
- std::cout << getpid() << ": IO任务\n"
- << std::endl;
- sleep(1);
- }
-
- void flushTask()
- {
- std::cout << getpid() << ": 刷新任务\n"
- << std::endl;
- sleep(1);
- }
-
- void loadTaskFunc(std::vector<func_t> *out)
- {
- assert(out);
- out->push_back(downLoadTask);
- out->push_back(ioTask);
- out->push_back(flushTask);
- }
-
- /下面的代码是一个多进程程序//
- class subEp // Endpoint
- {
- public:
- subEp(pid_t subId, int writeFd)
- : subId_(subId), writeFd_(writeFd)
- {
- char nameBuffer[1024];
- snprintf(nameBuffer, sizeof nameBuffer, "process-%d[pid(%d)-fd(%d)]", num++, subId_, writeFd_);
- name_ = nameBuffer;
- }
-
- public:
- static int num;
- std::string name_;
- pid_t subId_;
- int writeFd_;
- };
-
- int subEp::num = 0;
-
- int recvTask(int readFd)
- {
- int code = 0;
- ssize_t s = read(readFd, &code, sizeof code);
- if(s == 4) return code;
- else if(s <= 0) return -1;
- else return 0;
- }
-
- void sendTask(const subEp &process, int taskNum)
- {
- std::cout << "send task num: " << taskNum << " send to -> " << process.name_ << std::endl;
- int n = write(process.writeFd_, &taskNum, sizeof(taskNum));
- assert(n == sizeof(int));
- (void)n;
- }
-
- void createSubProcess(std::vector<subEp> *subs, std::vector<func_t> &funcMap)
- {
- std::vector<int> deleteFd;
- for (int i = 0; i < PROCSS_NUM; i++)
- {
- int fds[2];
- int n = pipe(fds);
- assert(n == 0);
- (void)n;
- // 父进程打开的文件,是会被子进程共享的
- // 你试着多想几轮
- pid_t id = fork();
- if (id == 0)
- {
- for(int i = 0; i < deleteFd.size(); i++) close(deleteFd[i]);
- // 子进程, 进行处理任务
- close(fds[1]);
- while (true)
- {
- // 1. 获取命令码,如果没有发送,我们子进程应该阻塞
- int commandCode = recvTask(fds[0]);
- // 2. 完成任务
- if (commandCode >= 0 && commandCode < funcMap.size())
- funcMap[commandCode]();
- else if(commandCode == -1) break;
- }
- exit(0);
- }
- close(fds[0]);
- subEp sub(id, fds[1]);
- subs->push_back(sub);
- deleteFd.push_back(fds[1]);
- }
- }
-
- void loadBlanceContrl(const std::vector<subEp> &subs, const std::vector<func_t> &funcMap, int count)
- {
- int processnum = subs.size();
- int tasknum = funcMap.size();
- bool forever = (count == 0 ? true : false);
-
- while (true)
- {
- // 1. 选择一个子进程 --> std::vector<subEp> -> index - 随机数
- int subIdx = rand() % processnum;
- // 2. 选择一个任务 --> std::vector<func_t> -> index
- int taskIdx = rand() % tasknum;
- // 3. 任务发送给选择的进程
- sendTask(subs[subIdx], taskIdx);
- sleep(1);
- if(!forever)
- {
- count--;
- if(count == 0) break;
- }
- }
- // write quit -> read 0
- for(int i = 0; i < processnum; i++) close(subs[i].writeFd_); // waitpid();
- }
-
-
- void waitProcess(std::vector<subEp> processes)
- {
- int processnum = processes.size();
- for(int i = 0; i < processnum; i++)
- {
- waitpid(processes[i].subId_, nullptr, 0);
- std::cout << "wait sub process success ...: " << processes[i].subId_ << std::endl;
- }
- }
-
- int main()
- {
- MakeSeed();
- // 1. 建立子进程并建立和子进程通信的信道, 有bug的,但是不影响我们后面编写
- // 1.1 加载方发表
- std::vector<func_t> funcMap;
- loadTaskFunc(&funcMap);
- // 1.2 创建子进程,并且维护好父子通信信道
- std::vector<subEp> subs;
- createSubProcess(&subs, funcMap);
-
- // 2. 走到这里就是父进程, 控制子进程,负载均衡的向子进程发送命令码
- int taskCnt = 3; // 0: 永远进行
- loadBlanceContrl(subs, funcMap, taskCnt);
-
- // 3. 回收子进程信息
- waitProcess(subs);
-
- return 0;
- }
匿名管道通信仅仅适用于有血缘关系的进程间的通信,有较大的局限性。有没有一种能用于没有血缘关系的进程间的通信呢?有,命名管道。
创建命名管道文件的操作:mkfifo +filename
示例演示:
我们可以发现它的文件类型前面以P开头,当大家看到P开头的,会能想到什么?在之前我给大家在讲我们Linux基础命令的时候说过一个话题叫做文件类型:以 - 开头普通文件、以D开头为目录文件、以L开头为链接文件L开头的叫做软链接、这里以P开头叫做管道文件,这时候在磁盘上存在了一个管道文件。
【解释说明】
在我们的理解中把它写到文件当中,此时就相当于当我一敲回车,echo对应的这个东西就会变成进程;
然后,执行我们向显示器当中打印,经过重定向,它最终不向显示器文件打印,而向管道文件中打印,所以底层作为重定向是没问题的;
紧接着我们就尝试去写了,但当前呢它卡在这里的,什么都没做,我们再看一下当前这个管道文件里,当前显示的是零,好像没有写入啊;
这是因为管道文件有种特殊特性,虽然在磁盘当中创建了这个 fifo,但它仅仅是一种符号,那么对于这种符号呢,将来你向这个文件里写入的消息,并没有或者并不会刷新落实到磁盘上,而是只帮我们在这里直接 echo,然后写入管道文件当中,但是管道文件当前是内存级的,所以你的大小没有变。
接下来,我们来试一下重定向:
我们在磁盘中创建并命名一个文件,这个文件是真是存在在磁盘的某个路径下的。所以任意进程都可以打开这个文件。如果系统中有两个想要通信的进程,这个文件对双方进程来说就是公共资源。
一个进程向该文件中写入数据,另一进程从该软件中读取数据,进程双方就可以达到通信的目的。但是要通信的数据不会真的刷新到文件中,还是利用文件的缓冲区来进行通信的。所以你查询该文件,总会发现这个文件的大小一直是0。
问:要通信的两个进程如何找到同一个文件呢?
答:命名文件一定是真实存在于磁盘的某个目录下的,也就是说会有具体的路径,所以通信进程双方可以使用路径和文件名相结合的方式找到同一个文件。路径+文件名=唯一性
问:两个进程同时打开同一个文件,操作系统会为该文件创建两个struct file结构体吗?
答:不会,操作系统在计算机里是一个精打细算的角色,尤其是内存这种有限且非常重要的资源。
同一个文件的struct file结构体内部的数据应该是一样的,既然如此,操作系统为什么还要花费资源去维护另外一块空间呢?
如图所示:
问:为什么不进行文件数据的刷新工作?
答:没有必要,我们想要的仅仅是读取数据而已,数据在缓冲区内依旧可以完成数据的写入和读取。理论上可以从将数据刷新到磁盘,然后再从磁盘中将数据读取出来,但这样做,进程间通信的成本就会大大增加,因为磁盘属于外设,将数据从内存中写入外设是很慢的。
我们创建4个文件:
client.cc
- #include "comm.hpp"
-
- // 你可不可以把刚刚写的改成命名管道呢!
- int main()
- {
- std::cout << "client begin" << std::endl;
- int wfd = open(NAMED_PIPE, O_WRONLY);
- std::cout << "client end" << std::endl;
- if(wfd < 0) exit(1);
-
- //write
- char buffer[1024];
- while(true)
- {
- std::cout << "Please Say# ";
- fgets(buffer, sizeof(buffer), stdin); // abcd\n
- if(strlen(buffer) > 0) buffer[strlen(buffer)-1] = 0;
- ssize_t n = write(wfd, buffer, strlen(buffer));
- assert(n == strlen(buffer));
- (void)n;
- }
-
- close(wfd);
- return 0;
- }
server.cc
- #include "comm.hpp"
-
- int main()
- {
- bool r = createFifo(NAMED_PIPE);
- assert(r);
- (void)r;
-
- std::cout << "server begin" << std::endl;
- int rfd = open(NAMED_PIPE, O_RDONLY);
- std::cout << "server end" << std::endl;
- if(rfd < 0) exit(1);
-
- //read
- char buffer[1024];
- while(true)
- {
- ssize_t s = read(rfd, buffer, sizeof(buffer)-1);
- if(s > 0)
- {
- buffer[s] = 0;
- std::cout << "client->server# " << buffer << std::endl;
- }
- else if(s == 0)
- {
- std::cout << "client quit, me too!" << std::endl;
- break;
- }
- else
- {
- std::cout << "err string: " << strerror(errno) << std::endl;
- break;
- }
- }
-
- close(rfd);
-
- // sleep(10);
- removeFifo(NAMED_PIPE);
- return 0;
- }
comm.hpp
- #pragma once
- #include<iostream>
- #include<string>
- #include<cstring>
- #include<cerrno>
- #include<assert.h>
- #include<unistd.h>
- #include<sys/types.h>
- #include<sys/stat.h>
- #include<fcntl.h>
-
- #define NAMED_PIPE "/home/user/exercise/my_pipe/named_pipe/name_pipe"
- bool createFiFo(const std::string &path)
- {
- umask(0);
- int n=mkfifo(path.c_str(),0600);
- if(n==0)
- {
- return true;
- }
- else
- {
- std::cout<<"errno:"<<errno<<"err string"<<strerror(errno)<<std::endl;
-
- }
- }
- void deleteFifo(const std::string &path)
- {
- int n=unlink(path.c_str());
- assert(n==0);
- (void)n;
- }
makefile
- .PHONY:all
- all:client server
- client:client.cc
- g++ -o $@ $^ -std=c++11
- server:server.cc
- g++ -o $@ $^ -std=c++11
- .PHONY:clean
- clean:
- rm -f client server
写在最前面:共享内存虽然作为进程间通信的一种方式,但是在实际工作中,使用的次数缺很少,具体原因我会在讲解中说明。这次,我们打破以往的讲解顺序:先讲原理,然后写代码,最后是概念。
依上图,我简单讲解一下:通过学习管道,我们知道两个进程要实现通信,必须看到同一块资源。其中,我们在内存中申请的这块空间就可以充当进程双方通信的资源,这块内存就叫做共享内存。
我们需要知道:
操作系统为了方便我们使用共享内存,对外提供了一批接口。
shmget:在内存中申请一块指定大小的共享内存空间
参数介绍
①:key
我们提到操作系统中一定会存在多个共享内存,所以一定要有一个数据来标定这个共享内存的唯一性,key的作用便是标定这个唯一性,未来key值要被写入到共享内存相关属性集中的。
我们可以使用函数ftok来获取这个key的值。这个值是多少一点都不重要,能进行唯一性标识最重要。就像我们的身份证号,具体是多少对警察声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。