我们之前学习到的管道是没有名字的正因为没有没有名字所以最后选择的是让子进程继承父进程的方式来达到让父子进程看到同一份资源的方式。这也也就导致了匿名管道只能在具有血缘关系的进程进行进程间通信。
但是我们需要进行进程间通信的场景并不是只有这一种的?如果是毫不相关的进程进行进程间通信呢?
所以我们需要下一种通信方式:命名管道
首先我们就来了解创建命名管道的一个函数:mkfifo
从这里我们也能够看到这里创建的是一个命名管道。FIFO也就是先进先出,也就是队列的一个特点。因为管道的本质也就是一个字节流的队列。
写方曾经怎么写,对方就怎么读。只不过读写的次数不一定严格一致。
这里我们也可以在命令行中使用mkfifo指令创建一个管道。
上图中的myfifo就是一个管道。它的文件类型以p开始也就是pipe也就是命名管道。虽然我们从命令行这里看到这个命名管道文件是存在于磁盘中的,但是真正储存在这个文件中的内容都是不会刷新到磁盘中的。
这个myfifo更多的只是一种符号。
下面我开启两个终端来使用一下:这个命名管道
当我的一个终端将hello重定向到myfifo中之后可以看到这里就被阻塞了。
我在另外一个终端中查看一下这个管道文件的大小:
可以看到这个管道中是没有被写入内容的,因为文件的大小为0,但是如果我让这个管道从文件中读取内容呢?
然后就能看到我写入的内容被打印出来了。
首先这里两边的终端可以说是毫无关系的两个进程但是这样的两个进程就可以通过这样的一个命名管道完成进程间的通信。
我们也可以将写入数据做成一个简单的脚本:
然后依旧是阻塞在这里了。
然后我们再去另外一个终端中
首先myfifo文件的大小依旧是0,说明数据并没有写入到myfifo中。
当我们从这个管道中读取文件的时候,就能够看到每间隔一秒就会接收到一个hello的消息了。
此时一个终端中的cat进程和另外一个终端中的echo进程就完成了通信。
以上我们就完成了现象的观察,下面我们就来理解这个命名管道。
对于理解我们先思考一个问题:如果两个不同的进程,打开同一个文件的时候,在内核中,操作系统会打开几个文件?
首先两个进程打开文件的时候的struct file对象肯定是一人一个,因为都需要这个file对象来管理打开的文件。但是对于这个被打开文件的操作方法,这个文件的文件缓冲区等等操作系统会给你维持几份呢?答案自然是只需要维持一份。因为对于os而言没有必要把相似的属性写两份,写两份还不方便os进行维护。操作文件的方法也自然只需要一套就够了,最重要的是文件的缓冲区也只需要一份就够了,那么为什么一份缓冲区就够了呢?os难道不怕数据再写的时候出现错乱吗?不要忘了此时你已经使用两个进程去打开同一份文件了,你在读写文件的时候,自己不加管理是一定会发生错乱的。
所以两个不相关的进程去打开同一个命名管道文件的原理图还是那张和匿名管道的图一模一样。
所以我们将它称之为命名管道的原因也就理解了,因为这个命名管道的原理依旧是基于文件的。我们知道两个进程进行进程间通信的前提条件就是我们的两个进程能够看到同一份资源,此时的这个命名管道很显然已经做到了这一点,但是,为什么我们还要使用管道呢?直接创建一个普通的文件,不也能达成上图中的那种而机构吗?当然没有这么简单,不要忘了普通的文件最终都是要往磁盘中刷新数据的,但是这里我们是要将一个进程中的数据写到另外一个进程中,自然是没有写到磁盘中的必要。两个进程只需要使用到这个文件的文件缓冲区即可,一个进程往文件缓冲区中写,一个进程往文件缓冲区中读。由此就诞生了我们的以p开头的管道文件。
这也是为什么,在刚刚的循环写入的时候,我们在开启一个终端区查看这个管道文件的大小发现依旧是0的原因。
因为os不会对这个文件进行刷盘,自然大小也就一直是0了。所以这个命名管道也就是一个内存级文件。
这个命名管道和匿名管道可以说原理是很相似的。
那么我们在思考一下,我们的两个不同的进程,再打开文件的时候,这两个进程是怎么知道和他通信的那个进程一定会打开这个文件的呢?以及为什么要打开同一个文件。
我们之前所说的匿名管道,父子进程怎么知道打开的是同一个管道文件呢?
因为父子进程可以通过继承的方式确定打开的是同一个文件。但是现在这里不一样了?如何保证呢?
原因也很简单因为这两个进程能够看到同路径下的同一个文件名。通过路径和文件名字,就能够通过储存这个文件的文件夹,拿到这个文件的inode编号通过indoe编号就能够通过文件系统将这个文件的内容和属性加载到内存的inode对象(这个inode是os为了储存某个文件的内容和属性建立的inode对象,不是前面说的inode编号(编号是用于在文件系统中找到这个文件的属性,再通过属性在磁盘中找到这个文件的内容,再将文件内容加载到文件缓冲区中))
所以只要两个进程能够通过看到同一路径下的同一文件名,那么这两个进程打开的一定是同一个文件。
又因为这个文件是p是内存级别文件,直接让两个进程进行通信即可。至于为什么因为我们知道对于os而言路径+文件名是具有唯一性的。此时我们的两个进程也就看到了同一份文件资源,进而实现了进程间的通信,因为这个原理依旧是建立在文件系统的基础上,并且这个文件依旧是存在名字的所以这被叫做是命名管道。因为这个文件不需要刷新到磁盘中所以进程间通信的效率也就高了。
首先使用这个管道进行通信的原理很简单,我们只需要让两个进程打开这个命名管道文件(因为在Linux中一切皆文件,所以打开文件很使用常规方法即可)一个去写一个去读就完成了通信。
既然是两个进程完成通信所以这里我创建了两个代码文件同时完成的makefile文件
我然后在client和server中写了一点简单的代码然后运行:
在这之后我们只需要运行client和server就能够形成两个不同的进程。
那么现在我们需要让两个进程完成通信首先第一步就是需要先创建一个管道文件。
我们在之前是在bash中使用命令创建的管道文件,但是现在我们需要在代码中创建一个管道文件。需要使用的就是下面的这个函数。
这个mkfifo函数就能够让我们的进程在代码中创建一个命名管道,其中的第一个代码表示的是这个管道文件所在的位置和你要创建的管道文件的名字(表明唯一性的那些点),而第二个则是这个管道文件的权限是什么(借助权限掩码完成的)。
这个函数创建成功返回值为0,不成功返回值为-1.
因为我们需要让两个进程都能看到这个管道文件的路径和文件名,所以我这里创建了一个comm.hpp其中暂时先存放着这个管道文件的路径,以及这个管道文件的劝降。以及两个进程需要的头文件都包含在这里面,因为两个进程都要包含这个.hpp文件。然后我们让server这一端来管理这个文件的创建。
以下是创建这个管道文件的路径和名字,在.hpp中写的内容
然后我们需要在server.cc中创建这个命名管道文件,使用的自然是mkfifo函数,这个函数如果返回-1,代表的是创建管道失败了,在这里错误信息就很重要了,因为我们之后要学会处理错误的信息,所以这里对于错误信息,我们需要使用函数打印出来。同时也是为我们下面的简单日志系统做一个铺垫。同时既然要处理错误信息,所以我们也需要规定一下错误码。
在server.cc中的代码:
到这里我们运行一下看是否能够创建出这个命名管道文件。
可以看到在运行了这个server程序之后确实是创建了一个命名管道文件。
如果你再次运行:
这里错误的原因就是这个文件已经存在了,退出码也是1。符合预期。
那么如果后面两个进程完成了对应的通信最后肯定是要将两个管道文件进行删除的,删除需要使用的接口就是unlink。
删除成功返回0,否则返回-1。下面我们再来测试这个函数的使用。这个函数因为也是具有错误返回的所以我们依旧是需要在枚举中增加一条新的退出码。
运行截图:
可以看到一开始这个文件确实是存在的,只不过之后就被删除了。
这说明我们已经能够创建一个管道文件并且能够删除了。
那么我们剩下的就是需要两个不同的进程一个打开写文件一个打开读文件,以完成两个进程的通信。如何打开读和写文件?不要忘了在Linux中一切皆是文件,在设计的时候,打开/读/写文件的接口已经被设计成为了一个通用的接口(open/read/write接口)。
这里我们让server作为读端。
以下就是server端得到信息的代码
然后我们需要处理的就是让client端通过命名管道文件写信息了。
下面就是我们的client中的代码了,这个进程只用于将内容写到管道文件中。
然后我们尝试来运行测试一下我们写的代码:
然后就能确定我们写的代码完成了正确的读取和写入。
但是这个代码还是存在问题的,首先就是我们的写端如果关闭了,但是我们这里的读端是不会正常退出的。所以我们需要在做一些改变,不要忘了管道通信的四个结论中的其中一个是,当写端关闭之后读端会读到0,根据这个结论我们就可以修改我们的代码。如下
这样在写端退出之后我们的读端自然也就是退出了。
在学习忘了匿名管道之后,再来学习命名管道就会发现很好学习,因为命名管道和匿名管道是很相似的,并且命名管道的代码理解起来比起匿名管道容易的多。因为命名管道的代码和普通的写文件是没有区别的。
但是我们的代码还是存在问题的,因为cin是以空格和\n作为分割符的。所以会出现你打了空格之后,读端读到两个信息的情况所以我们需要改成getline函数
除此之外在没有创建管道文件的时候你直接运行client是会直接报错的,因为我们将创建管道文件的方法写到了server中
虽然我们上面进行测试的时候,写端我们使用的是ctrl+c退出的,但是不影响,这样退出之后,管道文件的写端依旧会被关闭然后写端就会读到0,也就会关闭fd和删除管道文件后自己退出。当然你也可以让这个管道文件一直存在,然后在打开文件的那里修改一下打开文件的方式,让其修改为如果文件不存在创建,存在就直接打开,那么这个myfifo的管道文件就可以一直存在了,但是我这里就不这么设计了。
下面我们再给我们的代码增加一些东西。首先就是当我们的两个端打开管道文件之后打印一下消息。
就只是在两端打开文件的后面增加一个cout而已就不上图了。
当我们运行了serve之后发现并没有打印出信息,但是myfifo这个文件已经创建出来了(创建代码已经完毕了),说明我们的server端在open这个管道文件那里卡住了。
当我将client也运行起来之后,两边才打印出了打开文件成功的提示信息。
这说明了:
我们的server端会等待写入方打开之后,自己才会打开文件,向后执行
为什么呢?原因也很简单,现在你要进行文件内容的读取,但是文件连写端都没有你凭什么进行读取。反正也是一样的。
也就是在上面的两种情况(只有一段就绪,open会进行阻塞)。
下面我们再对我们的代码进行一些简单的调优。
我们将我们的创建管道文件的代码放到一个类的构造函数和析构函数中。
这个类存放在.hpp文件中
之后我们就可以将server.cc中的创建和和删除管道的代码删除了,只需要在server文件中创建以恶搞Init对象就能自动的完成对管道文件的建立和删除。
下面我们来测试代码是否可以运行。
检测结果可以运行。
那么到这里我们的代码就变得和文件操作没有任何的区别了。
到这里我们的命名管道的代码就完成了。
那么我们的命名管道也建立一个简单的进程池呢?答案自然也是可以的。并且命名管道去写简单的进程池比匿名管道还要简单一些(单论代码实现)。
命名管道的原理和匿名管道的原理都是一样的,只不过我们的命名管道能够做到不相关进程之间进程的通信而已。
下面我们需要为我们上面写的那个代码增加一个简单的日志系统,既然要完成的是一个日志系统,我们首先就需要理解什么是日志呢?
首先我们的服务是要不断的往某些地方不断地输出信息,例如会打印到屏幕,或者是写到某一个文件中去。这些日志信息能够帮助我们记录服务运行的信息方便我们后期进行排查。
虽然日志并没有严格的输出要求,但是一般而言一个日志也是具有自己最基本的几个组成部分的。
在一个比较完善的服务运行过程中,肯定是会出现一些问题的,这些问题根据严重的程度不同我们的做法也是不一样的。
常见的日志等级如下:
info : 常规消息
Warning:这个信息一般而言不会影响服务的运行,但是有必要要让用户知道,否则可能会造成某些问题的出现。
Error :比较严重了,可能需要立即处理,但是也有可能这颗Error是不会影响我们的服务继续往下运行的。
Fatal:致命问题,服务无法继续往下运行
Debug:这个信息正常情况下我们不需要但是在调式的运行中会需要。
这就是一般的日志等级的概念。
下面我们就为我们的代码新增一个简单的日志函数。刚好我们刚才的代码中也是存在一些必要的错误信息,退出信息,正常信息的输出的。
首先我们增加一个新的hpp文件log.hpp
我们实现这个函数使用的方法是可变参数。那么什么是可变参数呢?这里举一个简单的例子,假设我们这里存在一个这样的函数:
这里我要求一个n数的求和。而我们在这里需要使用我们的可变参数的话我们是需要使用若干个宏的。如下的这些宏。
现在我们要求n个数的和但是这些数我们不知道是多少。所以我们就需要使用到这些宏了。首先要使用可变参数首先必须要存在一个va_list结构。
这个va_list其实就是一个char *的结构。因为无论是c/c++,只要是调用函数无论这个函数的参数是可变的还是固定的,在函数调用的时候一定会对函数的参数从右往左进行压栈。这个栈就是函数特有的栈,将参数压入到这个栈中。
回到这个示例函数,我们要使用这个可变参数列表
那么这里的va_start是什么意思呢?
我们通过下面的这张图来解释:
假设现在是sum(3,1,2,3)
现在我们知道了固定参数为第一个3,但是在压栈的时候这个n是最后一个压入栈中的,上图中绿色的3个就是前面压入栈中的123,那么最后压入的3我们换一个颜色,也就是红色那个,那么va_start(s,n)是在做什么呢?
那么&n也就是将n的地址取出来了,而va_start也就是让s = &n+1,也就是让s指向了n前面的那个参数,也就是非固定参数。
这个位置也就是可变参数的开头处,那么以后我要拿到这个可变参数的值,只需要知道这个地方的参数的类型是什么强转一下就可以完成了,然后s继续往上。基本的原理就是这个。
所以一般而言在可变参数前面,至少一定是要有一个具体的参数的。
主要目的就是为了去找起始地址。那么下面我们继续来完成我们的例子
这样就完成了这个可变参数的函数。
下面我们来测试一下:
测试结果:
正确
那么如果你在使用的时候这样使用sum(3,1,3.14,'c');这样去使用就会出错,因为我们的这个sum函数在sum那里是严格的将其认为是int处理的,这也是为什么printf函数同样作为一个可变参数的函数,要使用%d,%s等等进行参数的控制。所以在实现printf函数的时候,肯定是进行了字符串解析的。这里为了简单我们的sum函数就没有这么做了。
现在回到我们的日志函数那里,既然我们要实现我们的日志函数首先就需要具有我们的日志等级。
下面我们要如何获取时间呢?
在Linux中有很多种的方法去获取时间其中time函数就是获取时间的一个方法。
time函数单纯的打印时间戳。
除此之外gettimeofday这个系统调用接口也是获取时间的方法。
其中tz这个代表的是时区,我们直接缺省为null即可。
而前面的这个tv就是我们获取到的时间结构体,它的内容如下:
储存的是秒和微秒。
除此之外还有一个localtime也是获取时间的一个函数。
这个函数需要的是一个time_t的类型,而这个time_t刚好就是time函数的返回值类型。这localtime会将time_t转化成为一个struct tm的结构。struct tm结构如下:
刚好里面储存的就是我们的年月日。
所以这里我们就先使用time函数获取时间戳,再使用localtime函数将这个时间戳转化成为我们日常使用的时间。
下面我们先来使用一下我们的这个时间函数。
但是很明显打印出现了错误,为什么呢?因为ctime->tm_year
表示年份(从 1900 年开始计数),ctime->tm_mon
表示月份(范围从 0 到 11),ctime->tm_yday
表示一年中的天数(范围从 0 到 365),ctime->tm_hour
表示小时数(范围从 0 到 23),ctime->tm_min
表示分钟数(范围从 0 到 59),ctime->tm_sec
表示秒数(范围从 0 到 59)。
所以年份处需要加上一个1900,月份和日期也需要加1.
这样就正确了。
那么现在日志的时间就已经有了,日志的等级也有了,但是现在还有可变参数呢?难道我们要自己对可变参数进行处理吗?当然不需要,我们这里可以使用一个函数,首先我们来看一下snprintf函数
这个函数是对固定的参数进行处理的但是在这个man手册的下面还存在一个。
vsnprintf这里我们要使用的就是这个函数,这个函数能够将可变参数按照你显示的格式(format),将其写到str中。
而snprintf则是将固定的参数格式化处理到str中。
首先我们的日志信息是存在两部分的,第一部分是固定的部分也就是日志信息的等级+时间,我们首先来完成这一部分。
首先我们要将传递过来的level转化成为一个字符串的日志等级,所以我们需要这样的一个函数。
然后我们需要将默认部分也就是[日志等级][时间]写到一个缓冲区中(也就是一个普通的字符数组中去)。
以上我们就将默认的部分写到了我们的leftbuff数组中去。
然后我们来写我们的自定义部分也就是由用户传递过来的部分
最后我们将左半部分和右半部分组合起来:
之后我们要将log.txt这个数据写到文件中还是其它什么都是可以做的。
这里我就先将其打印出来。
然后我们将我们的这个函数放到server.cc中来测试一下。
这里我先测试一下一个日志信息的打印是否正确,这里我会先将管道文件创建完毕之后,错误退出,让管道文件保留之后,再运行server,故意造成这个错误。
测试结果:
确实是打印出来了一个简单的日志信息。
现在我再讲服务端的这些日志信息一起放出来:
再来正常的运行一下我们的这个简单代码。
运行成功。
下面我们再来优化一下我们的这个日志函数。即现在我们的这个日志函数只能往显示器打印,我们能否自定义一下这个日志打印的方式呢?
当然可以。
首先我们就要设定三个不同打印的选项:
然后我们将我们刚刚完成的这一系列函数封装到一个类中
然后我们要提供一个函数让其能够修改我们对应的这个打印的方式。
然后下面是基本的框架:
现在已经完成了基本的打印,然后我们去完成往单个文件中打印。
既然要往一个文件中打印,那么我们肯定需要一个文件名字,这里我们再定义一个文件名字的宏。
然后我们来完成单个文件的写入函数:
然后为了让我们的分类写入能够复用我们的单个文件写入的函数,我需要修改一下:
将默认的FILENAME作为参数传递给我们的printfonefile。
然后我们再来写我们的分类写入函数:
下面我们再将我们的这个日志函数重新修改到我们上面的那个代码中,
这里就只显示一部分了。
然后是运行:
我这里写入的时候没有增加换行符号,所以导致了连贯在一起,待会我会修改但是这样也说明了我们的单个文件写入时没有问题的,下面我们再设置成分类写入。
现在单文件和分类文件写入都没有问题了。
最后的往显示器打印我这里就不测试了。
但是现在这样还是存在一个问题那就是这么多的打印文件放到一起,会让文件显得繁杂,那么我们能否将这些文件都放到一个路径的文件夹下面呢?当然可以,首先我们的文件系统判断一下是否是打印到显示器上的,如果不是那么我们就先在当前路径下创建一个文件夹。
这个工作我们就可以放到Enable函数中:
如果修改后的模式不是往显示器打印就创建一个文件夹。
下面我们再来修改一下创建的日志文件的路径。
因为我们的分类打印函数是对单个文件打印的复用所以这里我们只需要这样修改一下:
即可。下面我将已经创建的日志文件全部删除重现来实验一下:
然后我们修改一下我们的Makefile文件让其再清理的时候会将这个文件夹也一起清理。你也可以不写,这里为了我便于测试所以我会将log这个文件夹删除
最后我们再来看一下单个文件的写入是否存在问题
没有出现问题。即使你再次运行也没有出现问题。
现在我们已经能够完全定制化我们的日志了,但是这样我们的调用就不好用,所以我们再做最后一件事情。
我们将我们的这个logmessage函数直接使用重载()替代。
这样外部的函数在调用的时候就方便了。
最后的测试图片我就不放置了,到这里我们的简单的日志函数就完成了。
最后是完成的代码文件。如果有需要自己拿取便可。如果发现了任何的错误欢迎在评论区指出,希望能对阅读的您有所帮助。
#pragma once
#include
#include
#include
void LoadingTask(std::vector> *tasks)
{
// 添加任务函数示例
std::function task1 = []() {
std::cout<<"任务1逻辑"< task2 = []() {
std::cout<<"任务2逻辑"< task3 = [](){
std::cout<<"任务3逻辑"< task4 = []() {
std::cout<<"任务4逻辑"< task5 = []() {
std::cout<<"任务5逻辑"< task6 = []() {
std::cout<<"任务6逻辑"< task7 = []() {
std::cout<<"任务7逻辑"<push_back(task1);
tasks->push_back(task2);
tasks->push_back(task3);
tasks->push_back(task4);
tasks->push_back(task5);
tasks->push_back(task6);
tasks->push_back(task7);
}
#include"Task.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
const int processnum = 5;//我们默认的创建的子进程的个数
std::vector> task; // 声明一个全局的task对象,让父子进程都能够看到这个任务列表
//首先就是将对应的管道描述起来
class channel
{
public:
channel(int cmdfd,pid_t slaverid,std::string& slavername)
:_cmdfd(cmdfd),_slaverid(slaverid),_slavername(slavername)
{}//创建对应的构造函数用于创建对应的channel对象
int _cmdfd; // 发送任务的文件描述符号,即我们的父进程往什么地方发送对应的指令,能够让我们的子进程得到对应的命令
pid_t _slaverid; // 需要发送给哪一个子进程
std::string _slavername; // 子进程的名字 -- 方便我们打印日志
};//到这里我们就将一个管道描述完成了,下面就是要将创建的管道的对象管理起来
//管理
void worker()
{
while(true)
{
int cmdcode = 0;
// 在完成了重定向之后我们的子进程只需要从标准输入中读取对应的信息即可
//这里我们在规定每一个父进程只会写四个字节的内容,而我们的子进程也就直接读取四个字节的内容即可。
int n = read(0,&cmdcode,sizeof(int));
if(n == sizeof(int))//如果这里没有读到四个字节的内容那么就继续去读
{
if(cmdcode>=0&&cmdcode* channels)
{
std::vector oldfd;//创建一个储存之前创建的写端的vector
for(int i = 0;ipush_back(channel(pipefd[1],id,name));
}
}
void quict(std::vector& channels)
{
for(int i = 0;i& channels)
//因为这里只是单纯需要一个简单的输入即可,所以使用的是const &
{
for(const auto& e:channels)
{
std::cout<& channels)
{
//以下选择进程的方式是以随机数的方式选择的我们也可以使用轮询的方式
/*
while(true){
//选择任务
int cmdcode = rand()%task.size();//现在我们已经存在了任务,选择任务就直接从stl从选取下标即可
//选择进程
//第一种方法:随机数
int peocessfd = rand()%channels.size();//模上vector的大小防止出现选择不存在的进程
//第二种方法轮询:
//发送任务
std::cout<<"father say:"<<" cmdcode: "<>cmdcode;//让用户来选择任务
if(cmdcode == 0)
{
break;//代表用户选择了退出
}
if(cmdcode<0||cmdcode>=task.size())
{
std::cout<<"error enter"< channels;//使用一个stl容器就能将这些管道管理起来,此时管理这些管道就变成了对这个vector的管理
// 1.初始化
//如何做初始化呢?也就是根据你要创建的子进程的个数创建对应的子进程同时为每一个子进程创建属于自己的管道即可
Initchannelse(&channels);//将我们的channelse数组传递过去就能够完成创建子进程和管道的工作。//存在bug
//Debug(channels);
//sleep(1000);//为了不让父进程退出让我们的监控脚本能够看到我们对应的这些创建出来的进程。
//2.控制子进程
ctrlworker(channels);//将对应的控制子进程的任务封装成为一个函数
//这里假设我们的父进程会发布100次任务
//3.清理收尾
quict(channels);
return 0;
}