从今天开始,Linux的代码就切换在VScode上写了,总算告别VIM了,而且编程语言也开始使用C++了。
什么是进程通信:
进程间通信是两个或者多个进程之间进行通信,行为如下:
为什么要有进程通信:
- 在操作系统中,进程是独立运行的程序,它们之间需要相互协作完成任务。进程间通信的目的是为了实现进程之间的数据共享、协作和同步,从而提高系统的效率和可靠性。
说白了就是,很多场景下需要多个进程协同工作来完成要求。
两个进程cat和grep协同工作,将log.txt文件中带有hello的文字显示出来。
如何进行进程通信:
当前主要是通过三种策略来实现进程间通信的:
每一种策略下都有很多种通信方式,在这篇文章中本喵详细讲解管道策略的通信方式。
进程通信的本质:
我们知道,进程是相互独立的,所以进程之间的通信成本肯定不低。
为了进程在通信的时候,既能满足进程之间的独立性,又能够到达通信的目的,那么进程之间通信的地点就不能在两个进程中。
- 一个进程将自己的数据交给另一个进程,并且还要等待另一个进程的应答,这样一来,这个进程将不独立了,受到了另一个进程的影响,与进程的独立性矛盾。
所以,两个进程进行通信的地点必须是由第三方提供的,第三方只能是操作系统。操作系统提供的这个地点被我们称为:公共资源。
公共资源有了,还必须让要通信的进程都看到这一份公共资源,此时要通信的进程将有了通信的前提。之后就是进程通信,也就是访问这块公共资源的数据。
之所以有不同的通信方式,是因为公共资源的种类不能,如果公共资源是一块内存,那么通信方式就叫做共享内存,如果公共资源是一个文件,也就是struct file结构体,那么就叫做管道。
首先我们来回忆一下文件系统。
父进程打开一个文件,操作系统在内存上创建一个struct file结构体对象,里面包含文件的各种属性,以及对磁盘文件的操作方法。
- 每个struct file对象中还有一个内核缓冲区,这个缓冲区中可以存放数据。
当子进程创建的时候,父进程的文件描述符表会被子进程继承下去,所以此时子进程在相同的fd处也指向父进程打开的文件。
- 文件描述符表一个进程维护一个,但是struct file结构体对象在内存中只有一个,由操作系统维护。
此时,父子进程将看到了同一份公共资源,也就是操作系统在内存中维护的struct file对象,并且父子进程也都和这份资源建立了连接。
此时父子进程通信的基础有了,它们就可以通信了。
这样一读一写,父子进程将完成了一次进程间通信。
而我们又知道,对文件进行IO操作时,由于需要访问硬盘,所以速度非常的慢,而且我们发现,父子间进行通信,磁盘中文件的内容并不重要,重要的是父进程写了什么,子进程又读到了什么。
- 此时操作系统为了提高效率,就关闭了内存中struct file和硬盘中文件进行IO的通道。
- 父进程写数据写到了struct file的内核缓冲区中。
- 子进程读数据从struct file的内核缓冲区中读取。
此时,父子间通信仍然正常进行,并且效率还非常的高,而且还没有影响进程的独立性。而这种不进行IO的文件叫做内存级文件。
这种由文件系统提供公共资源的进程间通信,就叫做管道。
进程A和B就通过管道建立起了连接,并且可以进程进程之间的通信。而管道又分为匿名管道和命名管道。
- 匿名管道:顾名思义,就是没有名字的文件(struct file)。
- 匿名管道只能用于父子进程间通信,或者由一个父进程创建的兄弟进程之间进行通信。
现在我们知道了匿名管道就是没有名字的文件,通过管道进行通信时,只需要通信双方打开同一个文件就可以。
我们通过系统调用open打开文件的时候,会指定打开方式,是读还是写。
既然是通信,势必有一方在写,一方在读,而现在父子双方都是以写的方式打开,它们怎么进行通信呢?
- 父进程以读和写的方式打开同一份文件两次。
此时的管道文件分为写端和读端,并且写端和读端各会返回一个文件描述符fd。所以父进程的文件描述符表中,和管道文件有关的文件描述符fd就有两个。
这样一来,创建子进程后,父子进程都可以对管道进行读和写,它们就可以进行通信了,上面的问题就解决了。
之所以命名为管道,那么就有和管道类似的性质。在生活中,我们对水管,它的流向只能是单向的,管道也一样,通过管道建立的通信只能进行单向数据通信。
- 是因为通过内存级文件通信的方式具有这种特点,才命名的管道。
- 而不是先命名管道才设计的内存级文件通信方式。
- 为了防止父进程对管道进行误读,以及子进程对管道进行误写,破坏通信规则。
- 将父进程的读端关闭,将子进程的写端关闭,使用系统调用close(fd)。
此时,父子进程之间的单向数据通信就建立起来了,下一步就可以进行通信了。
如果想进行双向通信,可以建立两个管道。
上面都是理论上的,具体到代码中是如何建立管道的呢?既然是操作系统中的文件系统提供的公共资源,当然是用系统调用来建立管道了。
- 形参:int pipefd[2]是一个输出型参数,是一个数组,该数组只有两个元素,下标分别为0和1。
- 下标为0的元素表示的是管道读端的文件描述符fd。
- 下标为1的元素表示的是管道写端的文件描述符fd。
使用系统调用pipe,直接就会得到两个fd,并且放入父进程的文件描述符表中,不用打开内存级文件两次。
- 返回值:int类型的整数,对管道创建情况进行反馈。
- 返回0,表示管道创建成功。
- 返回-1,表示管道创建失败,并且会将错误码自动写入errno中。
那么,父进创建管道以后,得到的两个文件描述符是多少呢?是3和4吗?我们代码中来看。
#include
#include
#include
#include
int main()
{
int fds[2];
int ret = pipe(fds);
if(ret < 0)
{
std::cerr<<errno<<":"<<strerror(errno)<<std::endl;
}
std::cout<<"fds[0]: "<<fds[0]<<std::endl;
std::cout<<"fds[1]: "<<fds[1]<<std::endl;
return 0;
}
当管道创建失败的时候,进行报错,并且显示错误码和错误信息。
可以看到,创建管道后返回的两个fd值,果然是3和4,因为0,1,2分别被stdin,stdout,stderr占用。
知道了如何使用系统调用创建管道以后,接下来就创建子进程,然后关闭不需要的端口了,原理已经清楚,直接看代码。
此时在代码层面上, 父子双方就已经建立了连接了,接下来就是通信数据了。
子进程通信代码:
将上面子进程通信代码处的sleep(5)换成如上图所示代码。每隔一秒钟,子进程向父进程发生一个字符串,包括子进程的pid,以及发送次数。
- 为了保证通信的正确,使用snprintf来严格控制发送数据,将数据写入到buffer中。
- 向管道中写入,使用的是系统调用write。
父进程通信代码:
- 向管道中读取,使用的是系统调用read。
父子进行通信现象:
- 子进程循环不停向管道中写入数据。
- 每写入一次会打印一句写入完成,并且打印出子子进程的pid。
- 父进程只读取一次
- 读取完成后阻塞不动,1000s后再读取一次
此时就模拟出了,向管道中写入非常快,而从管道中读取则很慢,每隔1000读取一次。
根据上面现象可以看出,管道中的数据没有被读取端读走时,写入端在将管道写满以后会停止写入,为了防止管道中的数据被覆盖。
结论:写入快,读取慢,write调用阻塞,直到有进程读走数据。
- 子进程写入一次后延时1000s,阻塞不动。
- 父进程快速读取,没有时间间隔。
- 在读取之前打印开始读取,读取之后打印读取结束。
此时就模拟出,读取快,写入慢的场景。
根据上面现象可以看出,写入端没有向管道中写入数据的时候,读取端会阻塞,等待数据写入然后再读取。
结论:写入慢,读取块,read调用阻塞,即进程暂停执行,一直等到有数据来到为止。。
- 子进程每隔一秒向管道中写入一次数据。
- 写入十次以后停止写入,并且关闭子进程的写端。
- 父进程不停的从管道中读取数据。
- 当read返回值为0的时候,表示读到了文件结尾,也就是管道中数据读完了,并且不会再有了。
- 此时停止读取,并且关闭读端。
根据上面的现象,当管道的写入端关闭时,读取端也就没有等待的打开的必要了,所以read就会返回0,表示管道的写入端已关闭。
结论:管道写端对应的文件描述符被关闭,则read返回0。
- 子进程隔一秒向管道中写入一次,一只重复。
- 父进程读取10次,然后关闭读端。
- 再进行进程等待,并且查看子进程的退出信号。
根据上面现象,当管道的读取端关闭后,操作系统会自行结束管道的写入端,因为此时写入端的存在已经没有意义了。
结论:管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,让write进程退出。
总结一下管道的读取特征:
场景 | 特征 |
---|---|
读取慢,写入快 | 写入端阻塞在write处 |
读取快,写入慢 | 读取端阻塞在read处 |
读取端关闭 | 操作系统终结写端 |
写入端关闭 | 读取端read返回0 |
管道之所以有这样的读取特征,其实是为了对管道中的数据进行保护,这种方式称为互斥,后面本喵会详细讲解这一概念。
匿名管道本身也有它自己的特征,如下:
- 命名管道:顾名思义,有名字的管道(内存级文件)。
根据前面的学习,我们知道,父子进程间使用匿名管道的方式进行通信,是通过子进程继承父进程的方式来实现,而且匿名匿名管道常用于父子进程直接,或者由血缘关系的进程直接。
那么,如果两个进程毫无关系呢?此时就不能继承了,那这两个进程如何建立连接呢?
还是采用管道的方式,但是这个管道是有名字的管道,这样一来,两个进程就可以打开同一个管道文件建立连接。
还是这张图,此时内存中的struct file在磁盘上有对应文件的,如上图中的fifo.ipc文件。
- 指令:mkfifo 文件名
- 功能:创建命名管道文件
如上图所示,此时就创建了一个命名管道,可以看到,文件类型是p,而且该文件还有inode,说明在磁盘上是真实存在的。
当磁盘中有了命名管道文件以后,两个进程将可以通过这个管道文件进行通信了,步骤和匿名管道非常相似。
- 一个进程以写方式打开管道文件。
- 另一个进程以读端方式打开管道文件。
此时两个进程将建立了连接,然后将可以进行通信了。
我们知道,进程间通信的前提是,要通信的进程能够看到同一份公共资源,那么命名管道是如何做到这一点的呢?
路径 + 文件名 = 唯一性
所以说,命名管道是通过利用这种唯一性来让要通信的进程都看到这块内存级文件的。
创建管道文件:
可以在shell中通过命令的方式创建管道文件,两个进程直接去使用它。也可以像文件一样,在进程中创建管道文件,此时就需要用到系统调用。
- 第一个形参:管道文件的名字
- 第二个形参:创建管道文件的权限
- 返回值:0表示创建成功,-1表示创建失败。
- 虽然管道文件属于公共资源,并且是由操作系统维护的。
- 但是它也得有人去创建。通信双方必须有一方创建管道文件,
此时就有了这样一个管道文件,结果和使用命名mkfifo的结果是一样的。
再次运行程序,就会报错,管道文件已经存在,所以说,如果管道文件已经存在了,就没有必要再使用系统调用mkfifo。
删除管道文件:
向管道文件中写如数据,这些数据是不会IO到磁盘中的。
让程序开始疯狂向管道文件中写入内容,再查看管道文件,发现文件的大小没有变化。
和匿名管道一样,向命名管道写文件时,不会和磁盘进行IO,而是将数据写到了struct file结构体的缓冲区中,数据写入了内核中。
- 形参:要删除的管道文件名称(路径加名字)
- 返回值:删除成功返回0,失败返回-1。
此时在完成通信以后,我们也不会看到管道文件,它的创建与删除都由通信的某一方来维护。
我们创建两个进程进行通信,一方叫做sever,另一方叫做client,sever负责创建管道文件,并且从管道中读取数据,client负责向管道中写入数据。
sever.c代码:
创建好管道文件以后,使用系统调用open以写的方式打开文件,再通过系统调用read读取管道中的数据。
client.c代码:
在server.c创建好管道文件以后,再使用open以写方式打开管道文件,再通过write将从键盘上获取的数据写入到管道文件中。
运行效果:
client输入什么,sever就输出什么,此时两个无关的进程就成功进行了通信。
- 必须先运行sever,再运行client,因为sever要创建管道文件,只有管道文件创建了以后才有进行通信的前提。
有了匿名管道的基础,命名管道就很简单了,不同之处只在于需要创建管道文件和打开管道文件,而匿名管道的pipe系统调用直接就将管道文件创建好并且打开了。其他的操作都一样。
命名管道和匿名管道在那4个方面的读写特征是完全一样的,也是存在互斥机制的。除此之外还有一些其他的特点。
- 在读端代码中,在open操作前后分别打印一句话。
- 只执行server(读端),不执行client(写端)。
- 只有读端要打开管道文件,写端不打开。
根据现像,此时server不再执行了,阻塞在了这里。
结论:当读端要打开管道,而写端没有打开时,会在读端的open处阻塞。
- sever端在创建文件后进行1000s延时,阻塞在这里。
- 在写端open前后各打印一句话。
结论:当写端要打开管道,而读端没有打开时,会在写端的open处阻塞。
总的来说,进行通信的双方,一方没有打开管道文件,另一方在打开的时候就会阻塞在open处。
除了读写时的特征,命名管道本身也有特征:命名管道的生命周期随内核。
我们可以看到,管道文件是可以直接在磁盘上存在的,和进程无关,这一点和命名管道不一样,其他的特征都一样。
匿名管道和命名管道的区别:
现在对管道已经有了一定的认识,下面喵给大家用匿名管道写一个进程池的小项目。
在循环中,每次都先创建匿名管道,然后再创建子进程,让子进程继承父进程对匿名管道读写端的文件描述符fd。
子进程要做的第一件事情就是关闭对匿名管道写端的文件描述符fd。然后再执行具体的代码(后面再讲解具体怎么执行)。当每一个子进程执行完代码后,关闭对应的读端文件描述符fd,再进程退出。
父进程在创建好子进程后,第一件要做的事情就是关闭对应管道的读端文件描述符fd,然后再保存子进程的相关信息,方便后面进行控制。
要保存的子进程信息:
- 首先就是子进程的pid,方便后面进行进程等待等操作。
- 再就是和子进程通信管道的写端描述符fd,方便后面进行写数据。
- 其次就是子进程的名字信息,这个是我们自己为了观察现象而设置的。
- 还有就是子进程的编号,也就是计数器。
为了方便描述子进程的信息,我们创建一个结构体来描述子进程。
然后将子进程的信息对象放在vector中,这样就组织了起来,方便我们进行管理。
先描述再组织的思想。
先制定一批让子进程执行的任务。
本喵这里粗略写了五个任务,如上图所示,每一个任务实际上就是一个函数。
为了方便管理这些任务,同样需要将它们组织起来。
//定义函数指针
typedef void(*func)();
将函数指针类型typedef为func。
然后将这些任务都放在vector中管理起来,此时vector的每一个元素都是一个任务的函数指针,而元素的下标就是子进程要接收的任务码。
之所以让多个进程来执行任务,就是为了充分利用系统资源,提高效率,如果在一个子进程完成任务的期间,其他子进程在等待那就失去了意义。
- 为了同时调度所有子进程,并且完成多种任务,采取随机指定子进程和随机指定任务的方式。
//获取随机数种子
#define MakeSeed() srand((unsigned int)time(nullptr) ^ getpid() ^ 0x123456 ^ rand() % 123)
首先生成随机数种子,种种子的方法很多,本喵只是随意写了一下。
使用系统调用write向匿名管道中写入命令码,对应的管道的写端文件描述符从类对象中获取,在创建的时候就保存了。
命令码就是存放函数指针的vector的下标,是int类型的整数,要严格控制写入管道的字节数,防止通信发生意外。
如果执行的任务次数是有限次的,当任务被这些子进程执行完毕以后,需要关闭所有的写端描述符fd。这里存在潜在问题,一会儿详细讲解,但是不影响我们使用。
这部分代码在维护父子进程通信关系时出现过,现在本喵介绍下子进程执行任务的具体流程。
使用系统调用read从匿名管道中读取父进程发来的命令码,并且严格检查是否是4个字节,如果是说明接收正确,返回命令码,如果不是,返回-1。
根据接收到的命令码,从存放函数指针的vector中找到对应的函数指针,调用对应的函数。
当所有任务被完成以后,父进程关闭所有管道的写端,子进程接收到的命令码就会是-1,然后子进程就会退出。
当父进程关闭写端以后就会进程等待,每等待成功一个子进程都会打印一句话,当所有子进程被等待成功后父进程退出。
上面在关闭所有的写端描述符时,本喵提到存在潜在问题,下面来分析一下:
- 父进程的文件描述符表中存在一个管道的文件描述符,如上图所示的3。
- fork出子进程后,会继承父进程的文件描述符表,再加上自己的文件描述符fd,此时的文件描述符表中就有两个fd,如上图所示的3和4。
- 当父进程创建多个子进程时,就会创建多个管道,每增加一个子进程就会增加一个管道以及文件描述符fd。
- 子进程又会继承父进程的文件描述符,尤其是最后一个创建的子进程,文件描述符表中有很多的fd,但是只有最后一个是属于自己和父进程通信的。
此时问题就来了,当父进程关闭一个写端文件描述符fd的时候,这个管道被关了码?
- 很显然没有被关,因为子进程的文件描述符表中仍然有fd执行父进程关闭的管道。
我们的程序中,之所以没有出问题,是因为将父进程的所有写端被关闭时,所有的子进程对应那个管道的read会读取到0,然后子进程就会退出,它维护的文件描述符表也就销毁了,当所有文件描述符表被销毁后,就没有fd指向管道了,管道也就被回收了。
为了避免这个问题,每个子进程在创建后,不仅要关闭写端描述符,还要关闭从父进程继承下来的文件描述符fd。
专门创建一个vector,用来存放要被子进程删除的从父进程继承下来的写端fd。
- 在父进程保存子进程信息的时候,将父进程的写端fd放入到专用的删除vector中。
- 在子进程中,关闭vector中所有内容所对应的文件描述符fd,此时子进程的文件描述符表中就只剩下自己和父进程通信所用管道的fd了。
至此,我们进程池的代码就完美了。
本喵这里是创建了5个子进程,需要完成10次随机任务。
也可以用命名管道实现,只是需要由某一个进程负责创建命名管道文件和删除。要通信的其他进程也不用fork,需要自己写具体的进程,有兴趣的小伙伴可以自行尝试。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
/****************************************用到的定义***************************************************/
#define PROCESS_NUM 5
//获取随机数种子
#define MakeSeed() srand((unsigned int)time(nullptr) ^ getpid() ^ 0x123456 ^ rand() % 123)
//定义函数指针
typedef void(*func)();
class subInfo
{
public:
//构造函数
subInfo(pid_t subid,int wfd)
:_subid(subid)
,_wfd(wfd)
{
char nameBuffer[1024];
snprintf(nameBuffer,sizeof(nameBuffer),"子进程(%d)[pid(%d),fd(%d)]",_num,_subid,_wfd);
_name = nameBuffer;
_num++;
}
public:
static int _num;//子进程数量
std::string _name;
pid_t _subid;//子进程pid
int _wfd;//子进程对于管道写端fd
};
//域外初始化static变量
int subInfo::_num = 0;
/****************************************子进程要完成的任务***************************************************/
void downLoadTask()
{
std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:下载任务"<<std::endl;
std::cout<<std::endl;
sleep(1);
}
void ioTask()
{
std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:IO任务"<<std::endl;
std::cout<<std::endl;
sleep(1);
}
void flushTask()
{
std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:刷新任务"<<std::endl;
std::cout<<std::endl;
sleep(1);
}
void netTask()
{
std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:上网任务"<<std::endl;
std::cout<<std::endl;
sleep(1);
}
void dealTask()
{
std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:处理任务"<<std::endl;
std::cout<<std::endl;
sleep(1);
}
void loadTaskToMap(std::vector<func>& funcMap)
{
funcMap.push_back(downLoadTask);
funcMap.push_back(ioTask);
funcMap.push_back(flushTask);
funcMap.push_back(netTask);
funcMap.push_back(dealTask);
}
/****************************************进程池核心代码***************************************************/
//子进程接收任务码
int recvTask(int rfd)
{
int code = 0;
ssize_t ret = read(rfd,&code,sizeof(int));
if(ret==4) return code;
else if(ret <=0 ) return -1;//读取出错
else return 0;
}
//父进程发生任务码
void sendTask(const subInfo& proc,int taskIdx)
{
std::cout<<"--------------------------------------------------"<<std::endl;
std::cout<<"发送的命令码:"<<taskIdx<<", 发生给:"<<proc._name<<std::endl;
ssize_t ret = write(proc._wfd,&taskIdx,sizeof(taskIdx));
assert(ret == 4);
(void)ret;
}
//创建所有子进程及管道,并维护好通信关系
void createSubProcess(std::vector<subInfo>& subs,const std::vector<func>& funcMap)
{
std::vector<int> deleteFd;
for(int i = 0; i < PROCESS_NUM; ++i)
{
//创建匿名管道
int fds[2];
int ret = pipe(fds);
assert(ret==0);
(void)ret;
//创建子进程
pid_t id = fork();
/******************子进程**************************/
if(id == 0)
{
//子进程进行读,关闭写端
close(fds[1]);
//删除继承自父进程的写端描述符
for(int i = 0;i < deleteFd.size();++i)
{
close(deleteFd[i]);
}
//子进执行代码
while(1)
{
//读取命令码
int commandCode = recvTask(fds[0]);
//执行任务
if(commandCode >= 0 && commandCode < funcMap.size())
{
funcMap[commandCode]();
}
else if(commandCode == -1) break;
}
//子进程退出,关闭读端
close(fds[0]);
exit(0);
}
/******************父进程**************************/
//父进程进行写,关闭读端
close(fds[0]);
//保存子进程pid,对应管道的fd
subInfo sub(id,fds[1]);
subs.push_back(sub);
//保存子进程要删除的写端fd
deleteFd.push_back(fds[1]);
}
}
//负载均衡控制
void loadBalanceContral(const std::vector<subInfo>& subs,const std::vector<func>& funcMap,int taskCount)
{
int procesNum = subs.size();
int taskNum = funcMap.size();
bool forever = (taskCount == 0 ? true : false);
while(1)
{
//选择子进程
int subIdx = rand()%procesNum;
//选择任务
int taskIdx = rand()%taskNum;
//将任务发生给子进程
sendTask(subs[subIdx],taskIdx);
sleep(1);
if(!forever)
{
taskCount--;
if(taskCount == 0) break;
}
}
//任务执行完成,关闭所有写端
for(int i = 0;i < procesNum;++i)
{
close(subs[i]._wfd);
}
}
//回收子进程信息
void waitPorcess(std::vector<subInfo>& subs)
{
int procesNum = subs.size();
for(int i = 0;i < procesNum;++i)
{
int ret = waitpid(subs[i]._subid,nullptr,0);
assert(ret == subs[i]._subid);
std::cout<<"子进程等待成功,pid:"<<subs[i]._subid<<std::endl;
}
}
int main()
{
MakeSeed();
std::vector<subInfo> subs;
std::vector<func> funcMap;
loadTaskToMap(funcMap);
//创建子进程,维护通信关系
createSubProcess(subs,funcMap);
//父进程控制子进程
int taskCount = 10;
loadBalanceContral(subs,funcMap,taskCount);
//回收子进程信息
waitPorcess(subs);
return 0;
}
在实际应用中,匿名管道的使用比命名管道要多,而且只有掌握了匿名管道,命名管道可以说是手到擒来,很容易。进程池的小项目对理解匿名管道非常的有帮助。