Linux系统编程学习 NO.17——管道通信、匿名管道、命名管道、日志模块

什么是进程通信

进程通信指的是两个及两个以上的进程之间数据交互。在前面进程基础概念的学习中,了解到了fork()之后父子进程共享数据和代码,也就意味着父子进程之间可以通信。但是,进程特性中有独立性的概念,意味着两个进程通信的成本是比较高的。成本主要体现在需要打破独立性,让需要通信的多个进程能够彼此看到同一份系统资源。

为什么要进程通信

通常来说有以下需求才需要进程通信,如进程间基本数据的交流、下达某种指令、协同完成某项任务等等。总的来说进程间通信的核心目的:实现协作、资源共享、提高效率、增强模块化和系统扩展性。

如何做到进程通信

进程通信的本质就是让不同的进程看到同一份“资源”。这份“资源”就是某种形式的内存空间,它由操作系统提供。为什么不是进程提供呢?因为进程具有独立性,如果由进程提供势必会导致进程的数据不安全,并且进程通信的稳定性也是问题。进程访问共享资源的过程本质是访问操作系统,操作系统不相信任何人,它对外提供系统调用接口来让进程间能操作这块共享资源。 从对应的底层设计到接口的细节设计都有操作系统完成。

一般操作系统会有独立的通信模块,同行隶属于文件系统。Linux 的通信模块是IPC模块。这个模块由很多的实验室进行参与对应的标准和通信方式。

谈一谈定制标准的周边话题。制订标准往往就意味着在某一领域的话语权,它决定了巨大的利益。一些通信基带的标准由高通把持着,通信巨头高通它进25年Q1季度的专利收入就约15.2亿美金。所以一些行业巨头公司、组织以及个人,对于某一个方面标准的制订是非常渴望的。

将话题扯回进程间通信的标准,最终根据多方的竞争, 进程间通信的标准大体上有两种,一种是system V,另一种是posix。systemV 标准用于本机内部的进程间通信的,system V 消息队列、system V 信号量、system V 共享内存等。而posix标准通常用于网络通信,如消息队列、共享内存、信号量等。在这两个标准出现之前,进程间通信采用的是基于文件级别的通信方式也就是管道的方式。

管道

管道是Unix操作系统的一种进程间通信方式,而Linux是一种类Unix操作系统,Linux天然是支持管道的。在Linux的使用中已经对管道有过接触,在打监控脚本时 使用的 | 就是管道对应的指令。下面先介绍一下管道的原理。

管道的原理

首先,一个进程它有自己的task_struct结构体描述对应的属性字段,而其中一个字段是files_struct结构体,它里面维护这一个文件描述符表(fd_array)。fd_array的下标就是当前进程的打开的文件描述符,它的内容是一个struct file指针,这个指针指向一个描述文件的结构体。struct file内有许多的属性字段,其中一个关键的字段是当前被打开文件内存级别的缓冲区。当前进程如果fork创建出一个子进程时,此时父进程的代码和数据子进程也会拷贝一份,这也就意味着对应的files_struct子进程也会有一份儿。势必会导致对内存级别的缓冲区父子进程都可以看到并使用。当父进程想给子进程下达某种指令时,将对应的指令写到这个内存级别的缓冲区,子进程就可以从中进行读取了。这也是管道最朴素的原理。
在这里插入图片描述
有了上面朴素的认知后,在更详细的谈一谈具体的父子进程使用文件描述符创建管道的原理。首先,父进程以读写同时方式打开这个管道文件,这样就有两个文件描述符来指向对应的管道文件。随后,fork创造出子进程,子进程将父进程的文件描述符表也拷贝一份,子进程也有了读端和写端指向对应的管道文件。最后,根据数据流动的方向决定(以父进程数据流向子进程为例),将父进程的读端fd关闭,子进程写端fd关闭。这样一个父进程数据流向子进程的管道就创建好了,反之同理。

在这里插入图片描述
结合上面的认知后,再谈谈具体管道的原理。首先,为什么规定管道只希望进程单向通信呢?因为设计者希望管道通信的方式足够简单。因为这样管道通信依旧可以采用文件系统的那一套东西,不需要用更多的字段信息来对管道读写端的标志位等字段进行描绘以及管理。那为什么还需要关闭子进程父进程对应的读写端呢?从技术角度是可以不需要的,但是可能存在误操作的问题,所以最好关闭对应的读写端。而单向通信的方向使用上层的应用者来决定的。以子进程数据流向父进程为例,父进程依次以读方式打开管道文件、再以写方式管道文件。fork创建子进程后,对应的fd表内的也指向了对应的管道文件(特别是文件缓冲区)。随后,父进程关闭写端fd,子进程关闭读端fd,并维护好读端和写端的引用计数。

管道通信之所以有管道之名是因为生活中所有的管道都是以单向进行物质流动的。如水管、石油管道等。如果你希望父子进程可以实现双向通信能不能做到呢?答案是可以的,你创建两个管道不就能做到对应父子进程的双向通信了。为什么需要fork创建出子进程俩进程才能通信呢?因为进程具有独立性,只有将父进程对应的代码和数据拷贝给子进程后,父子进程才能同时指向管道文件进行数据通信。当然不仅是父子关系能够做到管道通信,像兄弟关系、爷孙关系等具有“血缘”关系的进程都能够进行管道通信。

管道相关接口的介绍

pipe是Linux的系统调用接口,用于创建一个管道,使用它需要包含unistd.h头文件。返回值是一个整型,创建成功返回0,创建失败返回-1,对应的错误码被设置。参数部分需要的是个整型数组。(int pipefd[2])。在C语言的学习中知道,参数部分的设计关于数组传参是不需要知指明对应的元素个数,因为编译器在编译时是忽略它的。这么设计主要是为了提醒用户。并且这个参数是输入型参数,它对应的传入的两个fd根据上面原理的认知可以知道一个是读端的一个是写端的。

下面写一份demo代码,对于上述介绍进行一个实操。并且验证一下上面原理部分提到的通常打开的是3、4号文件描述符。3号fd通常为读端fd,4号fd通常为写端fd。
在这里插入图片描述

编码实验

下面先搭建出一个最基本的父子进程使用管道通信的代码框架。首先需要创建管道,然后创建出子进程,并让父子进程执行对应的通信逻辑。父进程执行读方法,子进程执行写方法。将子进程的读fd关闭,将父亲的写fd关闭。随后执行对应的读写方法,然后再将剩下的fd关闭。
在这里插入图片描述

在这里插入图片描述
下面就实现Write和Read方法。Write()方法实现思路如下,需要定义一个用户级缓冲区,然后将用户级缓冲区的数据通过系统调用write写入到操作系统的文件缓冲区中。
在这里插入图片描述
Read实现思路如下,定义一个用户级缓冲区,通过系统调用将系统文件缓冲区的数据读取到用户缓冲区里。这里需要注意的是由于在写入时并没有吧对应的内容当做字符串,读取上来时,是把对应的内容当成字符串的,所以需要再末尾处加上’\0’。

在这里插入图片描述
在这里插入图片描述
从上面编码实现后可以观察到,父子进程间管道通信进行了两次的拷贝,子进程将用户缓冲区的数据拷贝到内核缓冲区。父进程将内核缓冲区内的数据读取到了用户缓冲区。期间,操作系统起到媒介的作用。

通过上面编码中,父进程并没有sleep等待子进程。但是,父子进程依旧是一人输出一句消息在显示器上。这是因为进程在管道通信中有协同的动作。

管道通信是面向字节流的。下面简单修改一下代码,让父进程sleep休眠几秒,子进程不sleep疯狂的写入数据。
在这里插入图片描述
观察运行结果可以看到,子进程疯狂写入了1616次,而父进程一次性读取了一批的数据。对于父进程而言,你子进程写了多少的数据我不关心,我一次就从管道里读取数据,能读取多少就读取多少。父进程不关心你管道内的数据格式是怎么样的,这个由上层用户来决定。他只关心数据是依次一个字节一个字节的读取到对应的用户缓冲区的,具体的边界也是根据上层来定。

管道具体的大小是多少?可以通过ulimit -a 来进行查看,当前ubuntu系统下管道文件的大小是4KB。

在这里插入图片描述
再编码看一看。
在这里插入图片描述

在这里插入图片描述
通过编码可以看到管道文件的大小是64KB。为什么不一样呢?这是因为在Linux2.6.11内核版本之前规定管道的大小事4096Byte,在2.6.11版本之后,大小为65536Byte。查看文档时发现,pipe_buff这个管道文件的大小依旧是4096,并且说明对它的读写必须是原子的。什么是原子性?简而言之就是假设父进程要往管道里写一串字符如hello world。而管道文件必须要保证hello world一定要写入到对应的管道文件中,写入时任何进程都不能写入/读取当前管道文件。

管道通信有四种情况,其中读写两端正常,如果管道为空,读端就要阻塞。读写两端正常,如果管道为满,写端就要阻塞。

还有一种情况是读端正常读,写端终止,读端就会读取到read的返回值0(文件结尾),不会被阻塞。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
如果是写端正在写入,而读端已经关闭,那么操作系统势必要将写端也给杀死,如何杀死这个进程呢?通过13号信号来杀死这个异常写入的进程。因为操作系统不会做低效且浪费系统资源的动作,如果有这类动作就是BUG。下面编码实验验证一下上述结论。

在这里插入图片描述
在Read方法内判断读取5s后结束,这段编码就不做截图。谈谈主要逻辑部分,子进程不断写入,父进程读取5s后,直接被关闭。此时,子进程收到13号信号,变成僵尸进程等待回收,最后,子进程被回收,父进程退出。

管道的特征

1、具有“血缘”关系的进程才能进行管道通信。2、管道通信只能单向通信。3、为了保护管道的数据安全,进程间需要协同动作,即同步于互斥。4、管道是面向字节流的。5、管道是基于文件的,文件的生命周期是随进程的。

管道的应用场景

1、文本的处理与过滤

cat xxx.txt | tail -10 | head -20

2、数据的筛选与提取

ps ajx | grep xxx

3、命令的组合

ls -l | sort -k5 -n | tail -n 5 #列出当前目录文件,按文件大小排序后显示最后 5 个

4、进程间通信(命名管道)

mkfifo mypipe  # 创建命名管道
echo "Hello" > mypipe &  # 向管道写入数据
cat < mypipe  # 读取管道数据

写一份简单的进程池

进程池就是提前向操作系统申请一批的进程,提供对应的接口。这样就可以避免频繁使用系统调用带来的效率上面的损耗,提高整体运行的效率。首先,就是要创建出一批的进程,然后,创建出管道让对应的父子进程之间能够进行通信,如任务的派发。

第一步设计一个类来描述每一个进程池对象所拥有的字段信息。

在这里插入图片描述

第二步就是用一个容器来组织它,用vector、list或是unordered_map都可以。紧接着就是完成一项初始化的任务。用pipe创建管道,让父子进程能够通信。fork创建出子进程并使用vector来管理对应的进程池对象。
在这里插入图片描述
在这里插入图片描述
完成初始化工作后,就到父进程控制子进程部分的实现。父进程选择特定的任务,本次实验中采取随机数的方式来选择任务的派发。对于子进程的选择可以采用轮询的方式也可以采用随机数选取的方式,这样能够比较均衡的负载。采用write接口将对应的任务指定派发给对应的子进程。
在这里插入图片描述

子进程采用read系统调用接口来读取父进程在管道中写入的数据。根据对于任务码的读取,回调对应的方法。

在这里插入图片描述

最后就是退出清理工作,需要将管道文件描述符关闭,并且让父进程等待子进程退出工作。
在这里插入图片描述
其实,在进程退出清理接口中,用两个循环来进行关闭文件描述符和等待回收子进程是因为在子进程被创建时,由于父进程的打开的子进程都会被继承下去。这也意味着当创建十个子进程时,第十个进程也会从父进程那里拷贝到前面九个进程的管道文件的读端。虽然,代码逻辑上控制了只有父进程能够写入。但是,每个子进程进程管道文件的读端都是被多个进程所共享。这也就是说当正向遍历channels数组关闭文件描述符的同时并等待回收子进程时,程序会直接卡住。为什么两个循环分别关闭和等待回收就不会有问题呢?因为当最后一个子进程的管道文件被释放后,它所指向前面管道的读端的引用计数会–,这样退出就不会有问题。当然,倒着遍历释放可以完美解决这个问题。

在这里插入图片描述

这么做其实“治标不治本”,具体的解决方案是在初始化部分,用一个vector将对应已经被父进程打开的fd存储到容器中,当fork创建子进程时,让子进程将历史上以及被打开的fd给close掉。这样就能保证管道文件被一个读端和一个写端读取。

在这里插入图片描述

命名管道

认识命名管道

上面介绍的管道都是就有血缘关系的进程间进行通信。而大部分场景下进程间通信的进程们不具有血缘关系。这类进程通信就需要借助命名管道。下面就直接看看它是怎么样的。

使用指令mkfifo + 命名管道名称就能在当前目录下创建出一个匿名管道文件。通过ls -l指令就能查看到当前myfifo是文件类型为p,即管道文件。

在这里插入图片描述

当我启动两个终端,一个终端用echo命令不停地向命名管道myfifo里追加写入字符串内容。而另一个终端用cat命令读取myfifo的字符串数据。可以看到两个终端的不同进程通过这个命名管道文件达成了数据的通信。当我们查看这个myfifo文件的大小时,发现是0个字节。这是因为命名管道文件是内存级别的文件,并不占有磁盘空间。

在这里插入图片描述

观察到上面的现象,两个不同的进程打开了同一个文件。那这两个进程是如何确定彼此打开的是同一个文件呢?匿名管道是通过继承方式让子进程和父进程看到同一份文件。命名管道则需要让两个进程看到同一个文件名,而由于操作系统中,文件名不具备唯一性,所以需要再加上路径。命名管道则需要让两个进程看到同路径下同一个文件名。

编码实现

下面就进行编码实现两个不相关进程使用命名管道通信。那就需要写两份代码生成两个可执行文件。这里分别为server.cc 和 client.cc 。紧接着需要一个放共同 头文件以及一些宏定义等的头文件 comm.hpp。 最后写一个Makefile。这样前置工作就完成了。

在这里插入图片描述

下面解决一下管道资源相关的问题。创建管道资源,需要借助系统调用mkfifo,用它需要包含 <sys/types.h> 和 <sys/stat.h>。第一个参数传递命名管道的路径,第二个参数传对应的权限字段。

在这里插入图片描述
紧接着就是用open调用打开对应的管道文件。
在这里插入图片描述
紧接着就是写通信的代码,定义局部缓冲区从系统缓冲区读取数据。当读取到0时,表示写段关闭,相应的读端也就关闭了。
在这里插入图片描述

最后就是释放管道文件,以避免内存泄漏。
在这里插入图片描述

下面设计一下客户端,首先,需要打开对应的管道文件。然后在将对应的数据通过键盘读取到对应的string字符串中,再用write写到对应的管道文件中。这样读端就能看到写端所写的内容。读写端单向通信得以建立。最后,结束通信时需要关闭打开的文件,避免内存泄漏。

这里读取键盘数据时有一个细节问题,用getline可以避免cin读取时将空格和换行符给直接读取,导致打印的信息比较混乱的问题。
在这里插入图片描述

下面就是实验截图。

先运行server端,我们发现在open打开管道文件时,由于client还没运行,所以server端阻塞在open这里,等待client启动。当./运行client时,双方通信构建成功,此时代码就会走到双方通信的逻辑中。
在这里插入图片描述

在这里插入图片描述

日志模块

日志是程序不可或缺的一部分,它负责监视程序在运行中所出现的各种情况。通过日志可以完成程序的调试、错误的修复以及性能提升等等。通常它有以下关键的信息字段,如时间、等级、内容、文件名称以及数据(如代码行号等关键数据)。下面就模拟实现一个简易版本的日志模块来感受一下它。

首先,在日志等级的定义上采取以下几个等级。debug表示该条日志是调试信息。info表示常规日志信息。warning表示告警日志信息,需要关注并在何时时候处理。error表示严重错误,需要尽快处理。fatal表示致命错误,程序崩溃需要立刻处理。

在这里插入图片描述
下面就需要解决获取时间的问题。采取获得时间戳,并将时间戳转化成我们能够比较好理解的年月日时分秒的格式的时间。采用C函数time来进行时间戳获取,随后用localtime接口将时间戳转化一下。然后time_t类型的时间戳转化成 struct tm * 类型。struct tm* 内部有对应的成员变量存储年月日时分秒等时间单位转化后的数据。需要注意的是struct tm* 内部的年是从1900开始计算,转化成当前年份需要+1900,以及月份是以0月开始计算的,转化成当前月份需要+1。
在这里插入图片描述

下面就要实现一下核心的接口。首先,获取当地时间。然后将日志分为两个部分,一个是标准模板部分,另一个是用户输入数据部分。合并两个部分后,再将日志打印到显示器中。
在这里插入图片描述
将上面实现的server端代码的代码进行一下改造,使用自定义的日志模块。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

核心接口实现完成后,在进一步将它进行一下封装。使用一个class来对日志模块进行描述。相对应的实现多种的方法,如打印到显示器,输出到文件,以及对不同等级日志做分类存储。所以,需要定义成员变量来表示日志输出方式,由于日志会涉及分组存储,还需要一个成员变量存储日志目录的路径

下面是构造函数以及控制日志输出方式的接口
在这里插入图片描述

在这里插入图片描述
实现将日志输出到一个文件以及输出到多个文件进行分类的接口。
在这里插入图片描述

最后,实现一个接口将对应不同方式的log进行输出。
在这里插入图片描述
下面是对核心接口的小小优化,将它重载成operator()。
在这里插入图片描述
下面是实验的结果。
在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

玩铁的sinZz

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值